feat(embeds): add embed.classroomio.com public widget host
Serve widgets from short public URLs via tenant-router with R2-backed assets and runtime API_URL injection, so copy-paste snippets hide infra paths. Update widget payloads, CSP, upload purge URLs, and dev-only API inject; document format/stage workflow in AGENTS.md and fix ui-prefix-check YAML syntax.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { EMBED_WIDGET_ROUTES } from '../../../packages/utils/src/constants/embeds';
|
||||
|
||||
const EMBED_CONTENT_TYPES: Record<string, string> = {
|
||||
html: 'text/html',
|
||||
js: 'application/javascript',
|
||||
mjs: 'application/javascript',
|
||||
json: 'application/json',
|
||||
css: 'text/css',
|
||||
map: 'application/json',
|
||||
txt: 'text/plain'
|
||||
};
|
||||
|
||||
function contentTypeForObjectKey(objectKey: string): string {
|
||||
const dot = objectKey.lastIndexOf('.');
|
||||
if (dot === -1) return 'application/octet-stream';
|
||||
const ext = objectKey.slice(dot + 1).toLowerCase();
|
||||
|
||||
return EMBED_CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isBootstrapScriptKey(objectKey: string): boolean {
|
||||
return Object.values(EMBED_WIDGET_ROUTES).some((route) => route.scriptKey === objectKey);
|
||||
}
|
||||
|
||||
function cacheControlForObjectKey(objectKey: string): string {
|
||||
if (objectKey.endsWith('.html')) {
|
||||
return 'public, max-age=300';
|
||||
}
|
||||
|
||||
if (/\.(js|mjs)$/.test(objectKey)) {
|
||||
if (isBootstrapScriptKey(objectKey)) {
|
||||
return 'public, max-age=300, must-revalidate';
|
||||
}
|
||||
|
||||
return 'public, max-age=31536000, immutable';
|
||||
}
|
||||
|
||||
if (objectKey.endsWith('.css')) {
|
||||
return 'public, max-age=3600';
|
||||
}
|
||||
|
||||
return 'public, max-age=3600';
|
||||
}
|
||||
|
||||
function resolvePublicWidgetKey(pathname: string, searchParams: URLSearchParams): string | null {
|
||||
const route = EMBED_WIDGET_ROUTES[pathname as keyof typeof EMBED_WIDGET_ROUTES];
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('pageKey' in route) {
|
||||
const pageParam = 'pageQueryParam' in route && route.pageQueryParam ? route.pageQueryParam : 'key';
|
||||
if (route.pageKey && searchParams.has(pageParam)) {
|
||||
return route.pageKey;
|
||||
}
|
||||
}
|
||||
|
||||
return route.scriptKey;
|
||||
}
|
||||
|
||||
export async function resolveEmbedObjectKey(url: URL, assetsBucket: R2Bucket): Promise<string | null> {
|
||||
const pathname = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!pathname || pathname.includes('..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Internal paths for lazy chunks (browser-loaded, not customer-facing).
|
||||
if (pathname.startsWith('embeds/')) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (!pathname.includes('/')) {
|
||||
const publicKey = resolvePublicWidgetKey(pathname, url.searchParams);
|
||||
if (publicKey) {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
for (const widgetName of Object.keys(EMBED_WIDGET_ROUTES)) {
|
||||
const candidate = `embeds/${widgetName}/${pathname}`;
|
||||
const head = await assetsBucket.head(candidate);
|
||||
if (head) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function needsApiUrlInjection(objectKey: string): boolean {
|
||||
return isBootstrapScriptKey(objectKey) || objectKey.endsWith('/embed.html');
|
||||
}
|
||||
|
||||
export function injectApiUrl(body: string, objectKey: string, apiUrl: string): string {
|
||||
if (!needsApiUrlInjection(objectKey)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const normalizedApiUrl = apiUrl.replace(/\/$/, '');
|
||||
const snippet = `window.CIO=window.CIO||{};window.CIO.apiBaseUrl=${JSON.stringify(normalizedApiUrl)};`;
|
||||
|
||||
if (objectKey.endsWith('.html')) {
|
||||
return body.replace('<head>', `<head>\n <script>${snippet}</script>`);
|
||||
}
|
||||
|
||||
return `${snippet}\n${body}`;
|
||||
}
|
||||
|
||||
export function embedResponseHeaders(objectKey: string): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', contentTypeForObjectKey(objectKey));
|
||||
headers.set('Cache-Control', cacheControlForObjectKey(objectKey));
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* - *.myclassroomio.com/* — free-tier tenant sites (acme.myclassroomio.com)
|
||||
* - myclassroomio.com/* — apex (301 → classroomio.com)
|
||||
* - app.classroomio.com/* — admin dashboard
|
||||
* - embed.classroomio.com/* — public embed widgets (`/{name}` → R2 `assets`)
|
||||
* - <BYOD>/* — customer-owned domains via Cloudflare for SaaS
|
||||
*
|
||||
* Per request, the Worker decides upstream by path:
|
||||
@@ -35,14 +36,22 @@
|
||||
* Domain attribute upstream, so they scope host-only on every domain.
|
||||
*/
|
||||
|
||||
import { embedResponseHeaders, injectApiUrl, needsApiUrlInjection, resolveEmbedObjectKey } from './embed-assets';
|
||||
|
||||
interface Env {
|
||||
DASHBOARD_UPSTREAM_HOST: string;
|
||||
API_UPSTREAM_HOST: string;
|
||||
APEX_REDIRECT_TARGET: string;
|
||||
/** Hostname that serves embed widget assets from R2 (e.g. embed.classroomio.com). */
|
||||
EMBED_HOST: string;
|
||||
/** Public API origin injected into embed scripts (e.g. https://api.classroomio.com). */
|
||||
API_URL: string;
|
||||
/** HMAC-SHA256 secret shared with the API to sign cio_hls tokens. */
|
||||
HLS_SIGNING_SECRET: string;
|
||||
/** R2 bucket binding for the videos bucket (HLS manifests + segments + audio). */
|
||||
VIDEOS_BUCKET: R2Bucket;
|
||||
/** R2 bucket binding for static assets (embed scripts under `embeds/`). */
|
||||
ASSETS_BUCKET: R2Bucket;
|
||||
}
|
||||
|
||||
// PostHog first-party proxy: requests to /ingest/* are forwarded to PostHog EU.
|
||||
@@ -156,6 +165,66 @@ async function verifyHlsToken(token: string, secret: string): Promise<VerifiedHl
|
||||
return { assetId: decoded.aid, expSeconds: decoded.exp };
|
||||
}
|
||||
|
||||
async function handleEmbedAssetRequest(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
return new Response('Method Not Allowed', { status: 405 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const objectKey = await resolveEmbedObjectKey(url, env.ASSETS_BUCKET);
|
||||
if (!objectKey) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const object =
|
||||
request.method === 'HEAD' ? await env.ASSETS_BUCKET.head(objectKey) : await env.ASSETS_BUCKET.get(objectKey);
|
||||
|
||||
if (!object) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const headers = embedResponseHeaders(objectKey);
|
||||
|
||||
if ('writeHttpMetadata' in object) {
|
||||
object.writeHttpMetadata(headers);
|
||||
}
|
||||
|
||||
const injectApi = needsApiUrlInjection(objectKey) && env.API_URL;
|
||||
|
||||
if (request.method === 'HEAD') {
|
||||
if (!injectApi && object.size != null) {
|
||||
headers.set('Content-Length', String(object.size));
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
if (injectApi) {
|
||||
const rawBody = await (object as R2ObjectBody).text();
|
||||
const body = injectApiUrl(rawBody, objectKey, env.API_URL);
|
||||
headers.delete('Content-Length');
|
||||
|
||||
return new Response(body, { status: 200, headers });
|
||||
}
|
||||
|
||||
if (object.size != null) {
|
||||
headers.set('Content-Length', String(object.size));
|
||||
}
|
||||
|
||||
return new Response((object as R2ObjectBody).body, { status: 200, headers });
|
||||
}
|
||||
|
||||
async function handleHlsRequest(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
return new Response('Method Not Allowed', { status: 405 });
|
||||
@@ -218,6 +287,11 @@ export default {
|
||||
return Response.redirect(target.toString(), 301);
|
||||
}
|
||||
|
||||
// Public embed scripts — served from the R2 `assets` bucket (no Render hop).
|
||||
if (originalHost === env.EMBED_HOST) {
|
||||
return handleEmbedAssetRequest(request, env);
|
||||
}
|
||||
|
||||
// HLS playback served directly from R2 — Render is never in the bytestream.
|
||||
// Accept both `/hls/{assetId}/...` and `/proxy/hls/{assetId}/...` so the
|
||||
// dashboard can construct URLs from the same `/proxy`-prefixed API base
|
||||
|
||||
Reference in New Issue
Block a user