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
|
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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user