The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.
Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
(alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
against the plugin's declared locales (manifest.locales) with English/key
fallback and {placeholder} interpolation.
Lets any sandboxed plugin localize its strings from its manifest.
493 lines
19 KiB
TypeScript
493 lines
19 KiB
TypeScript
'use client';
|
|
|
|
// Runtime that boots inside the null-origin plugin sandbox iframe.
|
|
//
|
|
// Lifecycle:
|
|
// 1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK;
|
|
// the message carries no secrets, and the parent's first inbound message
|
|
// gives us the origin to pin for everything that follows).
|
|
// 2. Parent posts 'init' with the bundle code + manifest + mode/slot.
|
|
// 3. We evaluate the bundle in a `new Function` scope with React/ReactDOM
|
|
// injected as globals; the bundle is CommonJS-style (`module.exports = {
|
|
// slots, hooks, activate }`). ES-module syntax inside the bundle is a
|
|
// build-time concern handled by the plugin's bundler.
|
|
// 4. In background mode: register hook handlers and call `activate(api)`.
|
|
// The host installs HookBus stubs and dispatches via 'hook-invoke'.
|
|
// 5. In slot mode: look up `slots[slot].component`, render it into the
|
|
// iframe body, push height back via ResizeObserver.
|
|
|
|
import { useEffect } from 'react';
|
|
import * as React from 'react';
|
|
import * as ReactDOM from 'react-dom/client';
|
|
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
|
import type {
|
|
HostToSandbox,
|
|
SandboxToHost,
|
|
InitPayload,
|
|
BackgroundInit,
|
|
SlotInit,
|
|
} from './protocol';
|
|
import type { SlotName } from '../plugin-types';
|
|
|
|
// ─── Module-scope state ──────────────────────────────────────
|
|
|
|
interface PluginExports {
|
|
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
|
|
hooks?: Record<string, (...args: unknown[]) => unknown>;
|
|
/**
|
|
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
|
|
* hook named `shortcut:<id>` so the host's keydown dispatcher can fire it.
|
|
*/
|
|
shortcuts?: Record<string, {
|
|
keys: string;
|
|
label: string;
|
|
category?: string;
|
|
handler: () => void | Promise<void>;
|
|
}>;
|
|
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
|
|
default?: unknown;
|
|
}
|
|
|
|
let parentWindow: Window | null = null;
|
|
let parentOrigin: string | null = null;
|
|
let pluginExports: PluginExports | null = null;
|
|
let mode: 'background' | 'slot' | null = null;
|
|
let slotName: SlotName | null = null;
|
|
let bootDone = false;
|
|
// Guards the initial sandbox-ready post against React strict mode's double
|
|
// useEffect invocation; the parent only needs to be pinged once per iframe.
|
|
let readyPosted = false;
|
|
|
|
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
|
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
|
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
|
|
|
|
function sendToHost(msg: SandboxToHost): void {
|
|
if (!parentWindow || !parentOrigin) return;
|
|
parentWindow.postMessage(msg, parentOrigin);
|
|
}
|
|
|
|
function uid(): string {
|
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
}
|
|
|
|
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
|
|
|
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
|
|
|
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
|
|
const id = uid();
|
|
return new Promise((resolve, reject) => {
|
|
pendingApi.set(id, { resolve, reject });
|
|
sendToHost({ type: 'api-request', id, method, args });
|
|
// Bounded so a hung host can't leak the promise forever. Interactive UI
|
|
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
|
|
// for human input, the host always resolves them on confirm/cancel/close,
|
|
// and any still-pending call dies with the iframe on teardown - so there's
|
|
// nothing to leak, and a thinking user must not trip a 30s timeout.
|
|
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
|
|
setTimeout(() => {
|
|
const entry = pendingApi.get(id);
|
|
if (!entry) return;
|
|
pendingApi.delete(id);
|
|
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
}, timeoutMs);
|
|
}
|
|
});
|
|
}
|
|
|
|
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
|
|
const id = uid();
|
|
return new Promise((resolve, reject) => {
|
|
pendingCallbacks.set(id, { resolve, reject });
|
|
sendToHost({ type: 'callback-invoke', id, callbackId, args });
|
|
setTimeout(() => {
|
|
const entry = pendingCallbacks.get(id);
|
|
if (!entry) return;
|
|
pendingCallbacks.delete(id);
|
|
entry.reject(new Error('host callback timed out after 30s'));
|
|
}, 30_000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Walks an object graph received from the host and rehydrates
|
|
* `{ __pluginCallback: id }` markers into stub functions that round-trip via
|
|
* the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
|
|
*/
|
|
function decodeCallbacks(value: unknown, depth = 0): unknown {
|
|
if (depth > 6) return null;
|
|
if (value === null || value === undefined) return value;
|
|
const t = typeof value;
|
|
if (t !== 'object') return value;
|
|
if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
|
|
const obj = value as Record<string, unknown>;
|
|
if (typeof obj.__pluginCallback === 'string') {
|
|
const cbId = obj.__pluginCallback;
|
|
return (...args: unknown[]) => invokeHostCallback(cbId, args);
|
|
}
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
out[k] = decodeCallbacks(v, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
type PluginManifest = BackgroundInit['manifest'];
|
|
|
|
function buildPluginApi(manifest: PluginManifest) {
|
|
return {
|
|
plugin: {
|
|
id: manifest.id,
|
|
version: manifest.version,
|
|
settings: { ...manifest.settings },
|
|
},
|
|
storage: {
|
|
get: (key: string) => callApi('storage.get', [key]),
|
|
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
|
|
remove: (key: string) => callApi('storage.remove', [key]),
|
|
keys: () => callApi('storage.keys', []),
|
|
},
|
|
http: {
|
|
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
|
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
|
|
},
|
|
toast: {
|
|
success: (m: string) => { void callApi('toast.success', [m]); },
|
|
error: (m: string) => { void callApi('toast.error', [m]); },
|
|
info: (m: string) => { void callApi('toast.info', [m]); },
|
|
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
|
},
|
|
ui: {
|
|
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
|
|
* No timeout - it waits for the user's choice. */
|
|
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
|
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
|
|
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
|
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
|
callApi('ui.alert', [opts], 0) as Promise<void>,
|
|
/** Opens an http/https URL in a new tab via host `window.open`. */
|
|
openExternalUrl: (url: string, target?: string) =>
|
|
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
|
},
|
|
admin: {
|
|
getConfig: (key: string) => callApi('admin.getConfig', [key]),
|
|
getAllConfig: () => callApi('admin.getAllConfig', []),
|
|
setConfig: (key: string, v: unknown) => callApi('admin.setConfig', [key, v]),
|
|
deleteConfig: (key: string) => callApi('admin.deleteConfig', [key]),
|
|
},
|
|
log: {
|
|
debug: (...a: unknown[]) => console.debug(`[plugin:${manifest.id}]`, ...a),
|
|
info: (...a: unknown[]) => console.info(`[plugin:${manifest.id}]`, ...a),
|
|
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
|
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
|
|
},
|
|
// Localization for plugins. The host pushes the active locale (init +
|
|
// 'locale-change'); `t` resolves a key against the plugin's declared
|
|
// `locales` map (manifest.locales), falling back to English then the key
|
|
// itself, with optional {placeholder} interpolation.
|
|
i18n: {
|
|
get locale(): string {
|
|
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
|
},
|
|
t(key: string, vars?: Record<string, string | number>): string {
|
|
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
|
const tables = manifest.locales || {};
|
|
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
|
|
if (vars) {
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
out = out.split('{' + k + '}').join(String(v));
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Bundle evaluation ───────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
|
|
* bundlers should be configured to externalise React; the runtime provides
|
|
* those modules here. Anything else is refused - the sandbox has no Node-
|
|
* compatible module resolution and we don't want plugins probing globals.
|
|
*
|
|
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
|
|
* `const api = require('@plugin-host')` in both background and slot modes.
|
|
*/
|
|
function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
|
|
const known: Record<string, unknown> = {
|
|
'react': React,
|
|
'react-dom': ReactDOM,
|
|
'react-dom/client': ReactDOM,
|
|
'react/jsx-runtime': ReactJSXRuntime,
|
|
'react/jsx-dev-runtime': ReactJSXRuntime,
|
|
};
|
|
if (api) known['@plugin-host'] = api;
|
|
return (name: string) => {
|
|
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
|
|
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
|
|
};
|
|
}
|
|
|
|
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
|
|
const mod: { exports: PluginExports } = { exports: {} };
|
|
const requireShim = makePluginRequire(api);
|
|
let fn: (...args: unknown[]) => void;
|
|
try {
|
|
fn = new Function(
|
|
'module', 'exports', 'require', 'React', 'ReactDOM', 'JsxRuntime', 'console',
|
|
code,
|
|
) as (...args: unknown[]) => void;
|
|
} catch (err) {
|
|
throw new Error(`Bundle parse error: ${(err as Error).message}`);
|
|
}
|
|
try {
|
|
fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
|
|
} catch (err) {
|
|
throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
|
|
}
|
|
const exports = (mod.exports?.default ?? mod.exports) as PluginExports;
|
|
if (!exports || typeof exports !== 'object') {
|
|
throw new Error('Bundle did not produce module.exports object');
|
|
}
|
|
return exports;
|
|
}
|
|
|
|
// ─── Init flow ───────────────────────────────────────────────
|
|
|
|
async function bootBackground(payload: BackgroundInit): Promise<void> {
|
|
const api = buildPluginApi(payload.manifest);
|
|
const exports = evaluateBundle(payload.code, api);
|
|
pluginExports = exports;
|
|
|
|
// Register hooks (each value must be a function).
|
|
const hookNames: string[] = [];
|
|
const hooks = exports.hooks ?? {};
|
|
for (const [name, handler] of Object.entries(hooks)) {
|
|
if (typeof handler === 'function') {
|
|
hookHandlers[name] = handler;
|
|
hookNames.push(name);
|
|
}
|
|
}
|
|
|
|
// Enumerate slot offers.
|
|
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number }> = [];
|
|
const slots = exports.slots ?? {};
|
|
for (const [name, def] of Object.entries(slots)) {
|
|
if (def && typeof def.component === 'function') {
|
|
slotInfo.push({
|
|
name: name as SlotName,
|
|
hasShouldShow: typeof def.shouldShow === 'function',
|
|
order: typeof def.order === 'number' ? def.order : 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Shortcuts: register each handler as a 'shortcut:<id>' hook so the host's
|
|
// global keydown dispatcher can invoke it.
|
|
const shortcutInfo: Array<{ id: string; keys: string; label: string; category?: string }> = [];
|
|
const shortcuts = exports.shortcuts ?? {};
|
|
for (const [id, def] of Object.entries(shortcuts)) {
|
|
if (!def || typeof def.handler !== 'function' || typeof def.keys !== 'string') continue;
|
|
hookHandlers[`shortcut:${id}`] = def.handler as (...args: unknown[]) => unknown;
|
|
hookNames.push(`shortcut:${id}`);
|
|
shortcutInfo.push({
|
|
id,
|
|
keys: def.keys,
|
|
label: typeof def.label === 'string' ? def.label : id,
|
|
category: typeof def.category === 'string' ? def.category : undefined,
|
|
});
|
|
}
|
|
|
|
// Side effects.
|
|
if (typeof exports.activate === 'function') {
|
|
await Promise.resolve(exports.activate(api));
|
|
}
|
|
|
|
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
|
|
}
|
|
|
|
function bootSlot(payload: SlotInit): void {
|
|
const api = buildPluginApi(payload.manifest);
|
|
const exports = evaluateBundle(payload.code, api);
|
|
pluginExports = exports;
|
|
slotName = payload.slot;
|
|
|
|
const slotDef = exports.slots?.[payload.slot];
|
|
if (!slotDef || typeof slotDef.component !== 'function') {
|
|
throw new Error(`Plugin "${payload.pluginId}" does not export slots["${payload.slot}"].component`);
|
|
}
|
|
|
|
const rootEl = document.getElementById('plugin-sandbox-root');
|
|
if (!rootEl) throw new Error('Sandbox root element missing');
|
|
|
|
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
|
|
const Component = slotDef.component;
|
|
|
|
// A trivial pub/sub so host-pushed `props-update` messages re-render the
|
|
// slot tree without tearing down the iframe.
|
|
const propsListeners = new Set<(p: Record<string, unknown>) => void>();
|
|
slotPropsUpdater = (next) => {
|
|
currentProps = decodeCallbacks(next) as Record<string, unknown>;
|
|
for (const l of propsListeners) {
|
|
try { l(currentProps); } catch { /* ignore */ }
|
|
}
|
|
};
|
|
|
|
const SlotShell = () => {
|
|
const wrapRef = React.useRef<HTMLDivElement>(null);
|
|
const [props, setProps] = React.useState(currentProps);
|
|
React.useEffect(() => {
|
|
propsListeners.add(setProps);
|
|
return () => { propsListeners.delete(setProps); };
|
|
}, []);
|
|
React.useEffect(() => {
|
|
if (!wrapRef.current) return;
|
|
let lastHeight = -1;
|
|
const ro = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const h = Math.ceil(entry.contentRect.height);
|
|
if (h !== lastHeight) {
|
|
lastHeight = h;
|
|
sendToHost({ type: 'slot-resize', height: h });
|
|
}
|
|
}
|
|
});
|
|
ro.observe(wrapRef.current);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
|
|
};
|
|
|
|
const reactRoot = ReactDOM.createRoot(rootEl);
|
|
reactRoot.render(React.createElement(SlotShell));
|
|
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
|
|
}
|
|
|
|
// Populated by bootSlot - receives `props-update` messages.
|
|
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
|
|
|
|
async function handleInit(payload: InitPayload): Promise<void> {
|
|
if (bootDone) return;
|
|
bootDone = true;
|
|
mode = payload.mode;
|
|
// Make the active locale available to plugin code (api.i18n) right away -
|
|
// not only after the first 'locale-change' push.
|
|
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
|
|
try {
|
|
if (payload.mode === 'background') {
|
|
await bootBackground(payload);
|
|
} else {
|
|
bootSlot(payload);
|
|
}
|
|
} catch (err) {
|
|
sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
|
|
}
|
|
}
|
|
|
|
// ─── Host message handler ────────────────────────────────────
|
|
|
|
function handleHostMessage(ev: MessageEvent): void {
|
|
// First inbound message pins source + origin. Reject everything else.
|
|
if (!parentWindow) {
|
|
if (!ev.source || ev.source === window) return;
|
|
parentWindow = ev.source as Window;
|
|
parentOrigin = ev.origin || null;
|
|
}
|
|
if (ev.source !== parentWindow) return;
|
|
if (parentOrigin && ev.origin !== parentOrigin) return;
|
|
|
|
const msg = ev.data as HostToSandbox;
|
|
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
|
|
|
|
switch (msg.type) {
|
|
case 'init':
|
|
void handleInit(msg.payload);
|
|
break;
|
|
|
|
case 'api-response': {
|
|
const pending = pendingApi.get(msg.id);
|
|
if (!pending) return;
|
|
pendingApi.delete(msg.id);
|
|
if (msg.ok) pending.resolve(msg.result);
|
|
else pending.reject(new Error(msg.error ?? 'api error'));
|
|
break;
|
|
}
|
|
|
|
case 'callback-response': {
|
|
const pending = pendingCallbacks.get(msg.id);
|
|
if (!pending) return;
|
|
pendingCallbacks.delete(msg.id);
|
|
if (msg.ok) pending.resolve(msg.result);
|
|
else pending.reject(new Error(msg.error ?? 'callback error'));
|
|
break;
|
|
}
|
|
|
|
case 'hook-invoke': {
|
|
const handler = hookHandlers[msg.hookName];
|
|
if (!handler) {
|
|
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: `no handler for ${msg.hookName}` });
|
|
return;
|
|
}
|
|
try {
|
|
const result = handler(...(msg.args ?? []));
|
|
Promise.resolve(result).then(
|
|
(v) => sendToHost({ type: 'hook-result', id: msg.id, ok: true, result: v }),
|
|
(e) => sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (e as Error).message ?? String(e) }),
|
|
);
|
|
} catch (err) {
|
|
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (err as Error).message });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'slot-should-show': {
|
|
// Resolved by the background instance for any slot it offers.
|
|
const slotDef = pluginExports?.slots?.[msg.slot];
|
|
let show = true;
|
|
try {
|
|
if (slotDef && typeof slotDef.shouldShow === 'function') {
|
|
show = !!slotDef.shouldShow(msg.context);
|
|
}
|
|
} catch {
|
|
show = false;
|
|
}
|
|
sendToHost({ type: 'slot-should-show-result', id: msg.id, show });
|
|
break;
|
|
}
|
|
|
|
case 'locale-change':
|
|
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
|
break;
|
|
|
|
case 'props-update':
|
|
slotPropsUpdater?.(msg.props ?? {});
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ─── React entry ─────────────────────────────────────────────
|
|
|
|
export function SandboxRuntime(): React.JSX.Element {
|
|
useEffect(() => {
|
|
window.addEventListener('message', handleHostMessage);
|
|
// Initial ping. We don't know parent origin yet, so '*' is required.
|
|
// Guard at module scope so React strict mode's double-invoke doesn't
|
|
// re-post (and so a re-post can't race with the parent's init reply).
|
|
if (!readyPosted && window.parent && window.parent !== window) {
|
|
readyPosted = true;
|
|
window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
|
|
}
|
|
return () => {
|
|
window.removeEventListener('message', handleHostMessage);
|
|
};
|
|
}, []);
|
|
return <div id="plugin-sandbox-root" />;
|
|
}
|
|
|
|
// Suppress unused-variable warning when `mode` is only read for debugging.
|
|
void mode;
|
|
void slotName;
|