dorfteich/packages/plugin-sdk/src/rpc.test.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

164 lines
5.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { createHostBridge } from './host';
import { createPlugin } from './plugin';
import { createRpcEndpoint, RpcErrorObject, type RpcMessage, type RpcTransport } from './rpc';
/** Wraps one end of a `MessageChannel` as an RPC transport. A `MessagePort`
* dispatches queued messages only after `start()`, which `addEventListener`
* does not call implicitly. */
function portTransport(port: MessagePort): RpcTransport {
port.start();
return {
post: (message) => port.postMessage(message),
listen: (onMessage) => {
const listener = (event: MessageEvent) => onMessage(event.data as RpcMessage);
port.addEventListener('message', listener);
return () => port.removeEventListener('message', listener);
},
};
}
const disposers: Array<() => void> = [];
afterEach(() => {
while (disposers.length) disposers.pop()?.();
});
/** Builds a connected host+plugin pair over a fresh channel. */
function connect(options: {
permissions: string[];
capabilities: Record<string, (params: unknown) => unknown>;
lifecycle?: Omit<Parameters<typeof createPlugin>[0], 'transport' | 'timeoutMs'>;
}) {
const channel = new MessageChannel();
const host = createHostBridge({
manifest: { permissions: options.permissions as never },
capabilities: options.capabilities,
transport: portTransport(channel.port1),
timeoutMs: 1000,
});
const plugin = createPlugin({
...options.lifecycle,
transport: portTransport(channel.port2),
timeoutMs: 1000,
});
disposers.push(
() => host.dispose(),
() => plugin.dispose(),
);
return { host, plugin, channel };
}
describe('RPC roundtrip (plugin → host)', () => {
it('resolves a declared, implemented capability call', async () => {
const { plugin } = connect({
permissions: ['readCurrentPage'],
capabilities: {
getContent: () => '# Hello',
getMeta: () => ({ id: 'p1', title: 'Hi', pondId: 'pond', slug: 'hi' }),
},
});
await expect(plugin.host.readCurrentPage.getContent()).resolves.toBe('# Hello');
await expect(plugin.host.readCurrentPage.getMeta()).resolves.toMatchObject({ id: 'p1' });
});
it('passes arguments through as params', async () => {
const getPageContent = vi.fn((pageId: unknown) => `content of ${String(pageId)}`);
const { plugin } = connect({
permissions: ['readPond'],
capabilities: { getPageContent },
});
await expect(plugin.host.readPond.getPageContent('page-9')).resolves.toBe('content of page-9');
expect(getPageContent).toHaveBeenCalledWith('page-9');
});
it('rejects a call whose capability the manifest did not declare', async () => {
const { plugin } = connect({
permissions: ['readCurrentPage'],
capabilities: { listPages: () => [] },
});
await expect(plugin.host.readPond.listPages()).rejects.toMatchObject({
code: 'capability_not_permitted',
});
});
it('rejects a declared capability the host does not implement', async () => {
const { plugin } = connect({
permissions: ['readCurrentPage'],
capabilities: { getContent: () => '' },
});
await expect(plugin.host.readCurrentPage.getOutline()).rejects.toMatchObject({
code: 'unknown_method',
});
});
it('surfaces a handler error as handler_error', async () => {
const { plugin } = connect({
permissions: ['readCurrentPage'],
capabilities: {
getContent: () => {
throw new Error('boom');
},
},
});
await expect(plugin.host.readCurrentPage.getContent()).rejects.toMatchObject({
code: 'handler_error',
message: 'boom',
});
});
});
describe('RPC roundtrip (host → plugin lifecycle)', () => {
it('invokes the plugin render handler with context', async () => {
const onRender = vi.fn();
const { host } = connect({
permissions: [],
capabilities: {},
lifecycle: { onRender },
});
await host.invoke('render', { extensionPointId: 'toc', locale: 'de' });
expect(onRender).toHaveBeenCalledWith({ extensionPointId: 'toc', locale: 'de' });
});
it('rejects a non-lifecycle host invocation', async () => {
const { host } = connect({ permissions: [], capabilities: {} });
await expect(host.invoke('nope' as never)).rejects.toBeInstanceOf(RpcErrorObject);
});
});
describe('RPC timeouts and disposal', () => {
it('rejects with a timeout when the peer never answers', async () => {
const channel = new MessageChannel();
// Only one side exists: the request is never answered.
const endpoint = createRpcEndpoint({ ...portTransport(channel.port1), timeoutMs: 20 });
disposers.push(() => endpoint.dispose());
await expect(endpoint.request('getContent')).rejects.toMatchObject({ code: 'timeout' });
});
it('answers unknown methods with unknown_method', async () => {
const channel = new MessageChannel();
createRpcEndpoint({ ...portTransport(channel.port1), handlers: {} });
const caller = createRpcEndpoint({ ...portTransport(channel.port2), timeoutMs: 200 });
disposers.push(() => caller.dispose());
await expect(caller.request('doesNotExist')).rejects.toMatchObject({
code: 'unknown_method',
});
});
it('rejects in-flight requests when disposed', async () => {
const channel = new MessageChannel();
const endpoint = createRpcEndpoint({ ...portTransport(channel.port1), timeoutMs: 1000 });
const pending = endpoint.request('getContent');
endpoint.dispose();
await expect(pending).rejects.toMatchObject({ code: 'endpoint_disposed' });
});
});