dorfteich/packages/plugin-sdk/src/rpc.ts
Claude Opus 4.8 ec6ca80c4d
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
Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70)
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
2026-07-10 16:12:00 +02:00

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);
},
};
}