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:
Rotimi Best
2026-06-12 02:06:37 +01:00
parent c868b2f23f
commit 367ddcddd7
23 changed files with 399 additions and 62 deletions
+9 -16
View File
@@ -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
+3 -1
View File
@@ -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) {
+49 -3
View File
@@ -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'
}
}
}