mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
165 lines
13 KiB
Markdown
165 lines
13 KiB
Markdown
# Slice 7c: plugins (spec)
|
|
|
|
Status: **ready to build** (owner, 2026-10-30: decisions "Slice 7c's points" and "Per plugin at startup and reload";
|
|
spec reviews by Fable and Opus, `/tmp/s7c-spec-review-{fable,opus}.md`, folded in). 7c builds `chord/plugins`: the
|
|
code that turns plugin directories into facets, loads and reloads them per plugin, and watches them. A Chord app is
|
|
self-modifying (the owner or an agent edits a plugin while it runs), so this is core machinery, not a dev tool (no
|
|
dev/prod split). It also changes the kernel's `load` and `reload` to per-plugin failure (§4). HTTP and workers are slice
|
|
10, the web page host and the TUI slice 11: 7c builds scripts for every surface and evaluates them on Node; its build
|
|
results are plain data those slices ship.
|
|
|
|
Read in full before writing code: this spec; `decisions.md` (2026-10-29/30: "Slice 7b's open points, as recommended",
|
|
"A plugin's npm dependencies are bundled", "Per plugin at startup and reload", "Slice 7c's points"); `design.md` (§3
|
|
`load`/`reload`, §16, §18, §21 in full); the 7b brief `slice-7b/book.md` (chapters "A Plugin, File by File", "Building
|
|
and Loading", "Assets and Files", "Performance", "A Mini p2 from Chord"); the two spec reviews; p2's
|
|
`apps/p2/src/hosts/{bundles,plugins}.ts` and `apps/p2/docs/plugin-loading.md` §4.1, §7.1, §8, §9, §14; coding-agent's
|
|
`packages/coding-agent/src/core/package-manager.ts` (dependency installs). Code lives in
|
|
`packages/chord/src/node/plugins/` (Node-only: `fs`, esbuild, `vm`; the kernel stays portable),
|
|
exported as `./next/plugins` when v2 gets its entry point; until then tests import the source.
|
|
|
|
## 1. A plugin on disk
|
|
|
|
```text
|
|
plugins/notes/
|
|
package.json "name": the plugin's name (required, npm style); "dependencies": bundled; "peerDependencies":
|
|
host packages; "chord": { "external": ["better-sqlite3"] } (optional, Node facets only)
|
|
server.ts worker.ts web.tsx tui.ts one file per facet kind the app names; default export: a facet
|
|
contract.ts store.ts NoteList.tsx notes.css assets/… plain files
|
|
node_modules/ its own npm dependencies (`installDependencies`)
|
|
```
|
|
|
|
- The plugin's name is its `package.json` `name`, never the directory name. A directory without a `package.json`, or
|
|
with an invalid name, is reported, not a plugin.
|
|
- Facet kinds are the app's (`["server", "worker", "web", "tui"]` in p3), each `.ts`, `.tsx`, `.js` or `.mjs`; two
|
|
files of one kind are reported (not chosen by extension). A plugin with none of the kinds (contracts only) is a
|
|
plugin with no facets: imported by others, never loaded.
|
|
- Discovery lists each root one level deep, reads only `package.json`, follows symlinks to their real paths, and keeps
|
|
the first root's plugin of a duplicate name (the other reported).
|
|
|
|
## 2. Building
|
|
|
|
```ts
|
|
const plugins = await discoverPlugins(["./plugins", join(dataDir, "plugins")], { kinds: KINDS }, ctx);
|
|
const built = await buildFacet(plugins[0], "worker", { hostPackages: HOST_NAMES, platform: "node" }, ctx);
|
|
// { kind: "script", plugin, facet: "worker", source, digest, inputs: [{ file, hash }], copied: [file],
|
|
// css?: { name, text }, assets: [{ name, bytes }] } | { kind: "failed", plugin, facet, errors }
|
|
```
|
|
|
|
One esbuild build per (plugin, kind), from cold (no kept contexts: about 10 ms per facet; p2 measured 237 ms for 25
|
|
worker facets). A failed build is a result, never a throw. The result is plain data (strings, bytes, arrays): the same
|
|
value a worker (slice 10) or a page (slice 11) evaluates where it runs.
|
|
|
|
| An import of | Becomes |
|
|
| --- | --- |
|
|
| the plugin's own files | inlined |
|
|
| a file of another discovered plugin (`../sessions/contract.ts`) | inlined (a copy), listed in `copied`; it may import only host packages and files of its own plugin, never packages (a build error otherwise): contracts and side-effect-free modules only (decision 4) |
|
|
| a package in the plugin's `dependencies` | inlined from its `node_modules` |
|
|
| a package in `chord.external` (Node builds) | external, `require`d at evaluation from the plugin's `node_modules`; a browser build fails |
|
|
| a host package (`options.hostPackages`, e.g. `@earendil-works/chord`, `react`, `react/jsx-runtime`, `@app/core`) | external, from the map at evaluation |
|
|
| `@earendil-works/chord/plugins` | external always (its `pluginFile` is per facet, §3) |
|
|
| a Node builtin (`node:fs`, `fs`) | external (Node builds); a browser build fails |
|
|
| CSS (browser builds) | one stylesheet per facet (`css`) |
|
|
| an asset (`import url from "./icon.svg?url"`, `.wasm`, browser builds) | a content-hashed file in `assets`; the import is its URL under the app's `options.publicPath` |
|
|
| `import.meta.url`, `__dirname`, `__filename`, a literal dynamic `import("x")`, top-level `await` | a build error pointing to `pluginFile` (§3); a computed `import(x)` cannot be seen at build and fails when it runs, through the facet's `require` naming the facet |
|
|
| anything else (a relative path into base code, a package not in `dependencies`) | a build error naming the file and the rule |
|
|
|
|
- Output: `format: "cjs"`, wrapped `(function (require, module, exports) { … })`; TypeScript stripped, JSX automatic
|
|
(`react/jsx-runtime`); no type check; minified; an inline source map whose `sources` are relative to the plugin's
|
|
directory (so the script and its `digest`, a hash of the source, are the same on every machine and checkout).
|
|
- `inputs` are captured where esbuild loads each file (an `onLoad` that hashes the exact bytes it hands esbuild, as p2's
|
|
`captureBundleInputs`; the metafile has no hashes), absolute paths, for the watcher.
|
|
|
|
## 3. Evaluating (Node)
|
|
|
|
```ts
|
|
const facet = evaluateFacet(built, { hostPackages: HOST, plugin }); // returns the facet, or throws its error
|
|
```
|
|
|
|
- `vm.Script` under a stable filename per (plugin, kind, digest), path-like (`/chord-plugins/@pi/notes/worker.ts`
|
|
plus the digest), run in this context; the default export, checked to be a facet (a declaration error otherwise).
|
|
- **The `require` it passes answers, in order:** the host-package map (each value is what Node's `require` would give:
|
|
a CommonJS package's exports, an ESM package's namespace wrapped once with `__esModule: true`, so default and named
|
|
imports both work); `@earendil-works/chord/plugins` → `{ pluginFile }` bound to this plugin's directory;
|
|
`chord.external` packages and Node builtins through `createRequire(join(plugin.dir, "package.json"))`; anything else
|
|
throws naming the facet.
|
|
- **`pluginFile(path)`** gives the absolute path of a file inside the plugin's directory; `..` out of it is refused.
|
|
- **No module map, no cache:** an old facet's closures are collected once nothing holds them. The component turns V8's
|
|
compilation cache off when it is created (`v8.setFlagsFromString("--no-compilation-cache")`, once per process; an
|
|
app may pass the flag itself).
|
|
- **Stack traces** (decision Q1 (b)): `mapStack(error)` returns the error's stack with plugin scripts' positions mapped
|
|
through their source maps (a `module.SourceMap` per evaluated script, kept while the script is) to `notes/worker.ts:12`;
|
|
the app's reporters call it. Nothing global (no `Error.prepareStackTrace`, no process-wide source-map switch).
|
|
|
|
## 4. Loading and reloading, per plugin (kernel change)
|
|
|
|
Decision "Per plugin at startup and reload" changes `host.load` and `host.reload` (slice 7b) and the component's
|
|
`load`/`reload` helpers:
|
|
|
|
- **`host.load(facets, ctx)`** resolves with `{ failed: [{ plugin, error }] }`: a plugin whose setup throws (or that is
|
|
refused at declaration: invalid name, a type or registry served twice, a remote type on a host without principal or
|
|
address, `checkAccepts`) is left out and reported; the others publish; its consumers wait `failed { waiting }`. `load`
|
|
never closes the host for a plugin (base code decides whether its core failing is fatal). A plugin left out is
|
|
loaded by the next reload after an edit rebuilt it (it is dirty).
|
|
- **`host.reload(facets, ctx)`**: staging per plugin. A plugin whose new code fails in staging (setup, a factory against
|
|
the recording `self`, a declaration error, `checkAccepts`) keeps its loaded facet (as if passed unchanged), and
|
|
staging runs again without its new code. **The closure rule:** a changed plugin whose recorded requests (staging
|
|
records them, for its existing objects, with their contract versions) would not bind (the same check as a real
|
|
bind, the provider's host address included) because the plugin they need was kept old, or because nobody serves the
|
|
name while any plugin of this reload failed (a failed new setup may be what would have served it: owner, 2026-10-30,
|
|
"keep"), is kept old too (repeat until stable). The report gains `failed: [{ plugin, error }]` (the same list as `load`'s; an added plugin that fails is left out); the commit applies the rest.
|
|
- **The component** builds and evaluates per plugin: a plugin whose build fails or whose evaluation throws is passed to
|
|
the host as its loaded facet at a reload, with `reload(facets, context, { failed })` naming it (the host applies the
|
|
closure rule to it and lists it in `failed`), left out at startup and reported with the host's report.
|
|
`plugins.load(host, ctx)` and `plugins.reload(host, names, ctx)` keep, per host, the facet loaded for every plugin, so
|
|
`reload` always passes the complete set (unchanged plugins as the same facet object, removed ones left out) and never
|
|
restarts an unchanged plugin.
|
|
|
|
## 5. Watching and rollout
|
|
|
|
```ts
|
|
const plugins = createPlugins({ roots: ["./plugins"], kinds: KINDS, hostPackages: HOST, base: ["./src"] }, ctx);
|
|
plugins.state // a JSON watchable: { dirty: ["@pi/notes"], install: ["@pi/notes"], set: false, base: false }
|
|
const next = await plugins.build(["@pi/notes"], ctx); // built results per (plugin, kind), plain data
|
|
await rollout([serverStep, ...workerSteps, ...clientSteps], next, ctx); // the app's order (decision 3)
|
|
```
|
|
|
|
- **The watcher** (`fs.watch` recursive over roots and `base`) ignores `node_modules` and `.git`. A changed file marks
|
|
dirty every plugin whose facets used it (`inputs`; a copied contract dirties every user) or whose directory contains
|
|
it; a changed `package.json` marks `install`; a plugin directory added or removed sets `set` (discovery runs again
|
|
at the next build); a file under `base` sets `base` (the app restarts its hosts; Chord restarts nothing). A change
|
|
during a build keeps its mark. If the watcher cannot watch (the system's limit), it reports once and the app builds
|
|
on demand. It marks, never applies.
|
|
- **`installDependencies(plugin, ctx)`**: `npm install --omit=dev --legacy-peer-deps --ignore-scripts --no-audit
|
|
--no-fund` in the plugin's real directory, the command overridable (decision 3); it clears `install`.
|
|
- **`rollout(steps, built, ctx)`**: each step is `{ name, kinds, reload(built, ctx) }` and returns the host's report;
|
|
steps run in order; a step's `reload` evaluates the facets of its kinds where its host runs (an in-process step through
|
|
the component's per-host state, §4; a remote one, slice 10/11, gets the built data). It stops at a step that rejects
|
|
(a host closed, an abort); kept plugins do not stop it. Marks clear for plugins applied on every step; a plugin kept
|
|
on any host stays dirty.
|
|
|
|
## 6. Not in 7c
|
|
|
|
Installing plugins from npm or git (p2 §8), project plugins with trust and snapshots (design §26 #14), a base bundle per
|
|
surface, evaluating in a browser (slice 11), shipping built scripts to workers and pages (slices 10, 11), static checks
|
|
of cross-plugin imports beyond §2's rule.
|
|
|
|
## 7. Tests, examples, cost
|
|
|
|
- One test file per source file; fixture plugins under `test/node/fixtures/plugins/`. Discovery (names, duplicates,
|
|
missing or invalid `package.json`, two files of a kind, contracts-only plugins, symlinks); build (every row of §2's
|
|
table, failures as results, relative source maps and stable digests across two checkout paths, inputs hashed at
|
|
load); evaluation (host packages as the host's instances: `instanceof ChordError` across a plugin; default and named
|
|
imports of ESM and CommonJS host packages; `pluginFile` and its `..` refusal; an import-time throw; a non-facet
|
|
export); `mapStack` naming plugin files and lines; per plugin at startup and reload (§4: a failing setup, factory,
|
|
declaration, build, evaluation; the closure rule with a provider and consumer changed together; the report's `failed`
|
|
and `failed`; `load` never closing the host); the watcher (dirty marks from inputs and directories, `install`, `set`,
|
|
`base`, a change during a build, `node_modules` ignored); `rollout` (order, stopping at a rejecting step, marks).
|
|
- **GC:** 200 reloads of an evaluated plugin facet: old closures collected, heap flat after warm-up.
|
|
- **End to end:** a server and a worker (memory links) loading plugin directories, a plugin edited on disk, built and
|
|
rolled out (server, then worker), a holder seeing one frame; a broken edit kept old on both hosts and reported.
|
|
- Examples (owner, 2026-10-30: full coverage, each a folder `NN-topic/` with the hosts in `main.ts` and real plugin
|
|
directories under `plugins/`): `35-plugins` (load, edit, build, rollout), `36-plugin-failures` (per plugin at startup
|
|
and reload, the closure rule, an abort after the commit), `37-plugin-imports` (copied contracts, dependencies,
|
|
`chord.external`, host packages, `pluginFile`, the refusals), `38-watch-and-rollout` (the marks, plugins added and
|
|
removed, a stopped rollout), `39-browser-build` (CSS, assets, the built result as plain data). Benchmark (`benchmarks.md`): cold build of a 20-plugin set, a rebuild of one facet,
|
|
evaluation per facet, heap after 100 reloads.
|