All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 3m18s
CI / Auth e2e pack (push) Successful in 4m3s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Has been skipped
The SDK is the contract every other M7 story builds on (ADR 0008,
plugin-architecture.md). New package `@dorfteich/plugin-sdk`, standalone
(only depends on zod) so a plugin author needs nothing else.
- Zod manifest schema (`validateManifest`/`parseManifest`) with actionable
`{ path, message }` issues and cross-field rules (extension-point/kind
match, unique ids, section_style declares no permissions). Fixtures:
3 valid + 14 invalid variants, asserted individually.
- `checkApiVersion` compatibility helper against the host's supported range.
- Capability names + method→capability map as the single source of truth
for the permission gate.
- Transport-agnostic postMessage RPC engine (`createRpcEndpoint`) with
request/response ids, per-request timeouts, unknown-method and
endpoint-disposed handling, plus a `windowTransport` adapter.
- Host side (`createHostBridge`): routes plugin capability calls through
the manifest permission gate; drives plugin lifecycle (render/edit/destroy).
- Plugin side (`createPlugin`): answers lifecycle calls, exposes a typed
`host` proxy. RPC roundtrip verified in a jsdom MessageChannel test
(roundtrip, args, timeout, unknown method, undeclared capability, dispose).
- README documents the protocol with a mermaid sequence diagram.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
270 lines
8.4 KiB
TypeScript
270 lines
8.4 KiB
TypeScript
/**
|
|
* 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<unknown>;
|
|
|
|
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<string, RpcHandler>;
|
|
/** 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: <T = unknown>(
|
|
method: string,
|
|
params?: unknown,
|
|
options?: RpcRequestOptions,
|
|
) => Promise<T>;
|
|
/** 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<typeof setTimeout> | 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<string, RpcHandler>(Object.entries(options.handlers ?? {}));
|
|
const pending = new Map<string, Pending>();
|
|
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
const generateId = options.generateId ?? defaultGenerateId;
|
|
let disposed = false;
|
|
|
|
async function handleRequest(message: RpcRequestMessage): Promise<void> {
|
|
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<T>(method: string, params?: unknown, opts?: RpcRequestOptions): Promise<T> {
|
|
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<T>((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);
|
|
},
|
|
};
|
|
}
|