pi-mono/packages/chord/docs/plugin-authoring.md
Mario Zechner a5d209afd0 feat(chord): reload restarts every affected object; old work finishes on old code
The pure swap is gone: a reload restarts every live object of a changed plugin
(documents and tables kept). Calls running at the commit and every call they cause
(the ctx call chain; an old flag on call from trusted links) run on the open old
attempt, which seals once its work drained and its successor's recorded requests are
ready, bounded by reloadDrainTimeout (5 s); then the successor's factory runs. New work
waits at the gate; same-host consumers see the draining object live; a reload cycle
fails at once with unavailable/reload_cycle. Example 64, design section 21 rewritten.
2026-11-05 18:07:11 +00:00

276 lines
17 KiB
Markdown

# Writing a Chord plugin
For agents and people writing plugins for an app built on Chord v2. Self-contained: everything a plugin author needs
is here. In the examples the app is "Acme", its API package `@acme/app`; replace it with your app's (its docs name the
package and the versions it provides).
## What a plugin is
A plugin is a feature of a running app: it serves objects (state plus methods) and contributes to the app's
registries (routes, panels, tools). The app loads it on its hosts (the server, its workers, browser pages, terminal
clients), reloads it when it is edited, and leaves it out alone when it is broken. Plugins talk to each other only
through object contracts and registries.
## Layout
A **directory plugin**, in one of the app's plugin roots:
```text
plugins/stats/
package.json { "name": "@bob/stats", "dependencies": { ... } } required; the name is the plugin's
server.ts the server facet one file per host kind the app has, each optional:
worker.ts the worker facet <kind>.ts, .tsx, .js or .mjs; default export: a facet
web.tsx the web (page) facet
tui.ts the terminal client facet
contract.ts contracts every kind may import (data only)
contract.web.tsx contracts and tokens for web facets only (`contract.<kind>.ts(x)` per kind)
*.ts, *.css, assets/ plain files of the plugin
```
- `package.json`: `name` (an npm package name, scoped or not) is the plugin's name; `dependencies` are bundled into
its scripts; `"chord": { "external": ["better-sqlite3"] }` keeps named packages external in Node facets (native
addons).
- A **package of several plugins** (a repository holding them) lists their folders in its own `package.json`:
`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }` (paths, globs and `!` exclusions; `.` is the
package itself; a listed folder inside another listed one is refused). Without the field, the package is one plugin
when its top has facet files, else **every** subfolder with a `package.json` is a plugin: a repository with nested
packages that are no plugins (a helper library, examples, fixtures) must list its plugins in `chord.plugins`.
- A plugin with no facet file holds contracts others import.
- A **single-file plugin** is a file at a root's top named `<name>.<kind>.ts` (or `.tsx`, `.js`, `.mjs`):
`hello.server.ts` and `hello.web.tsx` are the plugin `hello` with two facets. No `package.json`, so no
dependencies; it imports only the app's API and defines its contracts inline. When it needs more, make it the
directory `hello/` with a `package.json` named `hello`: the app treats it as the same plugin.
## A facet
```ts
// plugins/stats/worker.ts
import { defineFacet, int, local, str } from "@acme/app/v3";
import { Indexers } from "@acme/app/v3/worker";
import { Notes } from "../notes/contract.ts";
const Stats = local("bob.stats", { version: 1, params: { project: str() }, members: { words: int() } });
export default defineFacet((host, self) => {
self.contribute(Indexers, { name: "word count" }); // a registry entry while the plugin is loaded
host.serve(Stats, ({ project }, self) => { // a factory, run per object
const notes = self.get(Notes, { project }); // a dependency: Stats waits until Notes is live
return { words: self.derive([notes.text], ([text]) => text.split(/\s+/).filter(Boolean).length) };
});
});
```
A facet only declares (`host.serve`, `self.contribute`, `host.references`), synchronously. Everything at runtime
happens in objects: state in documents and tables, standing behaviour in a `local` object with empty params (it lives
as long as the host). Names are prefixed by the plugin (`bob.stats`).
## Tables shown live
A table has no watch. To show its rows live, the object owning it declares a live query with a limit:
```ts
host.serve(Store, (_params, self) => { // a local type with empty params: it may have tables
const items = self.table(Items);
const newest = self.query(items, query(Items).orderBy((i) => i.id, "desc"), 50);
return { items: newest, add: async (row, ctx) => self.tx(async (tx) => tx.put(items, row), ctx) };
});
```
- Chord runs it before the object goes live and again in each commit of the object that writes the table; watchers get
only what changed. A commit writing no such table runs nothing.
- Only the object's own tables, the query fixed for the object's attempt (decide it from params), the limit required
(`invalid/args` without one).
- One query per writing commit, at most `limit` rows (for 100 rows about 60 µs more per commit, 80 µs when watched;
`benchmarks.md`): there may be dragons for a table written hundreds of times a second. Keep a document for those.
- Never read a table in `self.open` to fill a window: a live query reruns by itself after every commit and at a
reload's restart; a read in `self.open` is a one-time snapshot.
## What a plugin may import
| Import | Resolves to |
| --- | --- |
| its own files (`./label.ts`, `./icon.svg?url` in web facets) | inlined into its script |
| the app's API, a version in the path: `@acme/app/v3`, its kind's module `@acme/app/v3/worker` | the app's own instance (a host package), never copied |
| another plugin of the same root (one repository): `../notes/contract.ts` | inlined (a copy, frozen at this build) |
| a package in `dependencies`: `@bob/notes`, `@bob/notes/web` | inlined from `node_modules` at the installed version, through the package's `exports` |
| a plugin inside a dependency on a whole repository of several: `@bob/plugins/notes/contract.ts` | inlined, as any package file |
| a `chord.external` package, a Node builtin (Node facets) | loaded when the script runs |
| `pluginFile` from `@earendil-works/chord/plugins` | the absolute path of a file of this plugin |
Refused at build, each with the file, line and rule:
- `@acme/app/v1` when the app does not list v1 ("@acme/app/v1 is not available: this app provides v3, v2");
another kind's module (`@acme/app/v3/web` from a worker facet); two API versions in one facet.
- Chord itself (`@earendil-works/chord`): the app's API re-exports what plugins may use.
- A relative path leaving the plugin's root (base code, another root); a file of a directory that is no plugin.
- A file copied from another plugin importing anything but the API and its own plugin's files.
- A per-kind file (`contract.web.tsx`, `contract.Web.ts` alike) or another kind's facet file (`web.tsx`) reached from
a facet of another kind, directly or through other files (the error names the chain). Files inside npm packages are
checked only when the package is plugin code (its `package.json`, or one above it in the package, has a `chord`
field); ordinary libraries name files as they like (`pdf.worker.mjs`).
- A package file whose real path is outside the plugin's own `node_modules` (a `file:` dependency linked elsewhere, a
package importing `../src`).
- A copied file importing a Node builtin or a package.
- A package not in `dependencies`; `import.meta`, `__dirname`, `__filename`, a literal `import("x")`, top-level
`await` (read files through `pluginFile`).
- In a single-file plugin, anything but host packages.
Contract files mention native things (React components, functions) only through `import type`; at runtime they import
only the API and the plugin's own data-only files.
## An app's API is a package
The app's API is a workspace package of the app's repository: a `package.json` named `@acme/app` with an `exports`
entry per module, listed in the repository root's `workspaces`, so `npm install` links it into `node_modules`:
```json
{
"name": "@acme/app",
"private": true,
"type": "module",
"exports": {
"./v3": "./src/api/v3/index.ts",
"./v3/web": "./src/api/v3/web.ts",
"./v3/worker": "./src/api/v3/worker.ts"
}
}
```
Plugins, tests and tools resolve it alike: a facet imports `@acme/app/v3`, and so do the app's base code, a test, and
a plugin's `contract.ts` that a test or a script imports. In a plugin's build it stays a host package (left out of the
script, the process's own instance passed in when the script is evaluated).
## Errors
Throw Chord's own errors where Chord's built-in checks would, from the API (it re-exports them):
`invalid("args", "a note needs a title")` for arguments a method refuses (`"params"` for params a factory refuses),
`forbidden("viewers only")` for a principal a factory or method refuses. The caller gets the same
`{ code: "invalid", reason: "args" }` or `{ code: "forbidden" }` across every link. For codes of the plugin's own throw
`new PluginError(code, message, details)`. Only the code, reason, message and JSON details cross a link, never a stack
or a cause.
## Contracts and versions
A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import
each other's contract files by relative path. A plugin from another repository takes the contracts as a dependency
pinned to a version: an npm version, or a git ref (`"@bob/notes": "github:bob/notes#<commit>"`, `"git+https://...#v2"`).
The app's own contracts for third parties come with its API (`@acme/app/v3` exports `Notes`). When a contract
changes shape, bump its `version`; the provider serves older consumers through `accepts`, else they wait "contract
mismatch" (visible, never wrong data).
## Reloads
A reload of the plugin restarts each of its live objects. The calls running at that moment, and every call they make
with their `ctx`, finish on the old code; new calls wait for the new code; documents and tables stay. Two rules keep
that working: a root opened inside a method takes the method's `ctx` (`host.root(fn, withoutAbortSignal(ctx))`), so its
calls count as the method's; and work longer than a few seconds is a job, since a call still running at
`reloadDrainTimeout` (5 s) is cut `released`.
## How the app builds it
The server builds each facet with one esbuild call from source: TypeScript and TSX (JSX with the automatic React
runtime), JS, JSON, CSS (web facets: one stylesheet per facet), assets and WASM by URL (`import url from "./x.svg?url"`).
No type check, no build hooks, no `dist` needed; generated files are committed. The script is evaluated in each
process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup)
or keeps its old code (at a reload), reported with the error.
## Styles, themes and state across reloads (web and terminal facets)
- A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a
file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping
(`import styles from "./notes.module.css"; <div className={styles.card}>`). Plain CSS files still work; their class
names should then start with the plugin's name.
- Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`,
`var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them.
- In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours.
- A reload of the plugin remounts its components (each is a new function): their `useState` starts over. Keep what
must survive an update (a half-typed message, a scroll position) in page state, kept by the page's host under a key
prefixed with the plugin's name: `usePageState("stats.draft", "")` instead of `useState("")` (from the app's web
API); in a terminal facet `scope.pageState("stats.draft", "")` on the `TuiScope` it was given. Any value, never sent
anywhere, gone with the page.
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
## Messages
Text people read is written once, in code, and rendered in each viewer's language (design §23, "Messages and
languages"). From the app's API:
```ts
// plugins/stats/messages.ts: imported by every facet that renders, and by the worker when it builds messages as data
export const messages = defineMessages("bob.stats", {
en: { title: "Stats", words: (n: number) => (n === 1 ? "1 word" : `${formatNumber(n)} words`), done: (by: string) => `${by} finished` },
de: { title: "Statistik", words: (n) => (n === 1 ? "1 Wort" : `${formatNumber(n)} Wörter`), done: (by) => `${by} ist fertig` },
});
```
- The namespace is the plugin's name; keys are names (`title`), never English text; the English catalog comes first and
annotates its functions' parameters, the others take them from it. A missing key, an unknown one or a wrong argument
is a type error. Plurals and word order are functions; dates, times and numbers go through `formatTime` and
`formatNumber` inside them (they follow the language being rendered).
- Every facet that renders contributes the plugin's own catalogs: `self.contribute(Translations, messages.translations)`
(registries are per host; the host learns the languages offered from them).
- Render in the host's language: `const { t } = useMessages(messages)` in a component, `const t =
scope.messages(messages)` in a terminal facet. The plain `messages(key, ...)` has no language of its own (outside a
render: the first catalog); never use it in UI code.
- Objects never store sentences: a member typed `union([MessageSchema, nul()])` holds `messages.message("done", by)`
(keys whose arguments are strings, numbers, booleans or `null`; a date as an ISO string), and each client renders it
with `t.render(message)`, which shows the key of a message it does not know.
- A translation is a plugin: its catalogs typed `MessagesOf<typeof messages>` (a type-only import of the other plugin's
`messages.ts`), partial allowed, in one shared file contributed as a list from each facet that renders:
`self.contribute(Translations, [{ namespace: "bob.stats", locale: "fr", messages: statsFr }])`; `override: true`
replaces the plugin's own catalog of that locale.
- Never translated: the model's prompts and answers, log lines, error codes.
## Installing
- `plugins.installPlugin({ git: "https://github.com/bob/stats.git", ref: "v1.2.0" }, root, ctx)`: a shallow clone of
the branch, tag or commit into `<root>/stats`; its plugins are the folders its `chord.plugins` lists, else by
convention. `{ npm: "@bob/stats", version: "1.2.0" }`: the package extracted into `<root>/@bob/stats`. Then their
dependencies are installed (`npm install --omit=dev --ignore-scripts`; no lifecycle scripts run). Refused: a plugin
name the app has already, an existing directory, a ref or name starting with `-`.
- Removing is deleting the directory. To update, install another ref (a new directory) or delete and install again.
## Testing a plugin alone
```ts
import { testPlugin } from "@earendil-works/chord/plugins";
import * as apiV3 from "@acme/app/v3";
import * as apiV3Worker from "@acme/app/v3/worker";
const Notes = apiV3.local("acme.notes", { version: 1, params: { project: apiV3.str() }, members: { text: apiV3.str() } });
const Stats = apiV3.local("bob.stats", { version: 1, params: { project: apiV3.str() }, members: { words: apiV3.int() } });
const notesStub = apiV3.defineFacet((host) => host.serve(Notes, () => ({ text: apiV3.constant("a b c") })));
const t = await testPlugin("./plugins/stats", {
kind: "worker",
kinds: ["server", "worker", "web"], // the app's kinds (by default: those its files name)
api: { name: "@acme/app", versions: ["v3"] },
modules: { "@acme/app/v3": apiV3, "@acme/app/v3/worker": apiV3Worker }, // the real API modules
stubs: [{ name: "@acme/notes", facet: notesStub }], // facets serving what it uses
principal: { kind: "worker", project: "p1" }, // the host's principal (optional)
}, ctx);
const stats = await t.host.get(Stats, { project: "p1" }, ctx); // stats.words.value: 3
t.contributions(apiV3Worker.Indexers); // its entries in a registry nobody serves (the helper collects them)
t.unserved(); // contracts its requests wait for: stub these
await t.root(async (ctx) => ..., ctx, { principal: { kind: "user", id: "u1" } });
await t.close(ctx);
```
The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary
facets with fake data. A test imports the API by its package name and the plugin's own contracts from its
`contract.ts` (the build bundles its own copy; contracts are data: the same name, version and schema bind); contracts
of plugins it has no files of, it declares again, as `Notes` above.
## Checklist
1. `package.json` with a valid name (or a single file `<name>.<kind>.ts`).
2. One facet file per kind it runs on; each default-exports `defineFacet(...)`.
3. Imports: the app's API at one version, own files, same-root contract files by relative path, other repositories'
by package name pinned in `dependencies`.
4. Contracts prefixed with the plugin's name; a version bump for every change of shape.
5. Live rows of a table through `self.query(table, q, limit)`, never a read in `self.open`.
6. Files of one kind named `*.<kind>.ts(x)`.
7. A test with `testPlugin` and stubs for every name `t.unserved()` lists.
8. Texts people read in `messages.ts` (`defineMessages`), its catalogs contributed from each facet that renders, state
holding messages as data.