From adfa8f3827e92947420d7c85458ff80566759d5e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 29 Oct 2026 12:15:57 +0000 Subject: [PATCH] docs(chord): slice 7b brief: plugin layout, building and loading, assets, HTTP, the app kit, measurements --- packages/chord/docs/next/slice-7b/book.md | 379 ++++++++++++++-- packages/chord/docs/next/slice-7b/index.html | 443 ++++++++++++++++--- 2 files changed, 728 insertions(+), 94 deletions(-) diff --git a/packages/chord/docs/next/slice-7b/book.md b/packages/chord/docs/next/slice-7b/book.md index bda87641d..8e2566b9c 100644 --- a/packages/chord/docs/next/slice-7b/book.md +++ b/packages/chord/docs/next/slice-7b/book.md @@ -2,12 +2,13 @@ title: Slice 7b: reloading plugins in a running Chord app cover: Slice 7b / Reload kicker: A design brief · Chord v2 · slice 7b · 2026-10-29 -subtitle: What happens to objects, references, jobs and registries when a plugin's code changes on one host or across many, what is decided and what the owner still decides +subtitle: What a plugin is, how it is built and loaded, how a mini p2 is assembled from Chord, and what happens when a plugin's code changes on one host or across many stats: - 4 | steps of a reload on one host - 3 | fates of a live object - 26 | carry-over items from slices 3a to 7a - - 5 | decisions open + - 11 ms | to rebuild one facet after an edit + - 6 | decisions open note: | A brief before the slice 7b spec, written by the redesign session from `design.md` §21, the dependency model (`dependencies/model.md`, decided and built for 7a in pi `339847eb1`), the carry-over in `slices.md` and the code at @@ -26,7 +27,7 @@ Chord v2 can do everything around that today: objects, references across hosts, 7a a dependency model in which a missing provider is a wait, not an error. What it cannot do yet is change code. `load` runs once per host (`host.ts`: "load runs once per host"). Slice 7b adds the reload, on one host and across hosts. -## Six things to know +## Eight things to know :::findings 1. **A reload is a transaction per host.** Load the bundles, stage the new declarations, commit atomically, then let @@ -39,8 +40,12 @@ runs once per host (`host.ts`: "load runs once per host"). Slice 7b adds the rel not fit waits (7a), it is not ended. 5. **7a already decided most of the hard part.** Removal, dropped versions and installs are waits woken by a publication; a reload's commit is such a publication. -6. **Five points are open:** the reload API, the rollout across hosts, loader D, a `writable` across a swap, and the - install watchdog. +6. **A plugin is a directory with a `package.json` and one file per surface.** Each facet is bundled alone; host + packages are passed in, never copied; a whole plugin set builds in a few hundred milliseconds. +7. **Chord should ship the app kit** (proposed): loader, HTTP, workers, web, TUI and dev reload as optional + components, so a mini p2 is a page of code. +8. **Six points are open:** the reload API, the rollout across hosts, a `writable` across a swap, the install + watchdog, where the loader lives, and the app kit. ::: ## Words used throughout @@ -51,6 +56,332 @@ A **reload** changes the code of one or more plugins on one host. A **facet** is in 7a's sense. A **pure** object has no natives (`self.open`), in its old or its new code. A **rollout** is a reload of the same code set on every host, server first. +# A Plugin, File by File {#plugin lede="What a plugin looks like on disk, and what each file is for."} + +A plugin is a directory. Its name is its `package.json` name; its code is one file per host kind it runs on, a +**facet**; everything else is ordinary TypeScript the facets import. This is p2's layout today (`apps/p2/plugins/*`), +plus the `package.json` that loader D needs (p2's `plugin-loading.md` §8; p2's built-in plugins have none yet and are +named by their directory). + +```text +plugins/notes/ + package.json name = the plugin's name; peerDependencies = host packages; dependencies = bundled npm deps + contract.ts the plugin's object types and registries (shared by its facets and by other plugins) + server.ts facet for the server (default export: defineFacet(...)) + worker.ts facet for session workers + web.tsx facet for browser tabs + tui.ts facet for the TUI + store.ts plain modules the facets import + NoteList.tsx + notes.css + assets/ + template.md files the plugin ships (read at runtime, or offered for download) + icon.svg + skills/notes/SKILL.md optional resources (p2 §4.4) +``` + +```json +{ + "name": "@pi/notes", + "version": "1.4.0", + "type": "module", + "peerDependencies": { + "@earendil-works/chord": "^2", + "@earendil-works/pi-durable": "^1", + "react": "^19" + }, + "dependencies": { + "marked": "16.4.1" + } +} +``` + +- **`name`** identifies the plugin everywhere: its facets, its registry contributions, diagnostics, reloads. +- **`peerDependencies`** are **host packages**: the running app supplies them, one instance per process (one Chord, + one React; `instanceof ChordError` and React hooks only work that way). The bundler never copies them. +- **`dependencies`** are the plugin's own npm packages (`marked`): installed into the plugin's `node_modules` with + `--ignore-scripts` and bundled into every facet that imports them. +- **No `tsconfig.json` is needed.** The bundler strips types and does not type-check; JSX is compiled with the + automatic React runtime, a fixed setting of the loader. A plugin may keep a `tsconfig.json` for its editor and for + `tsc --noEmit` (the SDK ships one to extend); the loader ignores it. + +A facet only declares (chapter III of the model): it serves object types, serves or contributes to registries, and +returns nothing. + +```ts +// plugins/notes/contract.ts +import { defineRegistry, local, remote, str, obj, arr, method } from "@earendil-works/chord/next"; +import { Session } from "@app/core"; // the app's host kinds, a host package +export const NoteStore = local("notes.store", { version: 1, params: {}, members: { /* … */ } }); +export const Notes = remote("notes", { + version: 2, + params: { session: str() }, + hostAddress: ({ session }) => Session({ id: session }), + variants: { editor: { state: { list: arr(obj({ id: str(), text: str() })) }, methods: { add: method(obj({ text: str() })) } } }, +}); + +// plugins/notes/worker.ts +import { defineFacet } from "@app/core"; // bound to the app's principal type +import { NoteStore, Notes } from "./contract.ts"; +import { serveStore } from "./store.ts"; +export default defineFacet((host) => { + host.serve(NoteStore, serveStore); + host.serve(Notes, ({ session }, principal, self) => { /* … */ }); +}); + +// plugins/notes/web.tsx +import { defineFacet } from "@app/core"; +import { NoteList } from "./NoteList.tsx"; // a React component, bundled +import "./notes.css"; // bundled into the facet's stylesheet +export default defineFacet((host, self) => { + self.contribute(Panels, { notes: NoteList }); // the app's UI registry +}); +``` + +# Building and Loading {#loading lede="How the bundler knows what to pull in, and how the code gets into a running process."} + +Each facet is built on its own, as one script. The bundler starts at the facet file and follows its imports: + +| An import of | Becomes | +| --- | --- | +| the plugin's own files (`./store.ts`, `./NoteList.tsx`) | inlined into the facet's script | +| another plugin's contract (`../sessions/contract.ts`, or `@pi/sessions/contract` for an installed package) | inlined too: a copy, so a plugin carries the contract version it was built against (`accepts` needs that); only contracts and side-effect-free modules may be imported across plugins | +| an npm package in `dependencies` (`marked`) | inlined, resolved from the plugin's own `node_modules` | +| a host package (`@earendil-works/chord/next`, `react`, `@app/core`, …) | **external**: a parameter of the script, the host's instance at load time | +| a Node builtin (`node:fs`) | external, through the `require` the loader passes (server and worker facets only; a web facet that imports one fails to build) | +| CSS (`import "./notes.css"`) | collected into one stylesheet per web facet | +| an asset by URL (`import iconUrl from "./assets/icon.svg?url"`) | a content-hashed file served by the app; the import is its URL | + +The built script is a function of its host packages. That is the whole trick of loader D: there is no module map, no +second copy of Chord, and an old version is garbage-collected once nothing holds its closures. + +```js +// what the bundler emits for plugins/notes/worker.ts (simplified) +(function (require, __files) { + const { defineFacet } = require("@app/core"); // the host's instance + const { local, remote, str } = require("@earendil-works/chord/next"); + const { marked } = /* marked's code, inlined */; + /* contract.ts, store.ts, inlined */ + return defineFacet((host) => { /* … */ }); +}) +``` + +```ts +// the loader (proposed @earendil-works/chord/next/loader), Node side +import { buildFacet, evaluateFacet } from "@earendil-works/chord/next/loader"; + +const HOST_PACKAGES = { // the app's base bundle exports these + "@earendil-works/chord/next": chordNext, "@earendil-works/pi-durable": durable, + "@app/core": core, react, "react/jsx-runtime": jsxRuntime, +}; +const script = await buildFacet({ plugin: "/data/plugins/notes", facet: "worker.ts", hostPackages: Object.keys(HOST_PACKAGES), platform: "node" }); +const facet = evaluateFacet(script, { hostPackages: HOST_PACKAGES, require: createRequire(import.meta.url), dir: "/data/plugins/notes" }); +await host.reload([{ name: "@pi/notes", facet }], ctx); // or host.load the first time +``` + +- `buildFacet` is esbuild with: the facet as entry, the host packages and `node:*` external, `format: "cjs"` wrapped + as the function above, minified, an `onResolve` that refuses anything outside the plugin, its `node_modules`, a + contract of another plugin or a host package. It returns the script, its content hash and esbuild's metafile (the + dev loop knows which files a facet used). +- `evaluateFacet` runs the script with `vm.Script` and a stable filename per (facet, hash) on Node, or as a classic + `