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
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:
parent
ec469e1bc2
commit
ec6ca80c4d
150
packages/plugin-sdk/README.md
Normal file
150
packages/plugin-sdk/README.md
Normal 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`.
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "sample",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1",
|
||||
"kind": "code",
|
||||
"extensionPoints": [],
|
||||
"permissions": ["readCurrentPage"],
|
||||
"license": "MIT"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
23
packages/plugin-sdk/fixtures/manifests/valid/mermaid.json
Normal file
23
packages/plugin-sdk/fixtures/manifests/valid/mermaid.json
Normal 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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
28
packages/plugin-sdk/fixtures/manifests/valid/toc.json
Normal file
28
packages/plugin-sdk/fixtures/manifests/valid/toc.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
packages/plugin-sdk/package.json
Normal file
34
packages/plugin-sdk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
packages/plugin-sdk/src/api-version.test.ts
Normal file
41
packages/plugin-sdk/src/api-version.test.ts
Normal 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 1–1/);
|
||||
});
|
||||
|
||||
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 2–3/);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
61
packages/plugin-sdk/src/api-version.ts
Normal file
61
packages/plugin-sdk/src/api-version.ts
Normal 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;
|
||||
}
|
||||
59
packages/plugin-sdk/src/capabilities.ts
Normal file
59
packages/plugin-sdk/src/capabilities.ts
Normal 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];
|
||||
113
packages/plugin-sdk/src/host.ts
Normal file
113
packages/plugin-sdk/src/host.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
packages/plugin-sdk/src/index.ts
Normal file
6
packages/plugin-sdk/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './api-version';
|
||||
export * from './capabilities';
|
||||
export * from './host';
|
||||
export * from './manifest';
|
||||
export * from './plugin';
|
||||
export * from './rpc';
|
||||
119
packages/plugin-sdk/src/manifest.test.ts
Normal file
119
packages/plugin-sdk/src/manifest.test.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
174
packages/plugin-sdk/src/manifest.ts
Normal file
174
packages/plugin-sdk/src/manifest.ts
Normal 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);
|
||||
}
|
||||
136
packages/plugin-sdk/src/plugin.ts
Normal file
136
packages/plugin-sdk/src/plugin.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
163
packages/plugin-sdk/src/rpc.test.ts
Normal file
163
packages/plugin-sdk/src/rpc.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
269
packages/plugin-sdk/src/rpc.ts
Normal file
269
packages/plugin-sdk/src/rpc.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
10
packages/plugin-sdk/tsconfig.json
Normal file
10
packages/plugin-sdk/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
packages/plugin-sdk/vitest.config.ts
Normal file
9
packages/plugin-sdk/vitest.config.ts
Normal 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
19
pnpm-lock.yaml
generated
@ -309,6 +309,25 @@ importers:
|
||||
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)
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
markdown-it:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user