feat: sandbox plugins in null-origin iframes with postMessage RPC
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// 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';
|
||||
|
||||
// ─── 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 }>;
|
||||
}
|
||||
|
||||
// ─── 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>();
|
||||
|
||||
constructor(
|
||||
private plugin: InstalledPlugin,
|
||||
initPayload: InitPayload,
|
||||
hostContainer: HTMLElement,
|
||||
private slotResizeCb: ((height: number) => void) | null,
|
||||
) {
|
||||
this.pluginId = plugin.id;
|
||||
this.mode = initPayload.mode;
|
||||
|
||||
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');
|
||||
this.iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
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 });
|
||||
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 '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;
|
||||
this.send({ type: 'props-update', props });
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
extraProps: opts.extraProps,
|
||||
locale: opts.locale,
|
||||
};
|
||||
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
|
||||
}
|
||||
Reference in New Issue
Block a user