/** * Typed postMessage RPC protocol between the host app and a sandboxed plugin * iframe (ADR 0008, plugin-architecture.md §"Sandbox runtime"). See README.md * for the message shapes and a sequence diagram. * * The engine here is transport-agnostic: it is handed a `post`/`listen` pair so * the same code drives a real `iframe.contentWindow`/`window` boundary, a * `MessageChannel` (used by the tests), or any other structured-clone channel. */ /** Marks a message as belonging to this protocol so unrelated `message` events * on a shared window are ignored. */ export const RPC_PROTOCOL = 'dorfteich.plugin.rpc/1'; /** Error codes the protocol itself can raise (distinct from capability/business * errors, which a handler returns via its rejection `message`). */ export const RPC_ERROR_CODES = [ 'unknown_method', 'capability_not_permitted', 'handler_error', 'timeout', 'endpoint_disposed', ] as const; export type RpcErrorCode = (typeof RPC_ERROR_CODES)[number]; export interface RpcError { code: RpcErrorCode | string; message: string; } export interface RpcRequestMessage { protocol: typeof RPC_PROTOCOL; type: 'request'; id: string; method: string; params?: unknown; } export interface RpcResponseMessage { protocol: typeof RPC_PROTOCOL; type: 'response'; id: string; ok: boolean; result?: unknown; error?: RpcError; } export type RpcMessage = RpcRequestMessage | RpcResponseMessage; /** A handler for an incoming request. May be async; a thrown error or rejected * promise is reported to the caller as a `handler_error`. */ export type RpcHandler = (params: unknown) => unknown | Promise; export interface RpcTransport { /** Sends one protocol message across the boundary. */ post: (message: RpcMessage) => void; /** Subscribes to incoming protocol messages; returns an unsubscribe fn. */ listen: (onMessage: (message: RpcMessage) => void) => () => void; } export interface RpcEndpointOptions extends RpcTransport { /** Methods this endpoint answers when the peer calls it. */ handlers?: Record; /** Default timeout for outgoing requests, in ms (default 10000). */ timeoutMs?: number; /** Injectable id generator (tests pass a deterministic one). */ generateId?: () => string; } export interface RpcRequestOptions { /** Overrides the endpoint default for this call. */ timeoutMs?: number; } export interface RpcEndpoint { /** Sends a request to the peer and resolves with its result (or rejects with * an {@link RpcErrorObject}). */ request: ( method: string, params?: unknown, options?: RpcRequestOptions, ) => Promise; /** Registers/replaces a handler after construction. */ setHandler: (method: string, handler: RpcHandler) => void; /** Rejects all in-flight requests and stops listening. Idempotent. */ dispose: () => void; } /** Error thrown by {@link RpcEndpoint.request} carrying the protocol code. */ export class RpcErrorObject extends Error { readonly code: RpcErrorCode | string; constructor(error: RpcError) { super(error.message); this.name = 'RpcError'; this.code = error.code; } } interface Pending { resolve: (value: unknown) => void; reject: (reason: RpcErrorObject) => void; timer: ReturnType | undefined; } let idCounter = 0; function defaultGenerateId(): string { idCounter += 1; return `rpc-${Date.now().toString(36)}-${idCounter.toString(36)}`; } function toRpcError(error: unknown): RpcError { if (error instanceof RpcErrorObject) { return { code: error.code, message: error.message }; } if (error instanceof Error) { return { code: 'handler_error', message: error.message }; } return { code: 'handler_error', message: String(error) }; } /** * Creates one side of the RPC channel. Both host and plugin build an endpoint * over their transport; each can call the other and answer the other's calls. */ export function createRpcEndpoint(options: RpcEndpointOptions): RpcEndpoint { const handlers = new Map(Object.entries(options.handlers ?? {})); const pending = new Map(); const timeoutMs = options.timeoutMs ?? 10_000; const generateId = options.generateId ?? defaultGenerateId; let disposed = false; async function handleRequest(message: RpcRequestMessage): Promise { const handler = handlers.get(message.method); if (!handler) { options.post({ protocol: RPC_PROTOCOL, type: 'response', id: message.id, ok: false, error: { code: 'unknown_method', message: `no handler for method "${message.method}"` }, }); return; } try { const result = await handler(message.params); options.post({ protocol: RPC_PROTOCOL, type: 'response', id: message.id, ok: true, result, }); } catch (error) { options.post({ protocol: RPC_PROTOCOL, type: 'response', id: message.id, ok: false, error: toRpcError(error), }); } } function handleResponse(message: RpcResponseMessage): void { const entry = pending.get(message.id); if (!entry) return; // late/duplicate/unknown response — ignore. pending.delete(message.id); if (entry.timer) clearTimeout(entry.timer); if (message.ok) { entry.resolve(message.result); } else { entry.reject( new RpcErrorObject(message.error ?? { code: 'handler_error', message: 'request failed' }), ); } } const unlisten = options.listen((message) => { if (disposed || !message || message.protocol !== RPC_PROTOCOL) return; if (message.type === 'request') { void handleRequest(message); } else if (message.type === 'response') { handleResponse(message); } }); function request(method: string, params?: unknown, opts?: RpcRequestOptions): Promise { if (disposed) { return Promise.reject( new RpcErrorObject({ code: 'endpoint_disposed', message: 'RPC endpoint was disposed' }), ); } const id = generateId(); const effectiveTimeout = opts?.timeoutMs ?? timeoutMs; return new Promise((resolve, reject) => { const timer = effectiveTimeout > 0 ? setTimeout(() => { pending.delete(id); reject( new RpcErrorObject({ code: 'timeout', message: `request "${method}" timed out after ${effectiveTimeout}ms`, }), ); }, effectiveTimeout) : undefined; pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer, }); options.post({ protocol: RPC_PROTOCOL, type: 'request', id, method, params }); }); } function dispose(): void { if (disposed) return; disposed = true; unlisten(); for (const entry of pending.values()) { if (entry.timer) clearTimeout(entry.timer); entry.reject( new RpcErrorObject({ code: 'endpoint_disposed', message: 'RPC endpoint was disposed' }), ); } pending.clear(); } return { request, setHandler: (method, handler) => { handlers.set(method, handler); }, dispose, }; } /** * Adapts a pair of `postMessage`/`addEventListener('message')` objects into an * {@link RpcTransport}. `target` is where messages are posted (the peer window * or port), `source` is where replies arrive (usually the same object, or * `window` when the peer posts back to us). `targetOrigin` guards cross-window * posts; `'*'` is safe for opaque-origin sandbox frames that have no origin to * pin. */ export function windowTransport(params: { target: { postMessage: (message: unknown, targetOrigin?: string) => void }; source: { addEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void; removeEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void; }; targetOrigin?: string; }): RpcTransport { return { post: (message) => { if (params.targetOrigin === undefined) { params.target.postMessage(message); } else { params.target.postMessage(message, params.targetOrigin); } }, listen: (onMessage) => { const listener = (event: MessageEvent) => onMessage(event.data as RpcMessage); params.source.addEventListener('message', listener); return () => params.source.removeEventListener('message', listener); }, }; }