Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/react-embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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),
Expand All @@ -62,21 +73,14 @@ 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',
target: 'browser',
format: 'esm',
naming: 'worker.js',
define: {
DATA_APP_BOOTSTRAP: JSON.stringify(await bootstrap.outputs[0]!.text()),
DATA_APP_BOOTSTRAP: JSON.stringify(bootstrapScript),
},
});

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-workflows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion src/client/data-client.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -97,7 +98,7 @@ function createOperationClient<Operations extends DataOperations>(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.',
Expand Down
5 changes: 3 additions & 2 deletions src/client/iframe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUuid } from '@/src/core/uuid';
import { createBridgeEndpoint } from '@/src/core/bridge-endpoint';
import {
PARENT_PARAM,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
17 changes: 17 additions & 0 deletions src/core/uuid.ts
Original file line number Diff line number Diff line change
@@ -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('-');
}
3 changes: 2 additions & 1 deletion src/embed/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUuid } from '@/src/core/uuid';
import {
createIframeTransport,
installDataAppTransport,
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/embed/host.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/embed/runtime-html.ts
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll("'", '&#39;');

return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${PAGE_CSP}">
</head>
<body>
<div id="root"></div>
<script data-parent-origin="${attribute}">${inlineBootstrap}</script>
</body>
</html>
`;
}
17 changes: 13 additions & 4 deletions src/embed/source.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 & {
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/embed/sql.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUuid } from '@/src/core/uuid';
import type { Lakehouse } from '@/src/core/contract';
import { toDataOperationFailure } from '@/src/core/operation';
import {
Expand All @@ -14,7 +15,7 @@ export function createSqlQueryHandler(
) => Promise<Lakehouse>
) {
async function handleSqlQuery(input: SqlQueryInput, context: MessageContext) {
const requestId = crypto.randomUUID();
const requestId = randomUuid();
context.signal.throwIfAborted();
let lakehouse: Lakehouse;
try {
Expand Down
3 changes: 2 additions & 1 deletion src/react/annotations/AnnotationControls.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUuid } from '@/src/core/uuid';
import {
useEffect,
useEffectEvent,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/react/embed/bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function SourceBridge(props: SourceBridgeProps) {
iframe,
source:
type === 'url'
? { type, url }
? { type, url: url! }
: {
type,
bootstrapUrl: url,
Expand Down
3 changes: 2 additions & 1 deletion src/react/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUuid } from '@/src/core/uuid';
import { DeclaredView } from '@/src/react/view-runtime';
import {
bindDataset,
Expand Down Expand Up @@ -45,7 +46,7 @@ const clientScopes = new WeakMap<object, string>();
function getClientScope(client: object): string {
let scope = clientScopes.get(client);
if (!scope) {
scope = crypto.randomUUID();
scope = randomUuid();
clientScopes.set(client, scope);
}

Expand Down
34 changes: 1 addition & 33 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll("'", '&#39;');

return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${PAGE_CSP}">
</head>
<body>
<div id="root"></div>
<script data-parent-origin="${attribute}">${inlineBootstrap}</script>
</body>
</html>
`;
}

function isPreviewHost(hostname: string, domainName: string) {
const suffix = `.${domainName}`;

Expand Down
Loading