Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70)
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
This commit is contained in:
Claude Opus 4.8 2026-07-10 16:12:00 +02:00
parent ec469e1bc2
commit ec6ca80c4d
32 changed files with 1706 additions and 0 deletions

View File

@ -0,0 +1,150 @@
# @dorfteich/plugin-sdk
The contract between the Dorfteich host app and a plugin bundle: the
**manifest schema**, the **capability names**, and the typed **postMessage RPC
protocol**. It is the one package a plugin author needs — it builds standalone
and pulls in only `zod`.
Read [ADR 0008](../../docs/architecture/adr/0008-plugin-sandbox.md) and
[plugin-architecture.md](../../docs/architecture/plugin-architecture.md) first;
the manifest example there is normative and mirrored by the fixtures under
[`fixtures/manifests/`](./fixtures/manifests).
## What's in here
| Module | Purpose |
| ----------------- | ----------------------------------------------------------------------------------- |
| `manifest.ts` | Zod schema for `manifest.json` + `validateManifest` / `parseManifest`. |
| `api-version.ts` | `checkApiVersion` — is a plugin's `apiVersion` within the host's supported range? |
| `capabilities.ts` | Capability names and the method → capability map that gates RPC calls. |
| `rpc.ts` | Transport-agnostic RPC engine (`createRpcEndpoint`) + a `windowTransport` adapter. |
| `host.ts` | `createHostBridge` — host end: routes plugin calls through the permission gate. |
| `plugin.ts` | `createPlugin` — plugin end: answers lifecycle calls, exposes a typed `host` proxy. |
## Manifest
```ts
import { validateManifest } from '@dorfteich/plugin-sdk';
const result = validateManifest(JSON.parse(raw));
if (!result.success) {
// result.issues: [{ path: 'extensionPoints.0.type', message: '…' }, …]
}
```
`validateManifest` never throws — it returns a flat list of `{ path, message }`
issues so the install path (#71) can show a Site Admin every problem at once.
`parseManifest` is the throwing variant. Cross-field rules enforced beyond the
field shapes:
- extension point types must match the plugin `kind`
(`section_style` → `sectionStyle` only; `code``block`/`pageTool`);
- extension point `id`s are unique within the manifest;
- `section_style` plugins run no JavaScript and must not declare `permissions`.
## RPC protocol
Every code-plugin surface runs in a sandboxed `<iframe>` with an **opaque
origin** (ADR 0008): no cookies, no host DOM, no storage, no network. Host and
plugin talk **only** through `postMessage` with structured-clone payloads.
### Message shapes
All messages carry `protocol: "dorfteich.plugin.rpc/1"`; anything else on the
channel is ignored.
```jsonc
// request (either direction)
{ "protocol": "dorfteich.plugin.rpc/1", "type": "request",
"id": "rpc-…", "method": "getContent", "params": { } }
// success response
{ "protocol": "dorfteich.plugin.rpc/1", "type": "response",
"id": "rpc-…", "ok": true, "result": "# Hello" }
// error response
{ "protocol": "dorfteich.plugin.rpc/1", "type": "response",
"id": "rpc-…", "ok": false,
"error": { "code": "capability_not_permitted", "message": "…" } }
```
Both directions are symmetric — the same engine answers incoming requests and
correlates outgoing ones by `id`:
- **plugin → host**: capability calls (`getContent`, `listPages`, …). The host
gate resolves each method to its capability, rejects it with
`capability_not_permitted` unless the manifest declared that capability, then
executes it against the REST API **with the viewing user's session** — so a
plugin can never read more than the person looking at it could.
- **host → plugin**: lifecycle calls (`render`, `edit`, `destroy`).
Error codes: `unknown_method`, `capability_not_permitted`, `handler_error`,
`timeout`, `endpoint_disposed`. Every outgoing request has a timeout (default
10 s), so a hung plugin never blocks the app.
### Sequence
```mermaid
sequenceDiagram
participant H as Host (parent window)
participant P as Plugin (sandboxed iframe)
Note over H,P: mount
H->>P: request render { extensionPointId, locale, data }
activate P
P->>H: request getContent
activate H
H-->>P: response ok "# Hello"
deactivate H
P-->>H: response ok (rendered)
deactivate P
Note over H,P: undeclared capability
P->>H: request listPages
H-->>P: response error capability_not_permitted
Note over H,P: hung call
P->>H: request getPageContent
Note right of P: no response within timeout
P--xP: reject timeout
```
### Wiring the transport
The engine is transport-agnostic; hand it a `post`/`listen` pair. In the host
app the sandbox runtime (#73) builds it from the iframe boundary:
```ts
import { createHostBridge, windowTransport } from '@dorfteich/plugin-sdk';
const bridge = createHostBridge({
manifest,
capabilities: { getContent: () => currentPageMarkdown() /* … */ },
transport: windowTransport({ target: iframe.contentWindow!, source: window, targetOrigin: '*' }),
});
await bridge.invoke('render', { extensionPointId, locale, data });
```
Inside the plugin bundle:
```ts
import { createPlugin, windowTransport } from '@dorfteich/plugin-sdk';
const { host } = createPlugin({
transport: windowTransport({ target: window.parent, source: window, targetOrigin: '*' }),
onRender: async ({ locale }) => {
const md = await host.readCurrentPage.getContent();
// …render into document.body…
},
});
```
`targetOrigin: '*'` is intentional for sandbox frames: an `allow-scripts`
iframe **without** `allow-same-origin` has an opaque origin there is nothing to
pin, and the CSP already blocks it from reaching anywhere else.
## Scripts
- `pnpm build` — bundle ESM + CJS + types via tsup.
- `pnpm test` — Vitest (jsdom); the RPC suite drives a real `MessageChannel`.
- `pnpm typecheck``tsc --noEmit`.

View File

@ -0,0 +1,18 @@
{
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,19 @@
{
"id": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,18 @@
{
"id": "sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,19 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,19 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1.0",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,19 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "widget",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,10 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,18 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "section_style",
"extensionPoints": [
{
"type": "block",
"id": "x",
"title": {
"de": "X",
"en": "X"
}
}
],
"license": "MIT"
}

View File

@ -0,0 +1,27 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "dup",
"title": {
"de": "A",
"en": "A"
}
},
{
"type": "pageTool",
"id": "dup",
"title": {
"de": "B",
"en": "B"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,18 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT"
}

View File

@ -0,0 +1,19 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage", "deleteEverything"],
"license": "MIT"
}

View File

@ -0,0 +1,23 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT",
"fallback": {
"type": "video",
"value": "x.mp4"
}
}

View File

@ -0,0 +1,19 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "section_style",
"extensionPoints": [
{
"type": "sectionStyle",
"id": "note",
"title": {
"de": "H",
"en": "N"
}
}
],
"permissions": ["ui"],
"license": "MIT"
}

View File

@ -0,0 +1,20 @@
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "sample",
"title": {
"de": "Beispiel",
"en": "Sample"
}
}
],
"permissions": ["readCurrentPage"],
"license": "MIT",
"extraField": true
}

View File

@ -0,0 +1,23 @@
{
"id": "mermaid",
"name": "Mermaid Diagrams",
"version": "0.3.1",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "block",
"id": "diagram",
"title": {
"de": "Diagramm",
"en": "Diagram"
}
}
],
"permissions": ["blockData", "ui"],
"fallback": {
"type": "image",
"value": "assets/fallback.png"
},
"license": "MIT"
}

View File

@ -0,0 +1,26 @@
{
"id": "section-styles-basic",
"name": "Basic Section Styles",
"version": "1.0.0",
"apiVersion": "1",
"kind": "section_style",
"extensionPoints": [
{
"type": "sectionStyle",
"id": "note",
"title": {
"de": "Hinweis",
"en": "Note"
}
},
{
"type": "sectionStyle",
"id": "warning",
"title": {
"de": "Warnung",
"en": "Warning"
}
}
],
"license": "MIT"
}

View File

@ -0,0 +1,28 @@
{
"id": "toc",
"name": "Table of Contents",
"version": "1.2.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "pageTool",
"id": "toc",
"title": {
"de": "Inhaltsverzeichnis",
"en": "Table of contents"
}
}
],
"permissions": ["readCurrentPage"],
"fallback": {
"type": "text",
"value": "[Table of contents]"
},
"license": "MIT",
"homepage": "https://dorfteich.example/plugins/toc",
"i18n": {
"de": "i18n/de.json",
"en": "i18n/en.json"
}
}

View File

@ -0,0 +1,34 @@
{
"name": "@dorfteich/plugin-sdk",
"version": "0.0.0",
"private": true,
"description": "Plugin contract: manifest schema, capability names, and the postMessage RPC protocol shared by the host app and plugin bundles (ADR 0008)",
"license": "MIT",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^26.1.0",
"jsdom": "^26.0.0",
"tsup": "^8.3.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { checkApiVersion, HOST_API_VERSION, isApiVersionSupported } from './api-version';
describe('checkApiVersion', () => {
it('accepts a supported major', () => {
const result = checkApiVersion('1');
expect(result).toEqual({ compatible: true, version: 1 });
});
it('rejects a non-numeric version with a reason', () => {
const result = checkApiVersion('1.0');
expect(result.compatible).toBe(false);
expect(result.version).toBeNull();
expect(result.reason).toMatch(/whole-number major/i);
});
it('rejects a version above the host range', () => {
const result = checkApiVersion('2', { min: 1, max: 1 });
expect(result.compatible).toBe(false);
expect(result.version).toBe(2);
expect(result.reason).toMatch(/outside the supported range 11/);
});
it('rejects a version below the host range', () => {
const result = checkApiVersion('1', { min: 2, max: 3 });
expect(result.compatible).toBe(false);
expect(result.reason).toMatch(/outside the supported range 23/);
});
it('accepts any major inside a wider host range', () => {
expect(checkApiVersion('2', { min: 1, max: 3 }).compatible).toBe(true);
});
});
describe('isApiVersionSupported', () => {
it('mirrors checkApiVersion as a boolean', () => {
expect(isApiVersionSupported(String(HOST_API_VERSION))).toBe(true);
expect(isApiVersionSupported('99')).toBe(false);
});
});

View File

@ -0,0 +1,61 @@
/**
* apiVersion compatibility (ADR 0008: "incompatible plugins are refused at
* install time"). The manifest's `apiVersion` is a single major version string
* (e.g. `"1"`); the host declares the inclusive range of majors it supports and
* refuses anything outside it.
*/
/** The highest plugin API major this SDK release implements. */
export const HOST_API_VERSION = 1;
/** Inclusive range of plugin API majors a host accepts. */
export interface ApiVersionRange {
min: number;
max: number;
}
/** The default range: everything from major 1 up to the current host version. */
export const DEFAULT_API_VERSION_RANGE: ApiVersionRange = { min: 1, max: HOST_API_VERSION };
export interface ApiVersionCheck {
compatible: boolean;
/** The parsed major, or `null` when `apiVersion` was not a valid version. */
version: number | null;
/** Present only when incompatible: a human-readable, actionable reason. */
reason?: string;
}
/**
* Checks a manifest `apiVersion` against the host's supported range. Returns a
* structured result so callers can surface the reason to the Site Admin rather
* than a bare boolean.
*/
export function checkApiVersion(
apiVersion: string,
range: ApiVersionRange = DEFAULT_API_VERSION_RANGE,
): ApiVersionCheck {
if (!/^\d+$/.test(apiVersion)) {
return {
compatible: false,
version: null,
reason: `apiVersion "${apiVersion}" is not a whole-number major version`,
};
}
const version = Number.parseInt(apiVersion, 10);
if (version < range.min || version > range.max) {
return {
compatible: false,
version,
reason: `plugin apiVersion ${version} is outside the supported range ${range.min}${range.max}`,
};
}
return { compatible: true, version };
}
/** Convenience boolean form of {@link checkApiVersion}. */
export function isApiVersionSupported(
apiVersion: string,
range: ApiVersionRange = DEFAULT_API_VERSION_RANGE,
): boolean {
return checkApiVersion(apiVersion, range).compatible;
}

View File

@ -0,0 +1,59 @@
/**
* Plugin API capabilities (ADR 0008, plugin-architecture.md §"Plugin API").
*
* A capability is a named group of host methods a plugin may call. The plugin
* declares the capabilities it needs in its manifest `permissions`; the host
* router rejects any call to a method whose capability was not declared. Every
* call is executed by the host against the REST API with the **viewing user's**
* session, so a plugin can never read more than the person looking at it could.
*/
/** The capability names a manifest may declare in `permissions`. */
export const CAPABILITIES = [
'readCurrentPage',
'readPond',
'readBlock',
'blockData',
'ui',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/**
* Which host methods each capability unlocks. This is the single source of
* truth mapping an RPC method name to the capability that must be declared for
* it; both the host router (permission filtering) and the plugin-side `host`
* proxy derive from it.
*/
export const CAPABILITY_METHODS = {
readCurrentPage: ['getOutline', 'getContent', 'getMeta'],
readPond: ['listPages', 'getPageOutline', 'getPageContent'],
readBlock: ['getBlock'],
blockData: ['getData', 'setData'],
ui: ['resize', 'openPage', 'toast'],
} as const satisfies Record<Capability, readonly string[]>;
/** Every host method name across all capabilities. */
export type HostMethod = (typeof CAPABILITY_METHODS)[Capability][number];
/** Reverse index: method name → the capability that must be declared for it. */
export const METHOD_CAPABILITY: Readonly<Record<string, Capability>> = Object.fromEntries(
CAPABILITIES.flatMap((capability) =>
CAPABILITY_METHODS[capability].map((method) => [method, capability] as const),
),
);
/** Returns the capability a host method belongs to, or `undefined` if the
* method is not part of the v1 API surface. */
export function capabilityForMethod(method: string): Capability | undefined {
return METHOD_CAPABILITY[method];
}
/**
* Lifecycle methods the **host** calls on the **plugin** (the reverse
* direction of the capability methods above). A code plugin implements the
* subset it needs; unimplemented methods are answered with an
* `unknown_method` error by the plugin endpoint.
*/
export const PLUGIN_LIFECYCLE_METHODS = ['render', 'edit', 'destroy'] as const;
export type PluginLifecycleMethod = (typeof PLUGIN_LIFECYCLE_METHODS)[number];

View File

@ -0,0 +1,113 @@
/**
* Host side of the RPC channel. The host owns the plugin's iframe, answers the
* plugin's capability calls (filtered against the manifest `permissions`), and
* drives the plugin's lifecycle methods.
*/
import { capabilityForMethod, PLUGIN_LIFECYCLE_METHODS } from './capabilities';
import type { PluginLifecycleMethod } from './capabilities';
import type { PluginManifest } from './manifest';
import {
createRpcEndpoint,
RpcErrorObject,
type RpcEndpoint,
type RpcHandler,
type RpcRequestOptions,
type RpcTransport,
} from './rpc';
/**
* The host's implementations of the plugin API methods, keyed by method name
* (`getContent`, `listPages`, ). Each is executed against the viewing user's
* session by the host; only methods whose capability the plugin declared are
* ever reached (the router rejects the rest before calling in).
*/
export type HostCapabilityHandlers = Partial<Record<string, RpcHandler>>;
export interface HostBridgeOptions {
manifest: Pick<PluginManifest, 'permissions'>;
/** Host implementations of the plugin API methods. */
capabilities: HostCapabilityHandlers;
transport: RpcTransport;
/** Default timeout for host→plugin lifecycle calls, in ms. */
timeoutMs?: number;
}
export interface HostBridge {
/** Invokes a plugin lifecycle method (`render`, `edit`, `destroy`). */
invoke: <T = unknown>(
method: PluginLifecycleMethod,
params?: unknown,
options?: RpcRequestOptions,
) => Promise<T>;
/** Tears down the channel and rejects any in-flight lifecycle calls. */
dispose: () => void;
/** The underlying endpoint, exposed for advanced host integrations. */
endpoint: RpcEndpoint;
}
/**
* Builds the host end of the RPC channel for one plugin surface. Incoming
* capability calls are routed through a permission gate: a method is answered
* only if it belongs to a v1 capability, that capability is declared in the
* manifest, and the host actually implements it otherwise the caller gets a
* `capability_not_permitted` or `unknown_method` rejection.
*/
export function createHostBridge(options: HostBridgeOptions): HostBridge {
const declared = new Set(options.manifest.permissions);
const endpoint = createRpcEndpoint({
...options.transport,
timeoutMs: options.timeoutMs,
});
// Install one gated handler per known API method. The gate resolves the
// capability, checks the manifest declared it, then delegates to the host
// implementation (if any).
const gate = (method: string): RpcHandler => {
return async (params) => {
const capability = capabilityForMethod(method);
if (!capability) {
throw new RpcErrorObject({
code: 'unknown_method',
message: `"${method}" is not a plugin API method`,
});
}
if (!declared.has(capability)) {
throw new RpcErrorObject({
code: 'capability_not_permitted',
message: `plugin did not declare capability "${capability}" required for "${method}"`,
});
}
const impl = options.capabilities[method];
if (!impl) {
throw new RpcErrorObject({
code: 'unknown_method',
message: `host does not implement "${method}"`,
});
}
return impl(params);
};
};
// Register a gated handler for every method the host implements. Methods the
// host omits fall through to the endpoint's `unknown_method` response.
for (const method of Object.keys(options.capabilities)) {
endpoint.setHandler(method, gate(method));
}
return {
invoke: (method, params, requestOptions) => {
if (!PLUGIN_LIFECYCLE_METHODS.includes(method)) {
return Promise.reject(
new RpcErrorObject({
code: 'unknown_method',
message: `"${method}" is not a plugin lifecycle method`,
}),
);
}
return endpoint.request(method, params, requestOptions);
},
dispose: () => endpoint.dispose(),
endpoint,
};
}

View File

@ -0,0 +1,6 @@
export * from './api-version';
export * from './capabilities';
export * from './host';
export * from './manifest';
export * from './plugin';
export * from './rpc';

View File

@ -0,0 +1,119 @@
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parseManifest, validateManifest } from './manifest';
const fixturesDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'fixtures',
'manifests',
);
function loadFixture(kind: 'valid' | 'invalid', name: string): unknown {
return JSON.parse(readFileSync(path.join(fixturesDir, kind, name), 'utf8'));
}
function listFixtures(kind: 'valid' | 'invalid'): string[] {
return readdirSync(path.join(fixturesDir, kind))
.filter((f) => f.endsWith('.json'))
.sort();
}
describe('manifest validation — valid fixtures', () => {
const valid = listFixtures('valid');
it('ships at least the reference-plugin manifests', () => {
expect(valid).toEqual(
expect.arrayContaining(['mermaid.json', 'section-styles-basic.json', 'toc.json']),
);
});
it.each(valid)('%s validates cleanly', (name) => {
const result = validateManifest(loadFixture('valid', name));
expect(result.issues).toEqual([]);
expect(result.success).toBe(true);
expect(result.manifest?.id).toBeTruthy();
});
it('parseManifest returns a typed manifest', () => {
const manifest = parseManifest(loadFixture('valid', 'toc.json'));
expect(manifest.kind).toBe('code');
expect(manifest.extensionPoints[0]?.title.de).toBe('Inhaltsverzeichnis');
// permissions default is applied even though every fixture sets it.
expect(manifest.permissions).toContain('readCurrentPage');
});
it('defaults permissions to an empty array when omitted', () => {
const manifest = parseManifest(loadFixture('valid', 'section-styles-basic.json'));
expect(manifest.permissions).toEqual([]);
});
});
describe('manifest validation — invalid fixtures', () => {
const invalid = listFixtures('invalid');
it('provides at least ten invalid variants', () => {
expect(invalid.length).toBeGreaterThanOrEqual(10);
});
it.each(invalid)('%s is rejected with issues', (name) => {
const result = validateManifest(loadFixture('invalid', name));
expect(result.success).toBe(false);
expect(result.manifest).toBeUndefined();
expect(result.issues.length).toBeGreaterThan(0);
for (const issue of result.issues) {
expect(issue.path).toBeTruthy();
expect(issue.message).toBeTruthy();
}
});
// Spot-check that the messages are actionable and point at the right field.
const expectations: Record<string, { path: string | RegExp; message: RegExp }> = {
'01-missing-id.json': { path: 'id', message: /required/i },
'02-bad-id.json': { path: 'id', message: /lowercase slug/i },
'03-missing-name.json': { path: 'name', message: /required/i },
'04-bad-version.json': { path: 'version', message: /semantic version/i },
'05-bad-api-version.json': { path: 'apiVersion', message: /major version/i },
'06-bad-kind.json': { path: 'kind', message: /.+/ },
'07-empty-extension-points.json': { path: 'extensionPoints', message: /.+/ },
'08-kind-mismatch.json': {
path: 'extensionPoints.0.type',
message: /allows only .* extension points/i,
},
'09-duplicate-extension-point-id.json': {
path: 'extensionPoints.1.id',
message: /duplicate extension point id/i,
},
'10-title-missing-de.json': { path: 'extensionPoints.0.title.de', message: /required/i },
'11-unknown-permission.json': { path: 'permissions.1', message: /.+/ },
'12-bad-fallback.json': { path: 'fallback.type', message: /discriminator/i },
'13-section-style-with-permissions.json': {
path: 'permissions',
message: /must not declare permissions/i,
},
'14-unknown-field.json': { path: /.+/, message: /unrecognized|extraField/i },
};
it.each(Object.entries(expectations))(
'%s yields an actionable message',
(name, { path: expectedPath, message }) => {
const result = validateManifest(loadFixture('invalid', name));
const matching = result.issues.find(
(issue) =>
(expectedPath instanceof RegExp
? expectedPath.test(issue.path)
: issue.path === expectedPath) && message.test(issue.message),
);
expect(
matching,
`expected an issue at "${String(expectedPath)}" matching ${message} but got ${JSON.stringify(
result.issues,
)}`,
).toBeDefined();
},
);
});

View File

@ -0,0 +1,174 @@
import { z } from 'zod';
import { CAPABILITIES } from './capabilities';
/**
* Plugin manifest schema (ADR 0008, plugin-architecture.md §"manifest.json",
* which is normative). A `manifest.json` is validated at install time; invalid
* packages are rejected with the precise messages Zod produces here.
*/
/** Plugin classes by trust needs (ADR 0008). */
export const PLUGIN_KINDS = ['code', 'section_style'] as const;
export type PluginKind = (typeof PLUGIN_KINDS)[number];
/** Extension point types a manifest may declare. */
export const EXTENSION_POINT_TYPES = ['sectionStyle', 'block', 'pageTool'] as const;
export type ExtensionPointType = (typeof EXTENSION_POINT_TYPES)[number];
/** Extension point types that belong to each plugin kind. A `section_style`
* plugin exposes only `sectionStyle` points; a `code` plugin only `block` /
* `pageTool`. */
export const EXTENSION_POINTS_BY_KIND = {
section_style: ['sectionStyle'],
code: ['block', 'pageTool'],
} as const satisfies Record<PluginKind, readonly ExtensionPointType[]>;
/** A plugin `id`: a stable slug used in URLs, CSS scopes, and node attributes.
* Lowercase to keep CSS class scoping (`.dt-style-<id>-<styleId>`) predictable. */
const pluginIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
'must be a lowercase slug (letters, digits, single hyphens), e.g. "page-index"',
);
/** Semantic version, e.g. `1.2.0`. Kept strict so update comparisons (#71) are
* well-defined. */
const semverSchema = z.string().regex(/^\d+\.\d+\.\d+$/, 'must be a semantic version like "1.2.0"');
/** The host checks this against its supported range at install time
* (see api-version.ts). A single major, e.g. `"1"`. */
const apiVersionSchema = z
.string()
.regex(/^\d+$/, 'must be a whole-number major version string like "1"');
/** A human-readable label available in at least German and English (ADR 0012).
* Extra locales are allowed. */
const localizedTextSchema = z
.object({
de: z.string().min(1),
en: z.string().min(1),
})
.catchall(z.string().min(1));
const extensionPointSchema = z.object({
type: z.enum(EXTENSION_POINT_TYPES),
/** Unique within the manifest; used as the surface id (e.g. the `pageTool`
* key, or the section-style key in the CSS scope). */
id: pluginIdSchema,
title: localizedTextSchema,
});
export type ExtensionPointManifest = z.infer<typeof extensionPointSchema>;
/** Static representation rendered in Word/PDF exports and when a plugin is
* disabled but its blocks still exist in documents (plugin-architecture.md). */
const fallbackSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), value: z.string().min(1) }),
z.object({
type: z.literal('image'),
/** Path to a bundled asset, relative to the package root. */
value: z.string().min(1),
}),
]);
export type ManifestFallback = z.infer<typeof fallbackSchema>;
/** Optional references to bundled UI-string files, keyed by locale. */
const i18nRefsSchema = z.record(
z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'must be a locale code like "de" or "en-GB"'),
z.string().min(1),
);
export const manifestSchema = z
.object({
id: pluginIdSchema,
name: z.string().min(1).max(120),
version: semverSchema,
apiVersion: apiVersionSchema,
kind: z.enum(PLUGIN_KINDS),
extensionPoints: z.array(extensionPointSchema).min(1),
permissions: z.array(z.enum(CAPABILITIES)).default([]),
fallback: fallbackSchema.optional(),
/** SPDX identifier or free-form license string. */
license: z.string().min(1),
homepage: z.string().url().optional(),
i18n: i18nRefsSchema.optional(),
})
.strict()
.superRefine((manifest, ctx) => {
const allowed: readonly string[] = EXTENSION_POINTS_BY_KIND[manifest.kind];
// Extension point types must match the plugin kind, and ids must be unique.
const seen = new Set<string>();
manifest.extensionPoints.forEach((point, index) => {
if (!allowed.includes(point.type)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['extensionPoints', index, 'type'],
message: `kind "${manifest.kind}" allows only ${allowed
.map((t) => `"${t}"`)
.join(', ')} extension points, not "${point.type}"`,
});
}
if (seen.has(point.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['extensionPoints', index, 'id'],
message: `duplicate extension point id "${point.id}"`,
});
}
seen.add(point.id);
});
// Declarative section-style plugins run no JavaScript, so they cannot use
// capabilities — those only exist for the sandboxed code path.
if (manifest.kind === 'section_style' && manifest.permissions.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['permissions'],
message: 'section_style plugins run no code and must not declare permissions',
});
}
});
export type PluginManifest = z.infer<typeof manifestSchema>;
/** A single actionable validation problem: a dotted path and a message. */
export interface ManifestIssue {
path: string;
message: string;
}
export interface ManifestValidationResult {
success: boolean;
manifest?: PluginManifest;
issues: ManifestIssue[];
}
function toIssues(error: z.ZodError): ManifestIssue[] {
return error.issues.map((issue) => ({
path: issue.path.length > 0 ? issue.path.join('.') : '(root)',
message: issue.message,
}));
}
/**
* Validates an unknown value against the manifest schema, returning a flat list
* of actionable issues instead of throwing suited to the install path where
* every problem is reported to the Site Admin.
*/
export function validateManifest(input: unknown): ManifestValidationResult {
const result = manifestSchema.safeParse(input);
if (result.success) {
return { success: true, manifest: result.data, issues: [] };
}
return { success: false, issues: toIssues(result.error) };
}
/** Parses a manifest, throwing a `ZodError` on failure. Convenience for call
* sites that treat an invalid manifest as an exceptional condition. */
export function parseManifest(input: unknown): PluginManifest {
return manifestSchema.parse(input);
}

View File

@ -0,0 +1,136 @@
/**
* Plugin side of the RPC channel. A plugin bundle calls `createPlugin(...)`
* once; it wires up the message listener, answers the host's lifecycle calls
* with the supplied handlers, and returns a typed `host` proxy for calling the
* plugin API back.
*/
import { CAPABILITY_METHODS, CAPABILITIES } from './capabilities';
import { createRpcEndpoint, type RpcRequestOptions, type RpcTransport } from './rpc';
/** Page outline entry surfaced by `readCurrentPage.getOutline()` / TOC tools. */
export interface OutlineEntry {
id: string;
level: number;
text: string;
}
export interface PageMeta {
id: string;
title: string;
pondId: string;
slug: string;
}
export interface PageSummary {
id: string;
title: string;
slug: string;
}
/**
* The plugin API surface, grouped by capability. A plugin may call only the
* groups it declared in its manifest `permissions`; undeclared calls reject
* with `capability_not_permitted` at the host. Payload shapes are intentionally
* loose here (v1) and tightened by the capability endpoints in #74.
*/
export interface PluginHost {
readCurrentPage: {
getOutline: () => Promise<OutlineEntry[]>;
getContent: () => Promise<string>;
getMeta: () => Promise<PageMeta>;
};
readPond: {
listPages: () => Promise<PageSummary[]>;
getPageOutline: (pageId: string) => Promise<OutlineEntry[]>;
getPageContent: (pageId: string) => Promise<string>;
};
readBlock: {
getBlock: (pageId: string, blockId: string) => Promise<unknown>;
};
blockData: {
getData: () => Promise<unknown>;
setData: (data: unknown) => Promise<void>;
};
ui: {
resize: (height: number) => Promise<void>;
openPage: (pageId: string) => Promise<void>;
toast: (messageKey: string) => Promise<void>;
};
}
/** Params the host passes to lifecycle handlers. */
export interface RenderContext {
/** The plugin surface being rendered (extension point id). */
extensionPointId: string;
/** Locale to render in, e.g. `"de"`. */
locale: string;
/** For a `block` surface: the stored block data (may be undefined on first
* render of a fresh block). */
data?: unknown;
}
export interface PluginLifecycle {
/** Draw the surface. Called on mount and whenever inputs change. */
onRender?: (context: RenderContext) => void | Promise<void>;
/** Enter edit mode for a block surface. */
onEdit?: (context: RenderContext) => void | Promise<void>;
/** Release resources before the frame is torn down. */
onDestroy?: () => void | Promise<void>;
}
export interface CreatePluginOptions extends PluginLifecycle {
transport: RpcTransport;
/** Default timeout for plugin→host calls, in ms. */
timeoutMs?: number;
}
export interface PluginInstance {
host: PluginHost;
dispose: () => void;
}
// A single-argument method sends its argument as `params`; a multi-argument
// method sends the positional array. This keeps the wire format simple while
// letting the host implementation destructure as needed.
function toParams(args: unknown[]): unknown {
if (args.length === 0) return undefined;
if (args.length === 1) return args[0];
return args;
}
/**
* Initializes a plugin inside its sandboxed frame. Returns the `host` proxy
* (typed capability calls) and a `dispose()` to detach. The returned proxy
* issues real RPC calls lazily, so importing the SDK has no side effects until
* a method is invoked.
*/
export function createPlugin(options: CreatePluginOptions): PluginInstance {
const endpoint = createRpcEndpoint({
...options.transport,
timeoutMs: options.timeoutMs,
handlers: {
render: (params) => options.onRender?.(params as RenderContext),
edit: (params) => options.onEdit?.(params as RenderContext),
destroy: () => options.onDestroy?.(),
},
});
const call = (method: string, args: unknown[], requestOptions?: RpcRequestOptions) =>
endpoint.request(method, toParams(args), requestOptions);
// Build the grouped `host` proxy from the capability→methods map so it stays
// in lock-step with the protocol without hand-listing every method.
const host = {} as Record<string, Record<string, (...args: unknown[]) => Promise<unknown>>>;
for (const capability of CAPABILITIES) {
const group: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
for (const method of CAPABILITY_METHODS[capability]) {
group[method] = (...args: unknown[]) => call(method, args);
}
host[capability] = group;
}
return {
host: host as unknown as PluginHost,
dispose: () => endpoint.dispose(),
};
}

View File

@ -0,0 +1,163 @@
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' });
});
});

View File

@ -0,0 +1,269 @@
/**
* 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);
},
};
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist",
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// The RPC roundtrip exercises the postMessage transport, so the suite
// runs against a DOM (MessageChannel / window messaging under jsdom).
environment: 'jsdom',
},
});

19
pnpm-lock.yaml generated
View File

@ -309,6 +309,25 @@ importers:
specifier: ^1.3.7 specifier: ^1.3.7
version: 1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) version: 1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
packages/plugin-sdk:
dependencies:
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^26.1.0
version: 26.1.0
jsdom:
specifier: ^26.0.0
version: 26.1.0
tsup:
specifier: ^8.3.0
version: 8.5.1(@swc/core@1.15.43)(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)
vitest:
specifier: ^3.0.0
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
packages/shared: packages/shared:
dependencies: dependencies:
markdown-it: markdown-it: