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:
@@ -44,7 +44,7 @@ jobs:
|
||||
echo "ref=origin/main" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check ui: prefix on changed UI files
|
||||
- name: 'Check ui: prefix on changed UI files'
|
||||
env:
|
||||
UI_PREFIX_GIT_BASE: ${{ steps.base.outputs.ref }}
|
||||
run: pnpm --filter @cio/ui prefix:check:changed
|
||||
|
||||
@@ -6,6 +6,20 @@ This document collects implementation rules and workflow conventions for code ch
|
||||
|
||||
When a task requires factual information (API specifications, context window sizes, library versions, pricing, rate limits, etc.), **look it up** using web search. Do not rely on educated guesses or assumptions from training data. If you're unsure whether something is a guess, look it up anyway.
|
||||
|
||||
## Translation, Formatting, and Git Workflow
|
||||
|
||||
- If `apps/dashboard/src/lib/utils/translations/en.json` changes, update the other dashboard locale files before staging or committing.
|
||||
- To translate dashboard locale changes, run `cd apps/dashboard && pnpm translate`.
|
||||
- After running translation, review the generated locale changes and make sure placeholders wrapped in `{}` were preserved exactly. Fix any translated variable names before continuing.
|
||||
- Format changed files with the repo's changed-file formatter before staging. Prefer `pnpm format:changed` from the repository root. Use broader format commands only if the changed-file formatter is not sufficient for the task.
|
||||
- If you changed files under `packages/ui/src/**`, make sure the required `ui:` Tailwind prefix rules still pass. Run `pnpm --filter @cio/ui prefix` if needed before the final formatting pass.
|
||||
- Stage files only after translation and formatting are complete. Prefer explicit staging such as `git add path/to/file` or `git add <changed-files>` over broad staging commands.
|
||||
- Before creating a commit, manually run the current `lefthook` checks that would run on `pre-commit`:
|
||||
- `pnpm format:check`
|
||||
- `pnpm --filter @cio/ui prefix:check:staged` if staged changes include `packages/ui/src/**/*.{svelte,ts,tsx,js,jsx}`
|
||||
- Commit messages must follow the Conventional Commits 1.0.0 spec: https://www.conventionalcommits.org/en/v1.0.0/
|
||||
- Never include agent-provider attribution in commits or commit trailers. Do not add `Co-authored-by` lines for Cursor, Claude, Codex, or any other tool.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
|
||||
|
||||
@@ -29,7 +29,7 @@ function generateWidgetPublicKey(): string {
|
||||
}
|
||||
|
||||
function getWidgetEmbedCode(publicKey: string): string {
|
||||
const scriptUrl = 'https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js';
|
||||
const scriptUrl = 'https://embed.classroomio.com/course-widget';
|
||||
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
|
||||
}
|
||||
|
||||
@@ -94,9 +94,15 @@ describe('getWidgetEmbedCode', () => {
|
||||
expect(embedCode).toContain('<script async type="module"');
|
||||
});
|
||||
|
||||
it('should reference the CDN script URL', () => {
|
||||
it('should reference the public embed script URL', () => {
|
||||
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
|
||||
expect(embedCode).toContain('course-widget.js');
|
||||
expect(embedCode).toContain('embed.classroomio.com/course-widget');
|
||||
});
|
||||
|
||||
it('should not include an API base URL in the embed snippet', () => {
|
||||
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
|
||||
expect(embedCode).not.toContain('data-api-base-url');
|
||||
expect(embedCode).not.toContain('api.classroomio.com');
|
||||
});
|
||||
|
||||
it('should properly escape special characters in public key', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/
|
||||
import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag';
|
||||
import { listWidgetCourses } from '@cio/db/queries/widget';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import { EMBED_PUBLIC_BASE_URL, getEmbedBaseUrl, getEmbedPublicUrl, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import {
|
||||
buildWidgetPayload as buildWidgetPayloadShared,
|
||||
normalizeWidgetConfig,
|
||||
@@ -16,9 +16,8 @@ import * as csstree from 'css-tree';
|
||||
import { env } from '@cio/core/config/env';
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const DEFAULT_EMBED_CDN_BASE_URL = 'https://assets.cdn.clsrio.com';
|
||||
const DEFAULT_EMBED_CDN_BASE_URL = EMBED_PUBLIC_BASE_URL;
|
||||
const DEFAULT_LOCAL_EMBED_BASE_URL = 'http://localhost:5180';
|
||||
const DEFAULT_WIDGET_API_BASE_URL = 'https://api.classroomio.com';
|
||||
const DEFAULT_WIDGET_APP_BASE_URL = 'https://classroomio.com';
|
||||
|
||||
const ALLOWED_CUSTOM_CSS_PROPERTIES = new Set([
|
||||
@@ -152,34 +151,25 @@ export function getCourseBaseUrl(siteName: string, customDomain?: string | null)
|
||||
return siteName ? `https://${siteName}.${TENANT_ROOT_DOMAIN}` : DEFAULT_WIDGET_APP_BASE_URL;
|
||||
}
|
||||
|
||||
export function getCourseWidgetScriptUrl(): string {
|
||||
const embedBaseUrl = (
|
||||
function resolveEmbedBaseUrl(): string {
|
||||
return getEmbedBaseUrl(
|
||||
env.PUBLIC_EMBED_BASE_URL ||
|
||||
(env.NODE_ENV === 'development' ? DEFAULT_LOCAL_EMBED_BASE_URL : DEFAULT_EMBED_CDN_BASE_URL)
|
||||
).replace(/\/$/, '');
|
||||
|
||||
return `${embedBaseUrl}/embeds/course-widget/course-widget.js`;
|
||||
);
|
||||
}
|
||||
|
||||
export function getWidgetApiBaseUrl(): string {
|
||||
return (env.PUBLIC_SERVER_URL || DEFAULT_WIDGET_API_BASE_URL).replace(/\/$/, '');
|
||||
export function getCourseWidgetScriptUrl(): string {
|
||||
return getEmbedPublicUrl('course-widget', undefined, resolveEmbedBaseUrl());
|
||||
}
|
||||
|
||||
export function getWidgetEmbedCode(publicKey: string): string {
|
||||
const scriptUrl = getCourseWidgetScriptUrl();
|
||||
const apiBaseUrl = getWidgetApiBaseUrl();
|
||||
|
||||
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}" data-api-base-url="${apiBaseUrl}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
|
||||
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
|
||||
}
|
||||
|
||||
export function getWidgetHostedEmbedUrl(publicKey: string): string {
|
||||
const embedBaseUrl = (
|
||||
env.PUBLIC_EMBED_BASE_URL ||
|
||||
(env.NODE_ENV === 'development' ? DEFAULT_LOCAL_EMBED_BASE_URL : DEFAULT_EMBED_CDN_BASE_URL)
|
||||
).replace(/\/$/, '');
|
||||
const apiBaseUrl = getWidgetApiBaseUrl();
|
||||
|
||||
return `${embedBaseUrl}/embeds/course-widget/embed.html?key=${encodeURIComponent(publicKey)}&api=${encodeURIComponent(apiBaseUrl)}`;
|
||||
return getEmbedPublicUrl('course-widget', { key: publicKey }, resolveEmbedBaseUrl());
|
||||
}
|
||||
|
||||
type OrgCourse = Awaited<ReturnType<typeof getOrgCourses>>['items'][number];
|
||||
|
||||
@@ -13,6 +13,8 @@ export const blockedSubdomain = [
|
||||
'courses',
|
||||
'demo',
|
||||
'dev',
|
||||
'embed',
|
||||
'embeds',
|
||||
'forum',
|
||||
'group',
|
||||
'groups',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const saasDefaults = {
|
||||
scriptSrc: [
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://embed.classroomio.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://*.posthog.com',
|
||||
'https://*.senja.io',
|
||||
|
||||
+9
-16
@@ -136,14 +136,15 @@ The course widget uses three different URL concerns:
|
||||
1. **Embed script host**
|
||||
|
||||
- this is where the browser downloads `course-widget.js`
|
||||
- production example: `https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js`
|
||||
- production script URL: `https://embed.classroomio.com/course-widget`
|
||||
- production hosted iframe: `https://embed.classroomio.com/course-widget?key=<public-key>`
|
||||
- configured on the API side with `PUBLIC_EMBED_BASE_URL`
|
||||
|
||||
2. **Payload API host**
|
||||
|
||||
- this is where the widget fetches `GET /widgets/:publicKey/payload`
|
||||
- it reuses the existing API public URL via `PUBLIC_SERVER_URL`
|
||||
- the generated embed snippet includes that host as `data-api-base-url`
|
||||
- production: tenant-router injects `window.CIO.apiBaseUrl` from its `API_URL` env when serving embed assets
|
||||
- local dev: `@cio/embeds` Vite injects the same global from `API_URL` (defaults to `http://localhost:3002`)
|
||||
|
||||
3. **Course destination host**
|
||||
|
||||
@@ -158,22 +159,14 @@ Local development should not depend on deployed CDN assets.
|
||||
|
||||
### Expected local hosts
|
||||
|
||||
- embed script host: `PUBLIC_EMBED_BASE_URL`
|
||||
- payload API host: existing `PUBLIC_SERVER_URL`
|
||||
- embed script host: `PUBLIC_EMBED_BASE_URL` on the API (falls back to `http://localhost:5180` in dev)
|
||||
- payload API host: `API_URL` when running embeds dev or tenant-router (not included in customer copy-paste code)
|
||||
|
||||
When `PUBLIC_EMBED_BASE_URL` is unset and the API runs in development, widget embed code falls back to:
|
||||
|
||||
```text
|
||||
http://localhost:5180
|
||||
```
|
||||
|
||||
That matches the `@cio/embeds` Vite dev server.
|
||||
|
||||
When `PUBLIC_SERVER_URL` is set to your local API URL, the generated snippet will look like:
|
||||
Generated snippet:
|
||||
|
||||
```html
|
||||
<div data-cio-widget="course-widget" data-widget-key="wgt_xxx" data-api-base-url="http://localhost:3002"></div>
|
||||
<script async type="module" src="http://localhost:5180/embeds/course-widget/course-widget.js"></script>
|
||||
<div data-cio-widget="course-widget" data-widget-key="wgt_xxx"></div>
|
||||
<script async type="module" src="https://embed.classroomio.com/course-widget"></script>
|
||||
```
|
||||
|
||||
### Dev server path shape
|
||||
|
||||
@@ -30,7 +30,7 @@ const EMBED_WIDGET_TARGETS = [
|
||||
/** Cloudflare allows a limited number of URLs per purge request. */
|
||||
const PURGE_URL_BATCH = 30;
|
||||
|
||||
const DEFAULT_CDN_BASE = 'https://assets.cdn.clsrio.com';
|
||||
const DEFAULT_CDN_BASE = 'https://embed.classroomio.com';
|
||||
|
||||
function normalizePublicBaseUrl(raw: string | undefined): string {
|
||||
const value = raw != null && raw.trim() !== '' ? raw.trim() : DEFAULT_CDN_BASE;
|
||||
@@ -235,6 +235,8 @@ class EmbedsUploader {
|
||||
purgeUrls.push(
|
||||
...files.map((relativePath) => `${publicBase}/${target.prefix}/${relativePath.split('\\').join('/')}`)
|
||||
);
|
||||
|
||||
purgeUrls.push(`${publicBase}/${target.buildDirName}`);
|
||||
}
|
||||
|
||||
console.log('Embeds upload completed.');
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<script type="module">
|
||||
const params = new URLSearchParams(location.search);
|
||||
const key = params.get('key');
|
||||
const apiBaseUrl = params.get('api');
|
||||
|
||||
if (!key) {
|
||||
const note = document.createElement('p');
|
||||
@@ -31,15 +30,12 @@
|
||||
const widgetDiv = document.createElement('div');
|
||||
widgetDiv.setAttribute('data-cio-widget', 'course-widget');
|
||||
widgetDiv.setAttribute('data-widget-key', key);
|
||||
if (apiBaseUrl) {
|
||||
widgetDiv.setAttribute('data-api-base-url', apiBaseUrl);
|
||||
}
|
||||
mount.appendChild(widgetDiv);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.type = 'module';
|
||||
script.src = './course-widget.js';
|
||||
script.src = window.location.pathname;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,20 +3,42 @@
|
||||
import type { TWidgetPayload } from '@cio/utils/validation/widget';
|
||||
import { CourseWidget } from '@cio/ui/custom/widget-layouts';
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.classroomio.com';
|
||||
declare global {
|
||||
interface Window {
|
||||
CIO?: {
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let { publicKey, apiBaseUrl = DEFAULT_API_BASE_URL }: { publicKey: string; apiBaseUrl?: string } = $props();
|
||||
let { publicKey, apiBaseUrl }: { publicKey: string; apiBaseUrl?: string } = $props();
|
||||
|
||||
let payload = $state<TWidgetPayload | null>(null);
|
||||
let errorMessage = $state('');
|
||||
|
||||
function resolveApiBaseUrl(): string {
|
||||
if (apiBaseUrl) {
|
||||
return apiBaseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const fromWindow = typeof window !== 'undefined' ? window.CIO?.apiBaseUrl : undefined;
|
||||
if (fromWindow) {
|
||||
return fromWindow.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function loadPayload() {
|
||||
try {
|
||||
errorMessage = '';
|
||||
|
||||
const response = await fetch(
|
||||
`${(apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, '')}/widgets/${publicKey}/payload`
|
||||
);
|
||||
const apiOrigin = resolveApiBaseUrl();
|
||||
if (!apiOrigin) {
|
||||
throw new Error('Widget API URL is not configured');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiOrigin}/widgets/${publicKey}/payload`);
|
||||
const result = (await response.json()) as { success?: boolean; data?: TWidgetPayload; error?: string };
|
||||
|
||||
if (!response.ok || !result.success || !result.data) {
|
||||
|
||||
@@ -7,11 +7,33 @@ import { defineConfig } from 'vite';
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const devApiUrl = process.env.API_URL ?? 'http://localhost:3002';
|
||||
|
||||
function embedApiUrlInjectPlugin() {
|
||||
const bootstrapFiles = [
|
||||
path.resolve(dirname, 'src/widgets/course-widget/bootstrap.ts'),
|
||||
path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts')
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'embed-api-url-inject',
|
||||
transform(code: string, id: string) {
|
||||
if (!bootstrapFiles.includes(id) || !devApiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snippet = `window.CIO=window.CIO||{};window.CIO.apiBaseUrl=${JSON.stringify(devApiUrl.replace(/\/$/, ''))};`;
|
||||
return { code: `${snippet}\n${code}`, map: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
const embedEntries = {
|
||||
'question-type-picker': path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'),
|
||||
'course-widget': path.resolve(dirname, 'src/widgets/course-widget/bootstrap.ts')
|
||||
} as const;
|
||||
const embedDevEntrypoints = {
|
||||
'/question-type-picker': '/src/widgets/question-type-picker/bootstrap.ts',
|
||||
'/course-widget': '/src/widgets/course-widget/bootstrap.ts',
|
||||
'/embeds/question-type-picker/question-type-picker.js': '/src/widgets/question-type-picker/bootstrap.ts',
|
||||
'/embeds/course-widget/course-widget.js': '/src/widgets/course-widget/bootstrap.ts',
|
||||
'/embeds/course-widget/embed.html': '/src/widgets/course-widget/embed.html'
|
||||
@@ -26,10 +48,28 @@ function embedDevEntryPlugin() {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
const requestUrl = req.url ?? '';
|
||||
const [pathname, search = ''] = requestUrl.split('?');
|
||||
const querySuffix = search ? `?${search}` : '';
|
||||
|
||||
if (pathname === '/course-widget') {
|
||||
const params = new URLSearchParams(search);
|
||||
const target = params.has('key')
|
||||
? '/src/widgets/course-widget/embed.html'
|
||||
: '/src/widgets/course-widget/bootstrap.ts';
|
||||
req.url = `${target}${querySuffix}`;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/question-type-picker') {
|
||||
req.url = `/src/widgets/question-type-picker/bootstrap.ts${querySuffix}`;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const rewrittenPath = embedDevEntrypoints[pathname as keyof typeof embedDevEntrypoints];
|
||||
|
||||
if (rewrittenPath) {
|
||||
req.url = search ? `${rewrittenPath}?${search}` : rewrittenPath;
|
||||
req.url = `${rewrittenPath}${querySuffix}`;
|
||||
}
|
||||
|
||||
next();
|
||||
@@ -44,7 +84,13 @@ export default defineConfig(({ command, mode }) => {
|
||||
|
||||
return {
|
||||
root: dirname,
|
||||
plugins: [embedDevEntryPlugin(), tailwindcss(), svelte(), cssInjectedByJsPlugin()],
|
||||
plugins: [
|
||||
embedDevEntryPlugin(),
|
||||
...(command === 'serve' ? [embedApiUrlInjectPlugin()] : []),
|
||||
tailwindcss(),
|
||||
svelte(),
|
||||
cssInjectedByJsPlugin()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'),
|
||||
@@ -72,7 +118,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: false,
|
||||
chunkFileNames: '[name]-[hash].js'
|
||||
chunkFileNames: buildTarget ? `${buildTarget}-[name]-[hash].js` : '[name]-[hash].js'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ Cloudflare Worker that fronts every browser-facing ClassroomIO host:
|
||||
- `*.myclassroomio.com/*` — free-tier tenant sites
|
||||
- `myclassroomio.com/*` — apex (301 → `classroomio.com`)
|
||||
- `app.classroomio.com/*` — admin dashboard
|
||||
- `embed.classroomio.com/*` — public embed widget scripts (R2 `assets` bucket, path-for-path)
|
||||
- BYOD customer domains via Approximated (their proxy forwards to `cio-api.onrender.com` directly, not through this Worker)
|
||||
|
||||
The Worker forwards each request to one of two Render services based on path:
|
||||
|
||||
| Path | Upstream |
|
||||
| Host / path | Upstream |
|
||||
| ------------- | ------------------------------------------------------------ |
|
||||
| `embed.*` | R2 `assets` bucket — `/{widget}` maps to internal keys (see `@cio/utils/constants/embeds`) |
|
||||
| `/proxy/*` | API service (`/proxy` prefix stripped, rest forwarded as-is) |
|
||||
| anything else | Dashboard service |
|
||||
|
||||
@@ -44,6 +46,8 @@ pnpm --filter @cio/tenant-router tail
|
||||
| `DASHBOARD_UPSTREAM_HOST` | `.onrender.com` host of the dashboard service (no scheme) |
|
||||
| `API_UPSTREAM_HOST` | `.onrender.com` host of the API service |
|
||||
| `APEX_REDIRECT_TARGET` | URL to redirect `myclassroomio.com` apex to (default `https://classroomio.com`) |
|
||||
| `EMBED_HOST` | Hostname for public embed widgets (default `embed.classroomio.com`) |
|
||||
| `API_URL` | API origin injected into embed scripts as `window.CIO.apiBaseUrl` |
|
||||
|
||||
Update the values in `wrangler.toml` after looking them up in the Render dashboard.
|
||||
|
||||
@@ -57,6 +61,7 @@ Update the values in `wrangler.toml` after looking them up in the Render dashboa
|
||||
`classroomio.com` zone:
|
||||
|
||||
- `app` CNAME → **Proxied** so the route binds
|
||||
- `embed` CNAME → **Proxied** (serves `embeds/*` from R2 via Worker)
|
||||
- `api` CNAME → **DNS only** (server-to-server callers and webhooks bypass the Worker)
|
||||
|
||||
## BYOD custom domains
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "../../packages/utils/src/constants/embeds.ts"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ compatibility_date = "2026-05-01"
|
||||
routes = [
|
||||
{ pattern = "*.myclassroomio.com/*", zone_name = "myclassroomio.com" },
|
||||
{ pattern = "myclassroomio.com/*", zone_name = "myclassroomio.com" },
|
||||
{ pattern = "app.classroomio.com/*", zone_name = "classroomio.com" }
|
||||
{ pattern = "app.classroomio.com/*", zone_name = "classroomio.com" },
|
||||
{ pattern = "embed.classroomio.com/*", zone_name = "classroomio.com" }
|
||||
]
|
||||
|
||||
# Override per environment. Both values are the `.onrender.com` hostnames
|
||||
@@ -20,6 +21,8 @@ routes = [
|
||||
DASHBOARD_UPSTREAM_HOST = "cio-api.onrender.com"
|
||||
API_UPSTREAM_HOST = "cio-api-vymh.onrender.com"
|
||||
APEX_REDIRECT_TARGET = "https://classroomio.com"
|
||||
EMBED_HOST = "embed.classroomio.com"
|
||||
API_URL = "https://api.classroomio.com"
|
||||
|
||||
# R2 bucket binding so /hls/* can stream segments directly from Cloudflare,
|
||||
# bypassing Render. The bucket name matches the storage config in
|
||||
@@ -28,6 +31,11 @@ APEX_REDIRECT_TARGET = "https://classroomio.com"
|
||||
binding = "VIDEOS_BUCKET"
|
||||
bucket_name = "videos"
|
||||
|
||||
# Public embed scripts (course-widget, question-type-picker) uploaded by apps/embeds.
|
||||
[[r2_buckets]]
|
||||
binding = "ASSETS_BUCKET"
|
||||
bucket_name = "assets"
|
||||
|
||||
# HLS_SIGNING_SECRET is set via `wrangler secret put HLS_SIGNING_SECRET` —
|
||||
# never commit it. The same value must be set as HLS_SIGNING_SECRET on the
|
||||
# API (Render env) so the cookie minted by the API verifies on the Worker.
|
||||
|
||||
@@ -16,8 +16,5 @@
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<script
|
||||
type="module"
|
||||
src="https://assets.cdn.clsrio.com/embeds/question-type-picker/question-type-picker.js"
|
||||
></script>
|
||||
<script type="module" src="https://embed.classroomio.com/question-type-picker"></script>
|
||||
</svelte:head>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,3 +7,5 @@ export const TENANT_ROOT_DOMAIN = 'myclassroomio.com';
|
||||
|
||||
/** The marketing / admin / api zone. */
|
||||
export const BRAND_ROOT_DOMAIN = 'classroomio.com';
|
||||
|
||||
export { EMBED_PUBLIC_BASE_URL, EMBED_PUBLIC_HOST } from './embeds';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export const EMBED_PUBLIC_HOST = 'embed.classroomio.com';
|
||||
|
||||
export const EMBED_PUBLIC_BASE_URL = `https://${EMBED_PUBLIC_HOST}`;
|
||||
|
||||
export type EmbedWidgetRoute = {
|
||||
/** R2 object key for the ES module bootstrap. */
|
||||
scriptKey: string;
|
||||
/** R2 object key for the hosted iframe page, when supported. */
|
||||
pageKey?: string;
|
||||
/** Query param that selects the hosted page instead of the script. */
|
||||
pageQueryParam?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public path segment → internal R2 keys under the `assets` bucket.
|
||||
* Customers only copy `https://embed.classroomio.com/{widgetName}`.
|
||||
*/
|
||||
export const EMBED_WIDGET_ROUTES = {
|
||||
'course-widget': {
|
||||
scriptKey: 'embeds/course-widget/course-widget.js',
|
||||
pageKey: 'embeds/course-widget/embed.html',
|
||||
pageQueryParam: 'key'
|
||||
},
|
||||
'question-type-picker': {
|
||||
scriptKey: 'embeds/question-type-picker/question-type-picker.js'
|
||||
}
|
||||
} as const satisfies Record<string, EmbedWidgetRoute>;
|
||||
|
||||
export type EmbedWidgetName = keyof typeof EMBED_WIDGET_ROUTES;
|
||||
|
||||
export function getEmbedBaseUrl(override?: string): string {
|
||||
return (override ?? EMBED_PUBLIC_BASE_URL).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function getEmbedPublicUrl(
|
||||
widgetName: EmbedWidgetName,
|
||||
params?: Record<string, string | undefined>,
|
||||
baseUrlOverride?: string
|
||||
): string {
|
||||
const baseUrl = getEmbedBaseUrl(baseUrlOverride);
|
||||
const path = `${baseUrl}/${widgetName}`;
|
||||
|
||||
if (!params) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value != null && value !== '') {
|
||||
search.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const query = search.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
@@ -2,5 +2,6 @@ export * from './roles';
|
||||
export * from './org';
|
||||
export * from './content';
|
||||
export * from './domains';
|
||||
export * from './embeds';
|
||||
export * from './error-codes';
|
||||
export * from './agent';
|
||||
|
||||
@@ -13,6 +13,8 @@ export const blockedSubdomain = [
|
||||
'courses',
|
||||
'demo',
|
||||
'dev',
|
||||
'embed',
|
||||
'embeds',
|
||||
'forum',
|
||||
'group',
|
||||
'groups',
|
||||
|
||||
Generated
+4
@@ -782,6 +782,10 @@ importers:
|
||||
version: 4.2.1
|
||||
|
||||
apps/tenant-router:
|
||||
dependencies:
|
||||
'@cio/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20250109.0
|
||||
|
||||
Reference in New Issue
Block a user