diff --git a/docs/embed.md b/docs/embed.md index a8edf71d..816ae79e 100644 --- a/docs/embed.md +++ b/docs/embed.md @@ -31,11 +31,18 @@ A bundle source has this shape: ```ts const source = { type: 'bundle' as const, - bootstrapUrl: 'https://my-report-app-1.apps.example.net/', javascript: bundle.javascript, }; ``` +Omit `bootstrapUrl` to load the SDK's packaged bootstrap as a `data:` document. +This supports MCP hosts that permit opaque child frames but block remote frame +URLs. The SDK embeds the exact host origin and applies a CSP that prohibits +direct network access. Data requests go through the host's dispatcher. + +Supply an HTTP(S) `bootstrapUrl` to use an externally served trusted bootstrap, +such as the [Worker asset](worker.md). + The host provides a self-contained JavaScript bundle. React bridges replace the iframe whenever its JavaScript content changes. Serve the bootstrap page with a CSP compatible with the bundle. Bundle mode uses `sandbox="allow-scripts"` and an opaque origin; it cannot read diff --git a/docs/react-embed.md b/docs/react-embed.md index 994824b1..a405ea49 100644 --- a/docs/react-embed.md +++ b/docs/react-embed.md @@ -45,10 +45,11 @@ The local URL must have a different origin from its shell. For hosted bundles, replace `source` with: ```tsx -source={{ type: 'bundle', bootstrapUrl, javascript }} +source={{ type: 'bundle', javascript }} ``` -The host supplies those bundle values. See [embedding](embed.md) for trust, +The host supplies the JavaScript bundle. Omit `bootstrapUrl` for the packaged +opaque bootstrap, or supply an HTTP(S) URL for an external bootstrap. See [embedding](embed.md) for trust, sandbox, CSP, and bootstrap setup. Source URL or JavaScript content changes replace the entire iframe. Change the diff --git a/scripts/build.ts b/scripts/build.ts index 96798c99..a11a0ea0 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -32,6 +32,16 @@ const [dataAppStyles, shellStyles, annotationStyles] = await Promise.all([ compileStyles('src/react/annotations/styles.css'), ]); +const bootstrap = await Bun.build({ + entrypoints: ['src/embed/standalone.ts'], + target: 'browser', + format: 'iife', + minify: true, +}); + +if (!bootstrap.success) throw new Error('Could not build bootstrap.'); +const bootstrapScript = await bootstrap.outputs[0]!.text(); + // Browser entries share chunks so error classes and transport helpers retain // their identity across public entry points. Hosts do not import the app UI. const browser = await Bun.build({ @@ -53,6 +63,7 @@ const browser = await Bun.build({ format: 'esm', splitting: true, define: { + DATA_APP_BOOTSTRAP: JSON.stringify(bootstrapScript), DATA_APP_STYLES: JSON.stringify(dataAppStyles), SHELL_STYLES: JSON.stringify(shellStyles), ANNOTATION_STYLES: JSON.stringify(annotationStyles), @@ -62,13 +73,6 @@ const browser = await Bun.build({ sourcemap: 'external', }); -const bootstrap = await Bun.build({ - entrypoints: ['src/embed/standalone.ts'], - target: 'browser', - format: 'iife', -}); - -if (!bootstrap.success) throw new Error('Could not build bootstrap.'); const worker = await Bun.build({ entrypoints: ['src/worker/index.ts'], outdir: 'dist', @@ -76,7 +80,7 @@ const worker = await Bun.build({ format: 'esm', naming: 'worker.js', define: { - DATA_APP_BOOTSTRAP: JSON.stringify(await bootstrap.outputs[0]!.text()), + DATA_APP_BOOTSTRAP: JSON.stringify(bootstrapScript), }, }); diff --git a/scripts/check-workflows.sh b/scripts/check-workflows.sh index 4c584245..235951eb 100644 --- a/scripts/check-workflows.sh +++ b/scripts/check-workflows.sh @@ -3,6 +3,6 @@ set -euo pipefail if command -v actionlint >/dev/null 2>&1; then actionlint else - go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + GOTOOLCHAIN=go1.27.1 go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 fi shellcheck scripts/*.sh scripts/release/*.sh diff --git a/src/client/data-client.ts b/src/client/data-client.ts index 7329045e..0ac04981 100644 --- a/src/client/data-client.ts +++ b/src/client/data-client.ts @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import { createHttpTransport, DataAppError } from '@/src/client/transport'; import { getDataAppTransport, localFrameBridge } from '@/src/client/iframe'; import { @@ -97,7 +98,7 @@ function createOperationClient(options: { return { async query(name, input, { signal } = {}) { signal?.throwIfAborted(); - const requestId = crypto.randomUUID(); + const requestId = randomUuid(); if (!Object.hasOwn(options.operations, name)) throw new DataAppError( 'Unknown data operation.', diff --git a/src/client/iframe.ts b/src/client/iframe.ts index 3ab9d682..e2676719 100644 --- a/src/client/iframe.ts +++ b/src/client/iframe.ts @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import { createBridgeEndpoint } from '@/src/core/bridge-endpoint'; import { PARENT_PARAM, @@ -45,7 +46,7 @@ export function createIframeTransport({ }) { if (new URL(parentOrigin).origin !== parentOrigin) throw new Error('Expected an exact parent origin.'); - const documentId = crypto.randomUUID(); + const documentId = randomUuid(); let token: string | undefined; let sessionId: string | undefined; let disposed = false; @@ -141,7 +142,7 @@ export function createIframeTransport({ return reject( new DataAppError('Too many pending data requests.', 'bridge_busy') ); - const id = crypto.randomUUID(); + const id = randomUuid(); let sent = false; function cleanup() { diff --git a/src/core/uuid.ts b/src/core/uuid.ts new file mode 100644 index 00000000..a18e9350 --- /dev/null +++ b/src/core/uuid.ts @@ -0,0 +1,17 @@ +/** Opaque documents can lack randomUUID even when cryptographic randomness is available. */ +export function randomUuid(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 15) | 64; + bytes[8] = (bytes[8]! & 63) | 128; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4), + hex.slice(4, 6), + hex.slice(6, 8), + hex.slice(8, 10), + hex.slice(10), + ] + .map(part => part.join('')) + .join('-'); +} diff --git a/src/embed/bootstrap.ts b/src/embed/bootstrap.ts index 13c304c9..98a0a38a 100644 --- a/src/embed/bootstrap.ts +++ b/src/embed/bootstrap.ts @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import { createIframeTransport, installDataAppTransport, @@ -13,7 +14,7 @@ export function startDataAppBootstrap({ }) { let loaded: string | undefined; let failed = false; - const bundleSource = `altertable-data-app-${crypto.randomUUID()}.js`; + const bundleSource = `altertable-data-app-${randomUuid()}.js`; const bridge = createIframeTransport({ parentOrigin, window: frame, diff --git a/src/embed/host.ts b/src/embed/host.ts index 613b7f14..cbabf4e2 100644 --- a/src/embed/host.ts +++ b/src/embed/host.ts @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import { createBridgeEndpoint } from '@/src/core/bridge-endpoint'; import type { DataAppPresentation } from '@/src/core/presentation'; import type { DataAppLogger } from '@/src/core/logger'; @@ -130,7 +131,7 @@ export function attachDataAppConnection({ if (message.documentId !== documentId) { cancelAll(); documentId = message.documentId; - sessionId = crypto.randomUUID(); + sessionId = randomUuid(); } onStatusChange?.('connected'); if (disposed) return; @@ -239,7 +240,7 @@ export function attachDataAppConnection({ } function load() { - if (connection.type === 'opaque') token = crypto.randomUUID(); + if (connection.type === 'opaque') token = randomUuid(); cancelAll(); documentId = undefined; sessionId = undefined; diff --git a/src/embed/runtime-html.ts b/src/embed/runtime-html.ts new file mode 100644 index 00000000..c8b1538e --- /dev/null +++ b/src/embed/runtime-html.ts @@ -0,0 +1,32 @@ +// The package build embeds the compiled classic script, never a runtime import. +declare const DATA_APP_BOOTSTRAP: string; + +export const PAGE_CSP = + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'"; +const inlineBootstrap = DATA_APP_BOOTSTRAP.replace( + /<\/script/gi, + match => `<\\${match.slice(1)}` +); + +export function runtimeHtml(parentOrigin: string) { + const attribute = parentOrigin + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll("'", '''); + + return ` + + + + + + + +
+ + + +`; +} diff --git a/src/embed/source.ts b/src/embed/source.ts index 8596fcec..cfc2034d 100644 --- a/src/embed/source.ts +++ b/src/embed/source.ts @@ -1,3 +1,5 @@ +import { randomUuid } from '@/src/core/uuid'; +import { runtimeHtml } from '@/src/embed/runtime-html'; import { PARENT_PARAM } from '@/src/core/bridge'; import { attachDataAppConnection, @@ -10,7 +12,8 @@ export type DataAppSource = | { type: 'url'; url: string } | { type: 'bundle'; - bootstrapUrl: string; + /** Omit to use the packaged, network-isolated data document. */ + bootstrapUrl?: string; javascript: string; }; export type DataAppSourceOptions = DataAppHostOptions & { @@ -32,10 +35,16 @@ export function attachDataAppSource({ const host = iframe.ownerDocument.defaultView; if (!host) throw new Error('The iframe requires a host window.'); const url = new URL( - source.type === 'url' ? source.url : source.bootstrapUrl, + source.type === 'url' + ? source.url + : (source.bootstrapUrl ?? + `data:text/html;charset=utf-8,${encodeURIComponent(runtimeHtml(host.location.origin))}`), host.location.href ); - if (!/^https?:$/.test(url.protocol)) + if ( + !(source.type === 'bundle' && source.bootstrapUrl === undefined) && + !/^https?:$/.test(url.protocol) + ) throw new Error('Data apps require an HTTP(S) URL.'); const opaque = source.type === 'bundle'; if (!opaque && url.origin === host.location.origin) @@ -71,7 +80,7 @@ export function attachDataAppSource({ const bridge = attachDataAppConnection({ iframe, connection: opaque - ? { type: 'opaque', token: crypto.randomUUID() } + ? { type: 'opaque', token: randomUuid() } : { type: 'origin', origin: url.origin }, javascript: opaque ? source.javascript : undefined, presentation, diff --git a/src/embed/sql.ts b/src/embed/sql.ts index 98a741e6..1fe7239b 100644 --- a/src/embed/sql.ts +++ b/src/embed/sql.ts @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import type { Lakehouse } from '@/src/core/contract'; import { toDataOperationFailure } from '@/src/core/operation'; import { @@ -14,7 +15,7 @@ export function createSqlQueryHandler( ) => Promise ) { async function handleSqlQuery(input: SqlQueryInput, context: MessageContext) { - const requestId = crypto.randomUUID(); + const requestId = randomUuid(); context.signal.throwIfAborted(); let lakehouse: Lakehouse; try { diff --git a/src/react/annotations/AnnotationControls.tsx b/src/react/annotations/AnnotationControls.tsx index df9ecf9e..1df0219f 100644 --- a/src/react/annotations/AnnotationControls.tsx +++ b/src/react/annotations/AnnotationControls.tsx @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import { useEffect, useEffectEvent, @@ -157,7 +158,7 @@ export function AnnotationControls({ const rect = annotationGeometry(target.element); const point = annotationPoint(rect, cursor); const draft: DataAppAnnotationDraft = { - id: crypto.randomUUID(), + id: randomUuid(), target: { id: target.id, label: region ? 'Selected area' : target.label.slice(0, 256), diff --git a/src/react/embed/bridge.tsx b/src/react/embed/bridge.tsx index fe7d043e..0c44f2aa 100644 --- a/src/react/embed/bridge.tsx +++ b/src/react/embed/bridge.tsx @@ -98,7 +98,7 @@ function SourceBridge(props: SourceBridgeProps) { iframe, source: type === 'url' - ? { type, url } + ? { type, url: url! } : { type, bootstrapUrl: url, diff --git a/src/react/hooks.ts b/src/react/hooks.ts index ba1d99ac..612c3f25 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1,3 +1,4 @@ +import { randomUuid } from '@/src/core/uuid'; import { DeclaredView } from '@/src/react/view-runtime'; import { bindDataset, @@ -45,7 +46,7 @@ const clientScopes = new WeakMap(); function getClientScope(client: object): string { let scope = clientScopes.get(client); if (!scope) { - scope = crypto.randomUUID(); + scope = randomUuid(); clientScopes.set(client, scope); } diff --git a/src/worker/index.ts b/src/worker/index.ts index eb8bec55..1fd512f3 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,44 +1,12 @@ +import { PAGE_CSP, runtimeHtml } from '@/src/embed/runtime-html'; import { trustedParent } from '@/src/worker/trusted-parent'; -// The package build embeds the compiled classic script, never a runtime import. -declare const DATA_APP_BOOTSTRAP: string; - interface WorkerBindings { DOMAIN_NAME: string; PARENT_ORIGINS: string; } const TOKEN_RE = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)+-app-[1-9][0-9]*$/; -const PAGE_CSP = - "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'"; -const inlineBootstrap = DATA_APP_BOOTSTRAP.replace( - /<\/script/gi, - match => `<\\${match.slice(1)}` -); - -function runtimeHtml(parentOrigin: string) { - const attribute = parentOrigin - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll("'", '''); - - return ` - - - - - - - -
- - - -`; -} - function isPreviewHost(hostname: string, domainName: string) { const suffix = `.${domainName}`; diff --git a/tests/public-api/apps-embedding.test.ts b/tests/public-api/apps-embedding.test.ts index 2b979248..22b85e68 100644 --- a/tests/public-api/apps-embedding.test.ts +++ b/tests/public-api/apps-embedding.test.ts @@ -302,7 +302,7 @@ test('an overloaded iframe rejects excess work, releases cancelled requests and .toBe('0'); }); -for (const mode of ['bridge-host', 'bundle-host']) { +for (const mode of ['bridge-host', 'bundle-host', 'bundle-host?packaged=1']) { test(`the ${mode} correlates concurrent queries and cancels host work when its caller aborts`, async ({ page, }) => { @@ -399,7 +399,7 @@ for (const mode of ['bridge-host', 'bundle-host']) { }); } -for (const source of ['bundle', 'url']) { +for (const source of ['bundle', 'packaged', 'url']) { test(`an embedded ${source} app queries through the host and restores navigation after reload`, async ({ page, }) => { @@ -409,9 +409,31 @@ for (const source of ['bundle', 'url']) { requests.push(request.frame() === page.mainFrame() ? 'host' : 'app'); }); await page.goto( - `/bundle-host?${source === 'url' ? 'url=1&' : ''}period=last-30#totals` + `/bundle-host?${source === 'url' ? 'url=1&' : source === 'packaged' ? 'packaged=1&' : ''}period=last-30#totals` ); const app = page.frameLocator('iframe'); + if (source === 'packaged') { + await expect + .poll(() => page.locator('iframe').getAttribute('src')) + .toMatch(/^data:text\/html/); + await expect + .poll(() => page.locator('iframe').getAttribute('sandbox')) + .toBe('allow-scripts'); + const frame = page.frames().find(frame => frame !== page.mainFrame())!; + expect(await frame.evaluate(() => typeof crypto.randomUUID)).toBe( + 'undefined' + ); + expect( + await frame.evaluate(async url => { + try { + await fetch(url); + return false; + } catch { + return true; + } + }, new URL('/api/sql', page.url()).href) + ).toBe(true); + } await expect .poll(() => app @@ -438,7 +460,7 @@ for (const source of ['bundle', 'url']) { .textContent() ) .toContain('"version":2'); - if (source === 'bundle') { + if (source !== 'url') { await app .getByRole('button', { name: 'Data query', exact: true }) .click(); @@ -461,6 +483,18 @@ for (const source of ['bundle', 'url']) { .toBe('{"publicError":true,"code":"forbidden"}'); expect(requests).toEqual(['host', 'host']); } + if (source !== 'url') { + await page + .getByRole('button', { name: 'Change theme', exact: true }) + .click(); + await expect + .poll(() => + app + .locator('html') + .evaluate(element => getComputedStyle(element).colorScheme) + ) + .toBe('light'); + } await app.getByRole('button', { name: 'Last 7 days', exact: true }).click(); await expect.poll(() => page.url()).toContain('period=last-7'); await page.goBack(); diff --git a/tests/public-api/apps-runtime.test.ts b/tests/public-api/apps-runtime.test.ts index cc25fcc9..5f4a0093 100644 --- a/tests/public-api/apps-runtime.test.ts +++ b/tests/public-api/apps-runtime.test.ts @@ -67,7 +67,7 @@ describe('runtime-errors', () => { test('a failed embedded app can recover with a corrected bundle and bootstrap failures settle', async ({ page, }) => { - await page.goto('/bundle-host?broken=1'); + await page.goto('/bundle-host?packaged=1&broken=1'); await expect .poll(() => page.getByRole('alert').textContent()) .toContain('Could not load'); diff --git a/tests/public-api/fixtures/bundle-host.tsx b/tests/public-api/fixtures/bundle-host.tsx index d6c36a92..5c91e744 100644 --- a/tests/public-api/fixtures/bundle-host.tsx +++ b/tests/public-api/fixtures/bundle-host.tsx @@ -41,6 +41,7 @@ function Host() { const [pendingRequests, setPendingRequests] = useState(0); const [cancelledRequests, setCancelledRequests] = useState(0); + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); const [status, setStatus] = useState('connecting'); const [attempt, retry] = useReducer(value => value + 1, 0); const [version, changeHandler] = useReducer(value => value + 1, 1); @@ -110,6 +111,11 @@ function Host() { {pendingRequests} {cancelledRequests} +