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:
+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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user