Files
SRCmail/lib/plugin-sandbox/host-bridge.ts
T

344 lines
12 KiB
TypeScript

// Host-side wrapper around a single sandbox iframe (one per plugin/background,
// plus one per slot mount). Owns the iframe lifecycle and the postMessage RPC.
//
// Origin model: the iframe is `sandbox="allow-scripts"` with no
// `allow-same-origin`, so its origin is opaque ("null"). We can't pin on
// `event.origin`; instead, every inbound message is gated on
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
// parent on the first inbound message.
import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol';
// ─── Callback marshalling ────────────────────────────────────
/**
* Walks an object graph and replaces any function values with
* `{ __pluginCallback: id }` markers, registering each function in `table` so
* the iframe can call back later via 'callback-invoke'. Non-plain values
* (functions on prototype, DOM nodes, etc.) are dropped.
*/
function encodeCallbacks(
value: unknown,
table: Map<string, (...args: unknown[]) => unknown>,
depth = 0,
): unknown {
if (depth > 6) return null; // hard cap to avoid pathological graphs
if (value === null || value === undefined) return value;
const t = typeof value;
if (t === 'function') {
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
table.set(id, value as (...args: unknown[]) => unknown);
return { __pluginCallback: id };
}
if (t !== 'object') return value;
if (Array.isArray(value)) {
return value.map((v) => encodeCallbacks(v, table, depth + 1));
}
// Plain object — copy own enumerable keys.
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = encodeCallbacks(v, table, depth + 1);
}
return out;
}
// ─── Public option types ─────────────────────────────────────
export interface BackgroundOptions {
plugin: InstalledPlugin;
code: string;
locale: string;
/** Where the hidden iframe should attach. Defaults to document.body. */
hostContainer?: HTMLElement;
}
export interface SlotOptions {
plugin: InstalledPlugin;
slot: SlotName;
code: string;
locale: string;
extraProps: Record<string, unknown>;
/** Container element the visible slot iframe is mounted into. */
hostContainer: HTMLElement;
/** Called whenever the sandbox reports a new content height. */
onResize: (height: number) => void;
}
export interface InitDoneInfo {
hooks: string[];
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}
// ─── Sandbox instance ────────────────────────────────────────
export class SandboxInstance {
readonly iframe: HTMLIFrameElement;
readonly pluginId: string;
readonly mode: 'background' | 'slot';
readyPromise: Promise<void>;
initPromise: Promise<InitDoneInfo>;
private resolveReady!: () => void;
private resolveInit!: (info: InitDoneInfo) => void;
private rejectInit!: (err: Error) => void;
private listener: (ev: MessageEvent) => void;
private destroyed = false;
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
private pendingShouldShow = new Map<string, (show: boolean) => void>();
/** Host-side function references the sandbox can call back via 'callback-invoke'. */
private callbackTable = new Map<string, (...args: unknown[]) => unknown>();
constructor(
private plugin: InstalledPlugin,
initPayload: InitPayload,
hostContainer: HTMLElement,
private slotResizeCb: ((height: number) => void) | null,
) {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
// Slot iframes get `extraProps`; encode any function values now so the
// structured-clone send doesn't drop them.
if (initPayload.mode === 'slot') {
initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record<string, unknown>;
}
this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
this.initPromise = new Promise<InitDoneInfo>((res, rej) => {
this.resolveInit = res;
this.rejectInit = rej;
});
this.iframe = document.createElement('iframe');
// Dev-only: Next's HMR/dev runtime refuses requests from the opaque
// ("null") origin a strict sandbox produces, so the iframe never
// hydrates and `sandbox-ready` is never posted. Add allow-same-origin
// in dev so the iframe shares the host's origin and HMR works.
// Production keeps the strict opaque-origin sandbox.
const sandboxFlags = process.env.NODE_ENV === 'development'
? 'allow-scripts allow-same-origin'
: 'allow-scripts';
this.iframe.setAttribute('sandbox', sandboxFlags);
this.iframe.setAttribute('referrerpolicy', 'no-referrer');
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
this.iframe.style.border = 'none';
this.iframe.style.display = 'block';
if (initPayload.mode === 'background') {
this.iframe.style.position = 'absolute';
this.iframe.style.width = '1px';
this.iframe.style.height = '1px';
this.iframe.style.opacity = '0';
this.iframe.style.pointerEvents = 'none';
this.iframe.style.left = '-9999px';
this.iframe.setAttribute('aria-hidden', 'true');
} else {
this.iframe.style.width = '100%';
this.iframe.style.height = '0px';
}
this.iframe.src = SANDBOX_PATH;
this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener);
hostContainer.appendChild(this.iframe);
// Send init after the iframe runtime signals it's ready.
this.readyPromise.then(() => {
if (this.destroyed) return;
const msg: InitMsg = { type: 'init', payload: initPayload };
this.send(msg);
});
}
// ─── Internal ───────────────────────────────────────────────
private send(msg: HostToSandbox): void {
// targetOrigin '*' is required because the iframe is opaque-origin. The
// payload contains no host secrets — bundle code and manifest fields the
// plugin already owns.
this.iframe.contentWindow?.postMessage(msg, '*');
}
private onMessage(ev: MessageEvent): void {
if (this.destroyed) return;
if (ev.source !== this.iframe.contentWindow) return;
const msg = ev.data as SandboxToHost;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
switch (msg.type) {
case 'sandbox-ready':
this.resolveReady();
return;
case 'init-done':
this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
return;
case 'init-error':
this.rejectInit(new Error(msg.error));
return;
case 'api-request': {
const { id, method, args } = msg;
void (async () => {
try {
const result = await dispatchApiCall(this.plugin, method, args ?? []);
this.send({ type: 'api-response', id, ok: true, result });
} catch (err) {
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
}
})();
return;
}
case 'callback-invoke': {
const { id, callbackId, args } = msg;
const fn = this.callbackTable.get(callbackId);
if (!fn) {
this.send({ type: 'callback-response', id, ok: false, error: `unknown callback ${callbackId}` });
return;
}
void (async () => {
try {
const result = await Promise.resolve(fn(...(args ?? [])));
// Only send back primitives / plain objects; functions inside
// results would round-trip but we don't support that yet.
this.send({ type: 'callback-response', id, ok: true, result });
} catch (err) {
this.send({ type: 'callback-response', id, ok: false, error: (err as Error).message ?? String(err) });
}
})();
return;
}
case 'hook-result': {
const entry = this.pendingHookInvokes.get(msg.id);
if (!entry) return;
this.pendingHookInvokes.delete(msg.id);
if (msg.ok) entry.resolve(msg.result);
else entry.reject(new Error(msg.error ?? 'hook error'));
return;
}
case 'slot-should-show-result': {
const cb = this.pendingShouldShow.get(msg.id);
if (!cb) return;
this.pendingShouldShow.delete(msg.id);
cb(msg.show);
return;
}
case 'slot-resize':
this.slotResizeCb?.(msg.height);
return;
}
}
// ─── Public ─────────────────────────────────────────────────
/** Dispatch a hook handler inside the sandbox; resolves with its return value. */
invokeHook(hookName: string, args: unknown[]): Promise<unknown> {
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
const id = uid();
const p = new Promise<unknown>((resolve, reject) => {
this.pendingHookInvokes.set(id, { resolve, reject });
});
this.send({ type: 'hook-invoke', id, hookName, args });
return p;
}
/** Ask the background instance whether a slot should mount for this context. */
evaluateShouldShow(slot: SlotName, context: unknown): Promise<boolean> {
if (this.destroyed) return Promise.resolve(false);
const id = uid();
const p = new Promise<boolean>((resolve) => {
this.pendingShouldShow.set(id, resolve);
});
this.send({ type: 'slot-should-show', id, slot, context });
return p;
}
setLocale(locale: string): void {
if (this.destroyed) return;
this.send({ type: 'locale-change', locale });
}
updateProps(props: Record<string, unknown>): void {
if (this.destroyed) return;
// Stale references would leak if we kept growing the table without
// bound; for now we let it grow until destroy(). A future refinement
// could diff old vs new props and drop entries no longer referenced.
const encoded = encodeCallbacks(props, this.callbackTable) as Record<string, unknown>;
this.send({ type: 'props-update', props: encoded });
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
window.removeEventListener('message', this.listener);
this.iframe.remove();
for (const { reject } of this.pendingHookInvokes.values()) {
reject(new Error('sandbox destroyed'));
}
this.pendingHookInvokes.clear();
this.pendingShouldShow.clear();
this.callbackTable.clear();
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─── Factory helpers ─────────────────────────────────────────
export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'background',
pluginId: opts.plugin.id,
manifest: {
id: opts.plugin.id,
version: opts.plugin.version,
permissions: opts.plugin.permissions,
settings: { ...opts.plugin.settings },
locales: opts.plugin.locales,
httpOrigins: opts.plugin.httpOrigins,
},
code: opts.code,
locale: opts.locale,
};
return new SandboxInstance(
opts.plugin,
payload,
opts.hostContainer ?? document.body,
null,
);
}
export function createSlotInstance(opts: SlotOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'slot',
pluginId: opts.plugin.id,
slot: opts.slot,
code: opts.code,
manifest: {
id: opts.plugin.id,
version: opts.plugin.version,
permissions: opts.plugin.permissions,
settings: { ...opts.plugin.settings },
locales: opts.plugin.locales,
httpOrigins: opts.plugin.httpOrigins,
},
extraProps: opts.extraProps,
locale: opts.locale,
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
}