feat: harden plugin sandbox and migrate in-tree plugins

This commit is contained in:
Linus Rath
2026-05-18 12:17:54 +02:00
parent e16f572252
commit 088810bd20
16 changed files with 728 additions and 1263 deletions
+88
View File
@@ -0,0 +1,88 @@
// Queue for plugin permission-consent requests.
//
// On first enable the plugin store posts a ConsentRequest here; the
// `PluginConsentDialog` component renders the head and resolves the promise
// when the user accepts or rejects. The store persists the granted set in
// `plugin.grantedPermissions` so future enables don't re-prompt.
import type { Permission } from '../plugin-types';
export interface ConsentRequest {
id: string;
pluginId: string;
pluginName: string;
permissions: Permission[];
resolve: (granted: boolean) => void;
}
const queue: ConsentRequest[] = [];
const listeners = new Set<() => void>();
function notify(): void {
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function requestConsent(pluginId: string, pluginName: string, permissions: Permission[]): Promise<boolean> {
return new Promise<boolean>((resolve) => {
queue.push({ id: uid(), pluginId, pluginName, permissions, resolve });
notify();
});
}
export function head(): ConsentRequest | null {
return queue[0] ?? null;
}
export function resolveHead(granted: boolean): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(granted); } catch { /* ignore */ }
notify();
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
// ─── Friendly labels for permission strings ───────────────────
const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
'contacts:write': { title: 'Modify your contacts', body: 'Create, edit, or delete contact entries.' },
'files:read': { title: 'Read your files', body: 'Browse files stored in your WebDAV folders.' },
'files:write': { title: 'Modify your files', body: 'Create, edit, rename, move, or delete files.' },
'identity:read': { title: 'Read your identities', body: 'Access the From addresses and signatures you send mail from.' },
'identity:write': { title: 'Modify your identities', body: 'Create, edit, or delete identities.' },
'filters:read': { title: 'Read your filters', body: 'Access your Sieve mail-filter rules.' },
'filters:write': { title: 'Modify your filters', body: 'Create, edit, or delete Sieve filter rules.' },
'tasks:read': { title: 'Read your tasks', body: 'Access your task list.' },
'tasks:write': { title: 'Modify your tasks', body: 'Create, edit, or delete tasks.' },
'templates:read': { title: 'Read your templates', body: 'Access stored mail templates.' },
'templates:write': { title: 'Modify your templates', body: 'Create, edit, or delete mail templates.' },
'smime:read': { title: 'Read your S/MIME state', body: 'Access information about installed S/MIME keys and certificates.' },
'vacation:read': { title: 'Read your vacation auto-reply', body: 'See the configured vacation auto-reply state.' },
'vacation:write': { title: 'Modify your vacation auto-reply',body: 'Create, change, or remove the vacation auto-reply.' },
'settings:read': { title: 'Read your settings', body: 'Access non-secret user preferences.' },
'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' },
'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' },
'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' },
'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
};
export function describePermission(perm: string): { title: string; body: string } {
return PERMISSION_LABELS[perm] ?? { title: perm, body: 'No description available.' };
}
+52 -1
View File
@@ -7,6 +7,7 @@ import { IMPLICIT_PERMISSIONS } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '../browser-navigation';
import { awaitDialog } from './host-dialog';
const PERM_PER_METHOD: Record<string, Permission | null> = {
// storage is unscoped by the manifest - implicit.
@@ -27,11 +28,20 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'admin.getAllConfig': 'admin:config',
'admin.setConfig': 'admin:config',
'admin.deleteConfig': 'admin:config',
// ui — any plugin can ask the host to render a modal or open a URL.
'ui.confirm': null,
'ui.alert': null,
'ui.openExternalUrl': null,
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
return plugin.permissions.includes(perm);
if (!plugin.permissions.includes(perm)) return false;
// Defense-in-depth: even if the manifest declares a permission, the host
// refuses the API call unless an admin has marked the plugin as managed,
// or the user has explicitly granted it via the consent dialog.
if (plugin.managed) return true;
return (plugin.grantedPermissions ?? []).includes(perm);
}
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
@@ -228,7 +238,48 @@ export async function dispatchApiCall(
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
case 'ui.confirm': {
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean };
return awaitDialog({
pluginId: plugin.id,
kind: 'confirm',
title: String(opts.title ?? plugin.name ?? 'Confirm'),
message: String(opts.message ?? ''),
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
danger: !!opts.danger,
});
}
case 'ui.alert': {
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string };
await awaitDialog({
pluginId: plugin.id,
kind: 'alert',
title: String(opts.title ?? plugin.name ?? 'Notice'),
message: String(opts.message ?? ''),
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
});
return undefined;
}
case 'ui.openExternalUrl': {
const url = String(args[0] ?? '');
// Only http(s) — the sandbox should not be able to navigate the host
// anywhere internal, nor open javascript:/data:/file: schemes.
let parsed: URL;
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`ui.openExternalUrl: ${parsed.protocol} not allowed`);
}
const target = typeof args[1] === 'string' ? (args[1] as string) : '_blank';
window.open(parsed.toString(), target, 'noopener,noreferrer');
return undefined;
}
default:
throw new Error(`Unhandled method "${method}"`);
}
}
// ─── Cleanup hook for unloading plugins ───────────────────────
export { cancelForPlugin as cancelPluginDialogs } from './host-dialog';
+75 -1
View File
@@ -14,6 +14,39 @@ 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 {
@@ -59,6 +92,8 @@ export class SandboxInstance {
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,
@@ -69,6 +104,12 @@ export class SandboxInstance {
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;
@@ -148,6 +189,26 @@ export class SandboxInstance {
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;
@@ -202,7 +263,11 @@ export class SandboxInstance {
updateProps(props: Record<string, unknown>): void {
if (this.destroyed) return;
this.send({ type: 'props-update', props });
// 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 {
@@ -215,6 +280,7 @@ export class SandboxInstance {
}
this.pendingHookInvokes.clear();
this.pendingShouldShow.clear();
this.callbackTable.clear();
}
}
@@ -253,6 +319,14 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
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,
};
+80
View File
@@ -0,0 +1,80 @@
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
// dialog here and resolves the awaited Promise after the user clicks. The
// `PluginDialogHost` component subscribes and renders one dialog at a time.
export type DialogKind = 'confirm' | 'alert';
export interface DialogRequest {
id: string;
pluginId: string;
kind: DialogKind;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
/** When true, confirm button uses destructive styling. */
danger?: boolean;
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
resolve: (ok: boolean) => void;
}
const queue: DialogRequest[] = [];
const listeners = new Set<() => void>();
function notify(): void {
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function enqueueDialog(req: Omit<DialogRequest, 'id'>): { id: string } {
const entry: DialogRequest = { ...req, id: uid() };
queue.push(entry);
notify();
return { id: entry.id };
}
export function head(): DialogRequest | null {
return queue[0] ?? null;
}
export function resolveHead(ok: boolean): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(ok); } catch { /* ignore */ }
notify();
}
/** Cancel every pending dialog for a plugin (called on unload). */
export function cancelForPlugin(pluginId: string): void {
let changed = false;
for (let i = queue.length - 1; i >= 0; i--) {
if (queue[i].pluginId === pluginId) {
const entry = queue[i];
queue.splice(i, 1);
try { entry.resolve(false); } catch { /* ignore */ }
changed = true;
}
}
if (changed) notify();
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Internal helper used by host-api to convert an `enqueueDialog` call into a
* Promise the plugin-side `await` can land on.
*/
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
return new Promise<boolean>((resolve) => {
enqueueDialog({ ...req, resolve });
});
}
+2
View File
@@ -15,6 +15,7 @@ import {
import { verifyBundle } from './bundle-integrity';
import { createBackgroundInstance } from './host-bridge';
import { register as registerActive, deregister as deregisterActive } from './registry';
import { cancelPluginDialogs } from './host-api';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -125,6 +126,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
}
removeAllPluginHooks(pluginId);
try { entry.background.destroy(); } catch { /* ignore */ }
cancelPluginDialogs(pluginId);
pluginErrorTracker.reset(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
+54 -1
View File
@@ -38,7 +38,25 @@ export interface SlotInit {
slot: SlotName;
/** Same bundle code as the background instance. */
code: string;
/** Initial props the host passes through from `PluginSlot` `extraProps`. */
/**
* Trimmed manifest (mirrors `BackgroundInit.manifest`). Slot iframes get the
* same fields so `api.plugin.settings` and `httpOrigins` work identically
* to the background context.
*/
manifest: {
id: string;
version: string;
permissions: string[];
settings: Record<string, unknown>;
locales?: Record<string, Record<string, string>>;
httpOrigins?: string[];
};
/**
* Initial props the host passes through from `PluginSlot` `extraProps`.
* Function values are pre-encoded by the host as
* `{ __pluginCallback: '<id>' }` markers and rehydrated to stub functions
* by the runtime; the stubs round-trip to the host via 'callback-invoke'.
*/
extraProps: Record<string, unknown>;
locale: string;
}
@@ -67,6 +85,25 @@ export interface ApiRequestMsg {
args: unknown[];
}
/** Sandbox → host: invoke a function the host passed in via `extraProps`. */
export interface CallbackInvokeMsg {
type: 'callback-invoke';
/** Round-trip id so the host can return a value if the caller awaits. */
id: string;
/** The callback marker id (matches `__pluginCallback`). */
callbackId: string;
args: unknown[];
}
/** Host → sandbox: response to a callback-invoke. */
export interface CallbackResponseMsg {
type: 'callback-response';
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
export interface HookResultMsg {
type: 'hook-result';
id: string;
@@ -91,6 +128,7 @@ export type SandboxToHost =
| InitDoneMsg
| InitErrorMsg
| ApiRequestMsg
| CallbackInvokeMsg
| HookResultMsg
| SlotResizeMsg
| SlotShouldShowResultMsg;
@@ -128,11 +166,25 @@ export interface SlotShouldShowMsg {
export type HostToSandbox =
| InitMsg
| ApiResponseMsg
| CallbackResponseMsg
| HookInvokeMsg
| LocaleChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg;
/** Marker used in extraProps for function values that the host owns. */
export interface PluginCallbackMarker {
__pluginCallback: string;
}
export function isCallbackMarker(value: unknown): value is PluginCallbackMarker {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { __pluginCallback?: unknown }).__pluginCallback === 'string'
);
}
// ─── Type guards ─────────────────────────────────────────────
export function isSandboxMessage(value: unknown): value is SandboxToHost {
@@ -154,6 +206,7 @@ export const API_METHODS = [
'http.post', 'http.fetch',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
] as const;
export type ApiMethod = (typeof API_METHODS)[number];
+94 -11
View File
@@ -46,6 +46,7 @@ let slotName: SlotName | null = null;
let bootDone = 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 {
@@ -74,7 +75,46 @@ function callApi(method: string, args: unknown[]): Promise<unknown> {
});
}
function buildPluginApi(manifest: BackgroundInit['manifest']) {
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,
@@ -97,6 +137,17 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
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. */
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
callApi('ui.confirm', [opts]) as Promise<boolean>,
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
callApi('ui.alert', [opts]) 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', []),
@@ -119,8 +170,11 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
* 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(): (name: string) => unknown {
function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
const known: Record<string, unknown> = {
'react': React,
'react-dom': ReactDOM,
@@ -128,15 +182,16 @@ function makePluginRequire(): (name: string) => unknown {
'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): PluginExports {
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
const mod: { exports: PluginExports } = { exports: {} };
const requireShim = makePluginRequire();
const requireShim = makePluginRequire(api);
let fn: (...args: unknown[]) => void;
try {
fn = new Function(
@@ -161,7 +216,8 @@ function evaluateBundle(code: string): PluginExports {
// ─── Init flow ───────────────────────────────────────────────
async function bootBackground(payload: BackgroundInit): Promise<void> {
const exports = evaluateBundle(payload.code);
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
// Register hooks (each value must be a function).
@@ -189,14 +245,15 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
// Side effects.
if (typeof exports.activate === 'function') {
await Promise.resolve(exports.activate(buildPluginApi(payload.manifest)));
await Promise.resolve(exports.activate(api));
}
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
}
function bootSlot(payload: SlotInit): void {
const exports = evaluateBundle(payload.code);
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
slotName = payload.slot;
@@ -208,11 +265,26 @@ function bootSlot(payload: SlotInit): void {
const rootEl = document.getElementById('plugin-sandbox-root');
if (!rootEl) throw new Error('Sandbox root element missing');
let currentProps: Record<string, unknown> = payload.extraProps;
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;
@@ -228,7 +300,7 @@ function bootSlot(payload: SlotInit): void {
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, currentProps));
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
};
const reactRoot = ReactDOM.createRoot(rootEl);
@@ -236,6 +308,9 @@ function bootSlot(payload: SlotInit): void {
sendToHost({ type: 'init-done', hooks: [], slots: [] });
}
// 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;
@@ -280,6 +355,15 @@ function handleHostMessage(ev: MessageEvent): void {
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) {
@@ -318,8 +402,7 @@ function handleHostMessage(ev: MessageEvent): void {
break;
case 'props-update':
// Phase-2: would push updates into the slot shell. Currently the slot
// iframe is torn down and recreated when props change at the host.
slotPropsUpdater?.(msg.props ?? {});
break;
}
}