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
164 lines
5.5 KiB
TypeScript
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' });
|
|
});
|
|
});
|