src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
9.2 KiB
Slice 12: the plugin model (spec)
Status: approved (owner, 2026-11-01: the plugin model's decisions, then "go ahead, just do all the things"; this
spec written by the redesign session from them, no separate spec review). Builds on 7c's plugins component
(src/node/plugins/) and slice 10's kinds and createPluginRuntime.
Read in full before writing code: this spec; decisions.md entries of 2026-11-01 "Contracts come from where the
plugin's code came from", "Plugins import only the app's versioned API", "The plugin model, points 3, 4, 5 and 8,
imports and builds", "Single-file plugins", "Testing a plugin alone"; design.md §21 (all of it, "The plugin model"
included) and §16-§17; slices.md (carry-over "Slice 12"); slice-7c.md, slice-10.md §3 "Plugins in workers";
src/node/plugins/README.md and its sources; the primer docs/plugin-primer/book.md.
1. The app's API (createPlugins option api)
const plugins = createPlugins({
roots: [{ path: "./plugins", required: true }, { path: join(dataDir, "plugins") }],
api: { name: "@acme/app", versions: ["v3", "v2"] }, // newest first
kinds: {
server: { platform: "node", hostPackages: ["@acme/app/v3", "@acme/app/v3/server", "@acme/app/v2"] },
worker: { platform: "node", hostPackages: ["@acme/app/v3", "@acme/app/v3/worker", "@acme/app/v2"] },
web: { platform: "browser", hostPackages: ["@acme/app/v3", "@acme/app/v3/web", "@acme/app/v2", "react", "react/jsx-runtime"] },
},
onError,
}, ctx);
apinames the app's API package and the versions it provides. A plugin import of<name>/<version>[/<kind>]: a version not listed is a build error naming the listed ones ("@acme/app/v1 is not available: this app provides v3, v2"); a listed one that the kind'shostPackageslacks (@acme/app/v3/webfrom a worker facet) is a build error naming the kind. One facet's imports of the API all use one version (a mix is a build error naming both).- Chord itself (
@earendil-works/chordand its subpaths other than/plugins, which stays as in 7c forpluginFile) is not importable by plugins: a build error pointing to the app's API. Apps that do not setapikeep 7c's behaviour (host packages as listed), so Chord's own tests and examples keep importing Chord directly where they model base code. rootsentries may be strings (as today) or{ path, required? }. A plugin of a required root whose build, evaluation or setup fails atplugins.loadmakesloadreject (PluginErrornaming it), after the host's own load settled; failures in other roots are reported as today. At a reload, a required plugin failing keeps its old code as any plugin (reported).
2. Imports between plugins (replaces slice 10's by-name lookup)
- Relative paths within a root: an import from one plugin into another plugin's directory under the same root
(
../notes/contract.ts) is inlined (listed incopied), the copied file under 7c's rule (only host packages and files of its own plugin). A relative path leaving the root (base code, another root) is a build error naming the file and pointing to the API or a dependency. A git checkout of several plugins cloned into a root is "within a root". - Package names across repositories: an import of a package in the plugin's
dependenciesresolves as npm does through that package'sexports(@bob/notes→ itscontract.ts,@bob/notes/web→contract.web.tsx), inlined at the installed version. A dependency may be an npm version or a git URL with a ref (github:bob/notes#<commit>,git+https://...#<tag>);installDependenciesinstalls it with npm as today. A plugin inside a multi-plugin repository is reached through a dependency on the whole repository, by path into it (@bob/plugins/notes/contract.ts) or itsexports(decision "chord.pluginsis a package's manifest of plugins"). chord.pluginsin a package'spackage.jsonlists the plugin folders it provides (globs, exclusions); used by discovery of a root's packages and byinstallPlugin; without it, conventional discovery.- The by-name lookup (
build.ts#byName), its error texts and the sixtsconfig.jsonpath aliases go; fixtures and examples 35-38 import relatively again.
3. Per-kind files and the import check
- A file whose name has a kind segment before its extension (
contract.web.tsx,ui.tui.ts, any*.<kind>.<ext>with<kind>one ofkinds) is a file of that kind: importing it (directly or through other files) from a facet of another kind is a build error naming the chain. Facet files keep their names (web.tsx); contract files arecontract.ts(shared) andcontract.<kind>.ts(x). - The check runs in the existing esbuild
onResolvepath, on the resolved file, for relative imports, dependencies and copied files alike. - Documented, not checked: a contract file mentions native things only through
import type(TypeScript erases it; a runtime import of a package the kind lacks already fails as above).
4. Single-file plugins
- Discovery also lists files at a root's top named
<name>.<kind>.(ts|tsx|js|mjs); files of one<name>form one plugin named<name>(an unscoped valid npm name, else reported), one facet per file,dirthe root, nopackage.json(no dependencies, nochord.external). A directory plugin of the same name in the same root is a reported duplicate (the directory wins). - Its build allows imports of host packages only (the app's API); anything else is a build error saying to make it a
directory.
pluginFilerefuses (it has no directory of its own). - The watcher: such a file appearing or disappearing at a root's top sets
set; a change marks the plugin dirty.
5. Installing plugins
installPlugin(source, root, ctx): source is { git: url, ref } (a shallow clone of the ref into
<root>/<repository name>; a repository whose top has a package.json is one plugin, otherwise its subdirectories
are plugins) or { npm: name, version } (npm pack and extract into <root>/<name>), then installDependencies for
each new plugin; marks set. Returns the installed plugin names. Removal is deleting the directory (set). No
updating in place: install another ref (a new directory) or delete and install.
6. testPlugin
const t = await testPlugin("./plugins/stats", {
kind: "worker",
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: "user", id: "u1" }, // the host's principal (optional)
}, ctx);
const stats = await t.host.get(Stats, { project: "p1" }, ctx);
t.contributions(Panels); // what the plugin contributed to a registry nobody serves (the helper serves it)
t.unserved(); // contract names requests wait for unbound `not_served`
await t.close(ctx);
- Built with the same build as production (
buildFacetwith the kind's options), evaluated withmodules, loaded on a host withaddress/principalas given, an in-memory store (:memory:SQLite); stubs loaded beside it. - The helper serves, as a collecting registry, every registry the plugin contributes to that no stub or the plugin
itself serves (
t.contributions(R): the current entries). t.unserved()lists the names of contracts whose requests wait unboundnot_servedon the host (the unbound index), so a test knows what to stub.t.root(fn, ctx, { principal })runs as another principal.- Exported from
chord/plugins(testing.ts); no test-only modes elsewhere.
7. Docs for authors and agents
docs/plugin-authoring.md: a plugin's layout (directory and single-file), its facet and contract files per kind, what it may import and how each import resolves, versions, how the server builds it (esbuild, what the build handles, no build hooks), installing from git or npm, testing withtestPlugin; written for agents (short, exact, with one complete example plugin).- Update
src/node/plugins/README.md, the guidedocs/guide-plugins-hosts/book.md(chapters "Plugins Using Plugins" and "Base Code and Plugins"; rebuildindex.html), design §21's slice-10 note.
8. Tests, examples
- One test file per source file; build tests for every refusal and allowance of §1-§4 (an unlisted version, a kind
module from another kind, mixed versions, a Chord import, a relative path leaving the root, a per-kind file through
a chain, a package from
dependenciesthroughexports, a git dependency with a ref (a local bare repository in a temp directory, no network), a subfolder dependency); discovery of single-file plugins; required roots at load;installPluginfrom a local git repository and a local npm tarball;testPlugin(stubs, collected contributions,unserved, a principal). - Examples:
49-plugin-api(an app API with two versions and per-kind modules, a plugin per version, the refusals),50-plugin-imports(relative within a root, a git dependency pinned to a commit, per-kind contract files),51-single-file-plugin,52-test-plugin(a plugin tested with a stub). Examples 35-38 updated. - No hot path changes; no benchmark beyond a cold build of the 20-plugin set (as 7c's) to show the checks cost nothing measurable.