mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(chord): slice 7b brief: plugin layout, building and loading, assets, HTTP, the app kit, measurements
This commit is contained in:
parent
972d7017af
commit
adfa8f3827
2 changed files with 728 additions and 94 deletions
|
|
@ -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
|
||||
`<script>` in the browser (the page fetches the script and its stylesheet by hash, which caches forever).
|
||||
- **Imports a plugin cannot use:** dynamic `import()` (resolved at build, or not at all), top-level `await` (a facet is a
|
||||
synchronous declaration), `require` of anything but builtins.
|
||||
|
||||
:::review | Open 5: where the loader lives
|
||||
(a) Chord ships `chord/next/loader` (discovery, `buildFacet`, `evaluateFacet`, `pluginFile`), an app chooses its host
|
||||
packages; (b) Chord knows only `host.reload(facets)` and the app bundles and evaluates. Recommended: (a); the kernel
|
||||
stays free of it, and Durable Objects (no `eval`) compose statically as before.
|
||||
:::
|
||||
|
||||
# Assets and Files {#assets lede="Files a plugin ships: bundled into the browser, read at runtime, or offered for download."}
|
||||
|
||||
A plugin has files that are not code. They fall into three cases.
|
||||
|
||||
**1. Imported by a web facet.** CSS, images, wasm. The bundler emits them as content-hashed files next to the facet's
|
||||
script, and the import evaluates to their URL. The app serves the bundle's files under one path; p2 does exactly this
|
||||
today for the terminal's wasm (`plugins/terminals/Terminal.tsx`: `import ghosttyWasm from
|
||||
"../../vendor/ghostty/ghostty-vt.wasm?url"`, served under `/__p2_web/`).
|
||||
|
||||
```tsx
|
||||
import iconUrl from "./assets/icon.svg?url";
|
||||
export const NoteIcon = () => <img src={iconUrl} alt="" />;
|
||||
```
|
||||
|
||||
**2. Read at runtime by a server or worker facet.** A template, a wasm module for a native, a script a native runs. A
|
||||
bundled script has no meaningful `import.meta.url` (today's `plugins/terminals/main.ts` reads its wasm with
|
||||
`new URL("../../vendor/…", import.meta.url)`). The loader passes the plugin's directory, and a helper resolves a path
|
||||
inside it, refusing `..` out of it:
|
||||
|
||||
```ts
|
||||
// plugins/notes/worker.ts
|
||||
import { pluginFile } from "@earendil-works/chord/next/loader"; // reads the directory the loader passed
|
||||
const template = await readFile(pluginFile("assets/template.md"), "utf8");
|
||||
```
|
||||
|
||||
**3. Offered for download over HTTP.** A plugin contributes a route to the app's HTTP registry; the handler opens the
|
||||
file through the same helper and streams it, with the plugin's own authorization:
|
||||
|
||||
```ts
|
||||
// plugins/notes/server.ts
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /notes/template.md": async (request, ctx) => {
|
||||
if (ctx.principal.kind !== "owner") return { kind: "error", status: 403 };
|
||||
const path = pluginFile("assets/template.md");
|
||||
const { size } = await stat(path);
|
||||
return { kind: "bytes", name: "template.md", mime: "text/markdown", size, read: (offset, length) => readRange(path, offset, length) };
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
So yes: a plugin can offer its files for download, because the loader tells it where it lives. Each facet sees only its
|
||||
own plugin's directory; nothing gives it another plugin's files.
|
||||
|
||||
# HTTP {#http lede="What Chord provides, what p2 has, and what p3 needs."}
|
||||
|
||||
**Chord v2 has no HTTP server today.** Design §23 designs one (`@earendil-works/chord/next/http`) and defers it until
|
||||
"the kernel fixes and a first real proof" are done. p2's HTTP is its own, about 30 lines of helpers plus the server host:
|
||||
|
||||
```ts
|
||||
// apps/p2/src/runtime/server.ts (p2 today, Chord v1)
|
||||
export const Routes = defineRegistry<RouteHandler>("p2.routes", { conflict: "error" }); // "METHOD /path" → handler
|
||||
export const Upgrades = defineRegistry<UpgradeHandler>("p2.upgrades", { conflict: "error" }); // WebSocket upgrades
|
||||
export const RequestResolvers = defineRegistry<RequestResolver>("p2.request-resolvers", { conflict: "error" });
|
||||
type RouteHandler = (request: IncomingMessage, response: ServerResponse, access: RequestAccess, ctx: Ctx) => void | Promise<void>;
|
||||
```
|
||||
|
||||
Plugins contribute handlers that write to Node's `ServerResponse` themselves; the files plugin implements ranges,
|
||||
download headers and previews by hand (`plugins/files/server.ts`).
|
||||
|
||||
The designed component (§23) replaces raw responses with descriptions, so every plugin gets ranges (206/416), `HEAD`,
|
||||
safe download headers, streaming and abort on disconnect from one place:
|
||||
|
||||
```ts
|
||||
export const Http = defineRegistry<HttpContribution, Router>("chord.http", { version: 1 });
|
||||
type HttpContribution = { routes?: Record<string, Route>; wraps?: Wrap[] };
|
||||
type Route = (req: HttpRequest, ctx: Ctx) => Promise<HttpResponse>; // ctx.principal: the request's principal
|
||||
type HttpResponse =
|
||||
| { kind: "json"; value: JsonValue } | { kind: "page"; title: string; body: string }
|
||||
| { kind: "bytes"; name: string; mime: string; size: number; read(offset: number, length: number): Promise<Uint8Array> }
|
||||
| { kind: "redirect"; to: string } | { kind: "error"; status: number };
|
||||
```
|
||||
|
||||
- Routes compose by `keyedEntries`: a duplicate pattern is a reported conflict, the first by plugin name wins.
|
||||
- Wraps (middleware) run in phases the owner defines (`observe`, `headers`, `transform`), by default only around their
|
||||
own plugin's routes.
|
||||
- Request resolvers (who sent a request: cookie, loopback, relay) are hooks; WebSocket byte channels for terminals are
|
||||
a separate component.
|
||||
|
||||
# Performance {#perf lede="Measured on slayer, Node 24, esbuild from the repo, p2's plugins as they are."}
|
||||
|
||||
Built with the loader D settings above (each facet alone, host packages and `apps/p2/src` external, minified), on
|
||||
p2's real plugins:
|
||||
|
||||
| Measurement | Result |
|
||||
| --- | --- |
|
||||
| all 25 worker facets, one script each, cold | 237 ms, 294 KB in total, largest `models/worker.ts` 35 KB |
|
||||
| all 20 server facets, cold | 167 ms, 236 KB |
|
||||
| all 17 web facets, cold | 144 ms, 912 KB, of which 688 KB is the terminal's wasm |
|
||||
| one facet rebuilt after an edit (esbuild context, warm) | 11 ms |
|
||||
| compile and evaluate all 25 worker scripts (`vm.Script`) | 23 ms |
|
||||
| worker spawn to ready, p2 today (TypeScript through the source resolver, `nodeLoader`) | 549 ms (p2's measurement, `plugin-loading.md` §14.1) |
|
||||
| the same with one bundle per worker plugin set | 218 ms |
|
||||
| base bundle plus one script per plugin through ESM (model B) | 254 ms; D is expected near it |
|
||||
|
||||
The plugins are small; the time goes into the host packages and the runtime (85% of a worker's load time, p2 §14.1),
|
||||
which the base bundle loads once per process. A reload of one plugin costs one rebuild (about 11 ms warm) and one
|
||||
evaluation (about 1 ms per facet), then Chord's reload. The memory caveat is V8's compilation cache: every distinct
|
||||
compiled script stays cached, so scripts get a stable name per (facet, hash) and hosts that reload in place run with
|
||||
the cache off (+0.1 MiB instead of +5.6 MiB per 100 reloads, p2's measurement).
|
||||
|
||||
# A Mini p2 from Chord {#kit lede="The owner: building a small p2 with a web and a TUI surface and plugins should be trivial from Chord; optional components should come out of the box."}
|
||||
|
||||
p2 has about 10,700 lines of host code in `apps/p2/src/hosts` and its runtime: the server's HTTP and WebSocket
|
||||
entry, the worker supervisor, plugin discovery, the web and TUI bundles, the web shell, the TUI sandbox and launcher,
|
||||
the reload control. Almost none of it is p2-specific. The proposal: Chord ships it as optional components, each a
|
||||
subpath an app imports only if it wants it, none of them in Chord's kernel.
|
||||
|
||||
## What it would look like
|
||||
|
||||
```ts
|
||||
// server.ts: a whole app with plugins, a web and a TUI surface
|
||||
import { createHost } from "@earendil-works/chord/next";
|
||||
import { discoverPlugins, pluginLoader } from "@earendil-works/chord/next/loader";
|
||||
import { httpServer } from "@earendil-works/chord/next/http";
|
||||
import { workerRoute } from "@earendil-works/chord/next/workers";
|
||||
import { webSurface } from "@earendil-works/chord/next/web";
|
||||
import { tuiSurface } from "@earendil-works/chord/next/tui";
|
||||
import { devReload } from "@earendil-works/chord/next/dev";
|
||||
import { Server, Session, core } from "./core.ts"; // the app's base code: host kinds, principals, core facet
|
||||
|
||||
const plugins = await discoverPlugins(["./plugins", join(dataDir, "plugins")]);
|
||||
const loader = pluginLoader({ hostPackages: core.hostPackages });
|
||||
|
||||
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
|
||||
server.route(Session, workerRoute({ entry: "./worker.ts", idleMs: 300_000, authorize: core.authorize }), ctx);
|
||||
await server.load([core.serverFacet, ...(await loader.facets(plugins, "server"))], ctx);
|
||||
|
||||
const http = httpServer(server, { port: 4321, authenticate: core.authenticate }); // routes from the Http registry
|
||||
http.surface(webSurface({ loader, plugins, shell: core.webShell })); // /: the page, its bundle, its socket
|
||||
http.surface(tuiSurface({ loader, plugins })); // the TUI launcher's bundle and socket
|
||||
devReload({ server, loader, plugins, watch: true }); // a changed plugin reloads everywhere
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker.ts
|
||||
import { workerMain } from "@earendil-works/chord/next/workers";
|
||||
await workerMain(async ({ address, init, parent }) => {
|
||||
const worker = createHost<P>({ address, principal: { kind: "worker", session: init.id }, onError });
|
||||
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx);
|
||||
await worker.load([core.workerFacet(init), ...(await loader.facets(init.plugins, "worker"))], ctx);
|
||||
return worker;
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core.ts, the web shell: the app decides the layout; plugins fill registries
|
||||
export const Panels = defineRegistry<Record<string, ComponentType>, Panel[]>("app.panels", { version: 1 });
|
||||
export const webShell = ({ host }: ShellProps) => {
|
||||
const panels = useRegistry(host, Panels); // from chord/next/web: a hook over a registry value
|
||||
return <Tabs>{panels.map((p) => <p.component key={p.name} />)}</Tabs>;
|
||||
};
|
||||
```
|
||||
|
||||
And a plugin is what chapter I shows: a directory, a `package.json`, facets that serve types and contribute to the
|
||||
app's registries (`Http`, `Panels`, a TUI command list).
|
||||
|
||||
## The components
|
||||
|
||||
| Subpath | What it does | Replaces in p2 |
|
||||
| --- | --- | --- |
|
||||
| `chord/next/loader` | `discoverPlugins` (directory listing, `package.json` names, no parsing), `buildFacet` (esbuild, host packages external), `evaluateFacet` (Node `vm`, browser `<script>`), `pluginFile`, npm/git install with `--ignore-scripts` | `src/hosts/plugins.ts`, `bundles.ts`, the source resolver, `nodeLoader` |
|
||||
| `chord/next/http` | the Node HTTP server, the `Http` registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, `HEAD`), WebSocket entry calling `host.accept` | `src/hosts/server.ts` (HTTP part), `runtime/http.ts`, `Routes`, `Upgrades`, `RequestResolvers` |
|
||||
| `chord/next/workers` | `workerRoute` (fork a worker per address, IPC link, start message, idle stop from `host.activity`, crash restart with backoff), `workerMain` (the child's side) | `SessionHosts` in `runtime/server.ts`, `hosts/worker.ts` |
|
||||
| `chord/next/web` | the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: `useObject` over `host.hold`, `useRegistry`, `useWatch`), serving the bundle by hash, reload on a new manifest | `hosts/web/*`, `runtime/web.tsx`, `src/web/reconnect.ts` |
|
||||
| `chord/next/tui` | the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries | `hosts/tui-*.ts`, `runtime/tui.ts` |
|
||||
| `chord/next/dev` | the source watcher marking plugins dirty, the reload control (`reload(plugins)` on every host in order: server, workers, then a manifest bump for pages and the TUI) | `performReload` in `hosts/server.ts`, the dev protocol |
|
||||
|
||||
Each is ordinary code on top of Chord's public API: nothing in the kernel depends on it, a Durable Object deployment
|
||||
uses none of it, and an app can replace any one (its own HTTP server, its own shell). React is a host package of the web
|
||||
component, pi-tui of the TUI component; an app without them never imports them.
|
||||
|
||||
## What stays the app's
|
||||
|
||||
The principal type and authentication; the host kinds (`Server`, `Session`) and what a worker's start message holds;
|
||||
the core facet and core objects (catalog, harness); the web shell's layout and the TUI's look; which registries plugins
|
||||
contribute to beyond `Http`. That is roughly what p3's base code should be: a few hundred lines, not ten thousand.
|
||||
|
||||
:::review | Open 6: the app kit
|
||||
(a) Chord ships these six components as optional subpaths of `@earendil-works/chord`, built in this order: `loader`
|
||||
and `dev` with 7b (reload needs them), `http` and `workers` next (p3 needs them first), `web` and `tui` after (lifted
|
||||
from p2's code, which works today). (b) Chord ships only `loader`; the rest is p3's base code. Recommended: (a), as the
|
||||
owner asked; each component gets its own slice spec and review, after 7b and before slice 9 or alongside p3's base.
|
||||
:::
|
||||
|
||||
# One Host Reloads {#host lede="Four steps, all or nothing up to the commit."}
|
||||
|
||||
Design §21 fixes the shape. **Decided.**
|
||||
|
|
@ -249,40 +580,6 @@ host stays on its old code; (b) no Chord timer: the app's context bounds the rel
|
|||
context like everything else, and the app passes its deadline; a host option would be the only timer left in reload.
|
||||
:::
|
||||
|
||||
# Loader D {#loader lede="How a plugin's new code gets into a running process."}
|
||||
|
||||
p2 designed it (`apps/p2/docs/plugin-loading.md` §9, §14); Chord has not built it. **Decided in p2's design:**
|
||||
|
||||
- **One base bundle per surface** holds every host package once: Chord, Durable, pi-ai, pi-tui, TypeBox, React and
|
||||
`react-dom`, plus the app's runtime. A change to it restarts the hosts.
|
||||
- **One script per facet**, an esbuild bundle wrapped as a function of its host packages, which are external and
|
||||
passed in, so there is one Chord and one React per process:
|
||||
|
||||
```js
|
||||
// notes/worker.facet.js, built
|
||||
(require, chord, durable, react) => {
|
||||
const { defineFacet, local, str } = chord; // the host's instance, not a copy
|
||||
/* the plugin's own files, contracts included (bundled: their versions are the plugin's) */
|
||||
return defineFacet((host, self) => { … });
|
||||
}
|
||||
```
|
||||
|
||||
- **Evaluation:** `vm.Script.runInThisContext` on Node (server, workers, TUI worker), a classic `<script>` plus one CSS
|
||||
file in the browser. No module map: an old generation is closures the GC reclaims once nothing holds them.
|
||||
- **Stable script names** per (facet, content digest), with V8's compilation cache off on hosts that reload in place
|
||||
(measured: every distinct compile otherwise stays in memory, +5.6 MiB per 100 compiles of 72 KB).
|
||||
- **Contracts are never host packages:** each facet bundles its own contract version, or `accepts` would never apply.
|
||||
- **Blockers** to solve: `instanceof` across plugins, plugin files shared between plugins (copied with their module
|
||||
state), `import.meta.url`, Node builtins through the passed `require`, CSS and wasm assets.
|
||||
|
||||
:::review | Open 5: where loader D lives
|
||||
(a) Chord ships the facet-script format, its evaluation (Node `vm`, browser `<script>`) and an esbuild helper that
|
||||
marks host packages external; an app chooses its host packages. (b) Chord knows only `host.reload(facets)`;
|
||||
bundling and evaluation are entirely the app's (p3's base code). Recommended: (a): every Chord app gets live reload,
|
||||
and the blockers are solved once; Chord's kernel stays free of it (a separate subpath, `@earendil-works/chord/next/loader`),
|
||||
so Durable Objects, which forbid `eval`, compose statically as before.
|
||||
:::
|
||||
|
||||
# Decisions {.epilogue lede="What is settled for the spec, and what the owner decides first."}
|
||||
|
||||
**Decided** (spec material): the four steps and rollback per host; the three fates and the parking of a swap with a
|
||||
|
|
@ -291,7 +588,8 @@ ids on restart; broken objects retried on reload; the handover of a parked attem
|
|||
re-subscribed or freshly bound; unbound `version` and `not_served` instead of `gone`; old-version calls and adapted
|
||||
bindings as diffs; `accepts` in `host.serve`; jobs' version mapping, `blocked { version }` and retries on reload, runs
|
||||
aborted on a native restart; registries swapped in one batch; rollout order, serialized installs, the digest,
|
||||
`installing`, installs never waiting for activations; loader D's design.
|
||||
`installing`, installs never waiting for activations; loader D's design: one script per facet, host packages passed in,
|
||||
contracts bundled, no module map, stable script names, plugins' npm dependencies installed with `--ignore-scripts`.
|
||||
|
||||
**Open** for the owner:
|
||||
|
||||
|
|
@ -302,3 +600,4 @@ aborted on a native restart; registries swapped in one batch; rollout order, ser
|
|||
| 3 | who runs the rollout: a Chord protocol, or the app calling `host.reload` per host | the app |
|
||||
| 4 | the install watchdog: a host option `installTimeout`, or the app's context | the app's context |
|
||||
| 5 | where loader D lives: Chord (`chord/next/loader`), or the app | Chord |
|
||||
| 6 | the app kit: Chord ships loader, HTTP, workers, web, TUI and dev reload as optional subpaths, or only the loader | Chord ships them, in that order |
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ table{max-width:100%}
|
|||
<section class="cover">
|
||||
<div class="kicker">A design brief · Chord v2 · slice 7b · 2026-10-29</div>
|
||||
<h1>Slice 7b<br>Reload</h1>
|
||||
<p class="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</p>
|
||||
<div class="cover-stats"><div class="stat"><b>4</b><span>steps of a reload on one host</span></div><div class="stat"><b>3</b><span>fates of a live object</span></div><div class="stat"><b>26</b><span>carry-over items from slices 3a to 7a</span></div><div class="stat"><b>5</b><span>decisions open</span></div></div>
|
||||
<p class="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</p>
|
||||
<div class="cover-stats"><div class="stat"><b>4</b><span>steps of a reload on one host</span></div><div class="stat"><b>3</b><span>fates of a live object</span></div><div class="stat"><b>26</b><span>carry-over items from slices 3a to 7a</span></div><div class="stat"><b>11 ms</b><span>to rebuild one facet after an edit</span></div><div class="stat"><b>6</b><span>decisions open</span></div></div>
|
||||
<div class="cover-note"><p>A brief before the slice 7b spec, written by the redesign session from <code>design.md</code> §21, the dependency model
|
||||
(<code>dependencies/model.md</code>, decided and built for 7a in pi <code>339847eb1</code>), the carry-over in <code>slices.md</code> and the code at
|
||||
<code>339847eb1</code>. Marks: <strong>Decided</strong> points cite the decision; <strong>Open</strong> points are the owner's, with a recommendation.</p>
|
||||
|
|
@ -120,13 +120,18 @@ table{max-width:100%}
|
|||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||||
<li><a href="#host">I. One Host Reloads</a></li>
|
||||
<li><a href="#fates">II. Three Fates</a></li>
|
||||
<li><a href="#consumers">III. Consumers Across a Reload</a></li>
|
||||
<li><a href="#jobs">IV. Jobs Across a Reload</a></li>
|
||||
<li><a href="#registries">V. Registries Across a Reload</a></li>
|
||||
<li><a href="#rollout">VI. Across Hosts</a></li>
|
||||
<li><a href="#loader">VII. Loader D</a></li>
|
||||
<li><a href="#plugin">I. A Plugin, File by File</a></li>
|
||||
<li><a href="#loading">II. Building and Loading</a></li>
|
||||
<li><a href="#assets">III. Assets and Files</a></li>
|
||||
<li><a href="#http">IV. HTTP</a></li>
|
||||
<li><a href="#perf">V. Performance</a></li>
|
||||
<li><a href="#kit">VI. A Mini p2 from Chord</a></li>
|
||||
<li><a href="#host">VII. One Host Reloads</a></li>
|
||||
<li><a href="#fates">VIII. Three Fates</a></li>
|
||||
<li><a href="#consumers">IX. Consumers Across a Reload</a></li>
|
||||
<li><a href="#jobs">X. Jobs Across a Reload</a></li>
|
||||
<li><a href="#registries">XI. Registries Across a Reload</a></li>
|
||||
<li><a href="#rollout">XII. Across Hosts</a></li>
|
||||
<li><a href="#decisions">Decisions</a></li>
|
||||
</ol></nav>
|
||||
|
||||
|
|
@ -139,7 +144,7 @@ restarting a single process.</p>
|
|||
<p>Chord v2 can do everything around that today: objects, references across hosts, the store, jobs, registries, and since
|
||||
7a a dependency model in which a missing provider is a wait, not an error. What it cannot do yet is change code. <code>load</code>
|
||||
runs once per host (<code>host.ts</code>: "load runs once per host"). Slice 7b adds the reload, on one host and across hosts.</p>
|
||||
<h3 id="six-things-to-know">Six things to know</h3>
|
||||
<h3 id="eight-things-to-know">Eight things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>A reload is a transaction per host.</strong> Load the bundles, stage the new declarations, commit atomically, then let
|
||||
each object follow; any code error before the commit leaves the host entirely on its old code.</li>
|
||||
|
|
@ -151,8 +156,12 @@ calls see the new code.</li>
|
|||
not fit waits (7a), it is not ended.</li>
|
||||
<li><strong>7a already decided most of the hard part.</strong> Removal, dropped versions and installs are waits woken by a
|
||||
publication; a reload's commit is such a publication.</li>
|
||||
<li><strong>Five points are open:</strong> the reload API, the rollout across hosts, loader D, a <code>writable</code> across a swap, and the
|
||||
install watchdog.</li>
|
||||
<li><strong>A plugin is a directory with a <code>package.json</code> and one file per surface.</strong> Each facet is bundled alone; host
|
||||
packages are passed in, never copied; a whole plugin set builds in a few hundred milliseconds.</li>
|
||||
<li><strong>Chord should ship the app kit</strong> (proposed): loader, HTTP, workers, web, TUI and dev reload as optional
|
||||
components, so a mini p2 is a page of code.</li>
|
||||
<li><strong>Six points are open:</strong> the reload API, the rollout across hosts, a <code>writable</code> across a swap, the install
|
||||
watchdog, where the loader lives, and the app kit.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p>A <strong>reload</strong> changes the code of one or more plugins on one host. A <strong>facet</strong> is a plugin's code for one host kind; a
|
||||
|
|
@ -162,7 +171,362 @@ in 7a's sense. A <strong>pure</strong> object has no natives (<code>self.ope
|
|||
of the same code set on every host, server first.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="host"><header class="opener"><div class="numeral">I</div><h2>One Host Reloads</h2><p class="lede">Four steps, all or nothing up to the commit.</p></header>
|
||||
<section class="chapter" id="plugin"><header class="opener"><div class="numeral">I</div><h2>A Plugin, File by File</h2><p class="lede">What a plugin looks like on disk, and what each file is for.</p></header>
|
||||
<p class="dropcap">A plugin is a directory. Its name is its <code>package.json</code> name; its code is one file per host kind it runs on, a
|
||||
<strong>facet</strong>; everything else is ordinary TypeScript the facets import. This is p2's layout today (<code>apps/p2/plugins/*</code>),
|
||||
plus the <code>package.json</code> that loader D needs (p2's <code>plugin-loading.md</code> §8; p2's built-in plugins have none yet and are
|
||||
named by their directory).</p>
|
||||
<pre class="plain"><code>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)</code></pre>
|
||||
<pre><code>{
|
||||
<span class="tk-s">"name"</span>: <span class="tk-s">"@pi/notes"</span>,
|
||||
<span class="tk-s">"version"</span>: <span class="tk-s">"1.4.0"</span>,
|
||||
<span class="tk-s">"type"</span>: <span class="tk-s">"module"</span>,
|
||||
<span class="tk-s">"peerDependencies"</span>: {
|
||||
<span class="tk-s">"@earendil-works/chord"</span>: <span class="tk-s">"^2"</span>,
|
||||
<span class="tk-s">"@earendil-works/pi-durable"</span>: <span class="tk-s">"^1"</span>,
|
||||
<span class="tk-s">"react"</span>: <span class="tk-s">"^19"</span>
|
||||
},
|
||||
<span class="tk-s">"dependencies"</span>: {
|
||||
<span class="tk-s">"marked"</span>: <span class="tk-s">"16.4.1"</span>
|
||||
}
|
||||
}</code></pre>
|
||||
<ul>
|
||||
<li><strong><code>name</code></strong> identifies the plugin everywhere: its facets, its registry contributions, diagnostics, reloads.</li>
|
||||
<li><strong><code>peerDependencies</code></strong> are <strong>host packages</strong>: the running app supplies them, one instance per process (one Chord,
|
||||
one React; <code>instanceof ChordError</code> and React hooks only work that way). The bundler never copies them.</li>
|
||||
<li><strong><code>dependencies</code></strong> are the plugin's own npm packages (<code>marked</code>): installed into the plugin's <code>node_modules</code> with
|
||||
<code>--ignore-scripts</code> and bundled into every facet that imports them.</li>
|
||||
<li><strong>No <code>tsconfig.json</code> is needed.</strong> 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 <code>tsconfig.json</code> for its editor and for
|
||||
<code>tsc --noEmit</code> (the SDK ships one to extend); the loader ignores it.</li>
|
||||
</ul>
|
||||
<p>A facet only declares (chapter III of the model): it serves object types, serves or contributes to registries, and
|
||||
returns nothing.</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/contract.ts</span>
|
||||
<span class="tk-k">import</span> { defineRegistry, local, remote, str, obj, arr, method } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Session</span> } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>; <span class="tk-c">// the app's host kinds, a host package</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteStore</span> = <span class="tk-f">local</span>(<span class="tk-s">"notes.store"</span>, { version: <span class="tk-n">1</span>, params: {}, members: { <span class="tk-c">/* … */</span> } });
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||||
version: <span class="tk-n">2</span>,
|
||||
params: { session: <span class="tk-f">str</span>() },
|
||||
hostAddress: ({ session }) => <span class="tk-t">Session</span>({ id: session }),
|
||||
variants: { editor: { state: { list: <span class="tk-f">arr</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), text: <span class="tk-f">str</span>() })) }, methods: { add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>() })) } } },
|
||||
});
|
||||
|
||||
<span class="tk-c">// plugins/notes/worker.ts</span>
|
||||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>; <span class="tk-c">// bound to the app's principal type</span>
|
||||
<span class="tk-k">import</span> { <span class="tk-t">NoteStore</span>, <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;
|
||||
<span class="tk-k">import</span> { serveStore } <span class="tk-k">from</span> <span class="tk-s">"./store.ts"</span>;
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host) => {
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, serveStore);
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ session }, principal, self) => { <span class="tk-c">/* … */</span> });
|
||||
});
|
||||
|
||||
<span class="tk-c">// plugins/notes/web.tsx</span>
|
||||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">NoteList</span> } <span class="tk-k">from</span> <span class="tk-s">"./NoteList.tsx"</span>; <span class="tk-c">// a React component, bundled</span>
|
||||
<span class="tk-k">import</span> <span class="tk-s">"./notes.css"</span>; <span class="tk-c">// bundled into the facet's stylesheet</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { notes: <span class="tk-t">NoteList</span> }); <span class="tk-c">// the app's UI registry</span>
|
||||
});</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="loading"><header class="opener"><div class="numeral">II</div><h2>Building and Loading</h2><p class="lede">How the bundler knows what to pull in, and how the code gets into a running process.</p></header>
|
||||
<p class="dropcap">Each facet is built on its own, as one script. The bundler starts at the facet file and follows its imports:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>An import of</th>
|
||||
<th>Becomes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>the plugin's own files (<code>./store.ts</code>, <code>./NoteList.tsx</code>)</td>
|
||||
<td>inlined into the facet's script</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>another plugin's contract (<code>../sessions/contract.ts</code>, or <code>@pi/sessions/contract</code> for an installed package)</td>
|
||||
<td>inlined too: a copy, so a plugin carries the contract version it was built against (<code>accepts</code> needs that); only contracts and side-effect-free modules may be imported across plugins</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>an npm package in <code>dependencies</code> (<code>marked</code>)</td>
|
||||
<td>inlined, resolved from the plugin's own <code>node_modules</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>a host package (<code>@earendil-works/chord/next</code>, <code>react</code>, <code>@app/core</code>, …)</td>
|
||||
<td><strong>external</strong>: a parameter of the script, the host's instance at load time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>a Node builtin (<code>node:fs</code>)</td>
|
||||
<td>external, through the <code>require</code> the loader passes (server and worker facets only; a web facet that imports one fails to build)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CSS (<code>import "./notes.css"</code>)</td>
|
||||
<td>collected into one stylesheet per web facet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>an asset by URL (<code>import iconUrl from "./assets/icon.svg?url"</code>)</td>
|
||||
<td>a content-hashed file served by the app; the import is its URL</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>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.</p>
|
||||
<pre><code><span class="tk-c">// what the bundler emits for plugins/notes/worker.ts (simplified)</span>
|
||||
(<span class="tk-k">function</span> (require, __files) {
|
||||
<span class="tk-k">const</span> { defineFacet } = <span class="tk-f">require</span>(<span class="tk-s">"@app/core"</span>); <span class="tk-c">// the host's instance</span>
|
||||
<span class="tk-k">const</span> { local, remote, str } = <span class="tk-f">require</span>(<span class="tk-s">"@earendil-works/chord/next"</span>);
|
||||
<span class="tk-k">const</span> { marked } = <span class="tk-c">/* marked's code, inlined */</span>;
|
||||
<span class="tk-c">/* contract.ts, store.ts, inlined */</span>
|
||||
<span class="tk-k">return</span> <span class="tk-f">defineFacet</span>((host) => { <span class="tk-c">/* … */</span> });
|
||||
})</code></pre>
|
||||
<pre><code><span class="tk-c">// the loader (proposed @earendil-works/chord/next/loader), Node side</span>
|
||||
<span class="tk-k">import</span> { buildFacet, evaluateFacet } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>;
|
||||
|
||||
<span class="tk-k">const</span> HOST_PACKAGES = { <span class="tk-c">// the app's base bundle exports these</span>
|
||||
<span class="tk-s">"@earendil-works/chord/next"</span>: chordNext, <span class="tk-s">"@earendil-works/pi-durable"</span>: durable,
|
||||
<span class="tk-s">"@app/core"</span>: core, react, <span class="tk-s">"react/jsx-runtime"</span>: jsxRuntime,
|
||||
};
|
||||
<span class="tk-k">const</span> script = <span class="tk-k">await</span> <span class="tk-f">buildFacet</span>({ plugin: <span class="tk-s">"/data/plugins/notes"</span>, facet: <span class="tk-s">"worker.ts"</span>, hostPackages: <span class="tk-t">Object</span>.<span class="tk-f">keys</span>(HOST_PACKAGES), platform: <span class="tk-s">"node"</span> });
|
||||
<span class="tk-k">const</span> facet = <span class="tk-f">evaluateFacet</span>(script, { hostPackages: HOST_PACKAGES, require: <span class="tk-f">createRequire</span>(<span class="tk-k">import</span>.meta.url), dir: <span class="tk-s">"/data/plugins/notes"</span> });
|
||||
<span class="tk-k">await</span> host.<span class="tk-f">reload</span>([{ name: <span class="tk-s">"@pi/notes"</span>, facet }], ctx); <span class="tk-c">// or host.load the first time</span></code></pre>
|
||||
<ul>
|
||||
<li><code>buildFacet</code> is esbuild with: the facet as entry, the host packages and <code>node:*</code> external, <code>format: "cjs"</code> wrapped
|
||||
as the function above, minified, an <code>onResolve</code> that refuses anything outside the plugin, its <code>node_modules</code>, 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).</li>
|
||||
<li><code>evaluateFacet</code> runs the script with <code>vm.Script</code> and a stable filename per (facet, hash) on Node, or as a classic
|
||||
<code><script></code> in the browser (the page fetches the script and its stylesheet by hash, which caches forever).</li>
|
||||
<li><strong>Imports a plugin cannot use:</strong> dynamic <code>import()</code> (resolved at build, or not at all), top-level <code>await</code> (a facet is a
|
||||
synchronous declaration), <code>require</code> of anything but builtins.</li>
|
||||
</ul>
|
||||
<div class="note review"><b class="tag">Open 5: where the loader lives</b><p>(a) Chord ships <code>chord/next/loader</code> (discovery, <code>buildFacet</code>, <code>evaluateFacet</code>, <code>pluginFile</code>), an app chooses its host
|
||||
packages; (b) Chord knows only <code>host.reload(facets)</code> and the app bundles and evaluates. Recommended: (a); the kernel
|
||||
stays free of it, and Durable Objects (no <code>eval</code>) compose statically as before.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="assets"><header class="opener"><div class="numeral">III</div><h2>Assets and Files</h2><p class="lede">Files a plugin ships: bundled into the browser, read at runtime, or offered for download.</p></header>
|
||||
<p class="dropcap">A plugin has files that are not code. They fall into three cases.</p>
|
||||
<p><strong>1. Imported by a web facet.</strong> CSS, images, wasm. The bundler emits them as content-hashed files next to the facet's
|
||||
script, and the import evaluates to their URL. The app serves the bundle's files under one path; p2 does exactly this
|
||||
today for the terminal's wasm (<code>plugins/terminals/Terminal.tsx</code>: <code>import ghosttyWasm from "../../vendor/ghostty/ghostty-vt.wasm?url"</code>, served under <code>/__p2_web/</code>).</p>
|
||||
<pre><code><span class="tk-k">import</span> iconUrl <span class="tk-k">from</span> <span class="tk-s">"./assets/icon.svg?url"</span>;
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteIcon</span> = () => <img src={iconUrl} alt=<span class="tk-s">""</span> />;</code></pre>
|
||||
<p><strong>2. Read at runtime by a server or worker facet.</strong> A template, a wasm module for a native, a script a native runs. A
|
||||
bundled script has no meaningful <code>import.meta.url</code> (today's <code>plugins/terminals/main.ts</code> reads its wasm with
|
||||
<code>new URL("../../vendor/…", import.meta.url)</code>). The loader passes the plugin's directory, and a helper resolves a path
|
||||
inside it, refusing <code>..</code> out of it:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/worker.ts</span>
|
||||
<span class="tk-k">import</span> { pluginFile } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>; <span class="tk-c">// reads the directory the loader passed</span>
|
||||
<span class="tk-k">const</span> template = <span class="tk-k">await</span> <span class="tk-f">readFile</span>(<span class="tk-f">pluginFile</span>(<span class="tk-s">"assets/template.md"</span>), <span class="tk-s">"utf8"</span>);</code></pre>
|
||||
<p><strong>3. Offered for download over HTTP.</strong> A plugin contributes a route to the app's HTTP registry; the handler opens the
|
||||
file through the same helper and streams it, with the plugin's own authorization:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/server.ts</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
||||
routes: {
|
||||
<span class="tk-s">"GET /notes/template.md"</span>: <span class="tk-k">async</span> (request, ctx) => {
|
||||
<span class="tk-k">if</span> (ctx.principal.kind !== <span class="tk-s">"owner"</span>) <span class="tk-k">return</span> { kind: <span class="tk-s">"error"</span>, status: <span class="tk-n">403</span> };
|
||||
<span class="tk-k">const</span> path = <span class="tk-f">pluginFile</span>(<span class="tk-s">"assets/template.md"</span>);
|
||||
<span class="tk-k">const</span> { size } = <span class="tk-k">await</span> <span class="tk-f">stat</span>(path);
|
||||
<span class="tk-k">return</span> { kind: <span class="tk-s">"bytes"</span>, name: <span class="tk-s">"template.md"</span>, mime: <span class="tk-s">"text/markdown"</span>, size, read: (offset, length) => <span class="tk-f">readRange</span>(path, offset, length) };
|
||||
},
|
||||
},
|
||||
});
|
||||
});</code></pre>
|
||||
<p>So yes: a plugin can offer its files for download, because the loader tells it where it lives. Each facet sees only its
|
||||
own plugin's directory; nothing gives it another plugin's files.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="http"><header class="opener"><div class="numeral">IV</div><h2>HTTP</h2><p class="lede">What Chord provides, what p2 has, and what p3 needs.</p></header>
|
||||
<p class="dropcap"><strong>Chord v2 has no HTTP server today.</strong> Design §23 designs one (<code>@earendil-works/chord/next/http</code>) and defers it until
|
||||
"the kernel fixes and a first real proof" are done. p2's HTTP is its own, about 30 lines of helpers plus the server host:</p>
|
||||
<pre><code><span class="tk-c">// apps/p2/src/runtime/server.ts (p2 today, Chord v1)</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Routes</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">RouteHandler</span>>(<span class="tk-s">"p2.routes"</span>, { conflict: <span class="tk-s">"error"</span> }); <span class="tk-c">// "METHOD /path" → handler</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Upgrades</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">UpgradeHandler</span>>(<span class="tk-s">"p2.upgrades"</span>, { conflict: <span class="tk-s">"error"</span> }); <span class="tk-c">// WebSocket upgrades</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">RequestResolvers</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">RequestResolver</span>>(<span class="tk-s">"p2.request-resolvers"</span>, { conflict: <span class="tk-s">"error"</span> });
|
||||
<span class="tk-k">type</span> <span class="tk-t">RouteHandler</span> = (request: <span class="tk-t">IncomingMessage</span>, response: <span class="tk-t">ServerResponse</span>, access: <span class="tk-t">RequestAccess</span>, ctx: <span class="tk-t">Ctx</span>) => <span class="tk-k">void</span> | <span class="tk-t">Promise</span><<span class="tk-k">void</span>>;</code></pre>
|
||||
<p>Plugins contribute handlers that write to Node's <code>ServerResponse</code> themselves; the files plugin implements ranges,
|
||||
download headers and previews by hand (<code>plugins/files/server.ts</code>).</p>
|
||||
<p>The designed component (§23) replaces raw responses with descriptions, so every plugin gets ranges (206/416), <code>HEAD</code>,
|
||||
safe download headers, streaming and abort on disconnect from one place:</p>
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Http</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">HttpContribution</span>, <span class="tk-t">Router</span>>(<span class="tk-s">"chord.http"</span>, { version: <span class="tk-n">1</span> });
|
||||
<span class="tk-k">type</span> <span class="tk-t">HttpContribution</span> = { routes?: <span class="tk-t">Record</span><string, <span class="tk-t">Route</span>>; wraps?: <span class="tk-t">Wrap</span>[] };
|
||||
<span class="tk-k">type</span> <span class="tk-t">Route</span> = (req: <span class="tk-t">HttpRequest</span>, ctx: <span class="tk-t">Ctx</span>) => <span class="tk-t">Promise</span><<span class="tk-t">HttpResponse</span>>; <span class="tk-c">// ctx.principal: the request's principal</span>
|
||||
<span class="tk-k">type</span> <span class="tk-t">HttpResponse</span> =
|
||||
| { kind: <span class="tk-s">"json"</span>; value: <span class="tk-t">JsonValue</span> } | { kind: <span class="tk-s">"page"</span>; title: string; body: string }
|
||||
| { kind: <span class="tk-s">"bytes"</span>; name: string; mime: string; size: number; <span class="tk-f">read</span>(offset: number, length: number): <span class="tk-t">Promise</span><<span class="tk-t">Uint8Array</span>> }
|
||||
| { kind: <span class="tk-s">"redirect"</span>; to: string } | { kind: <span class="tk-s">"error"</span>; status: number };</code></pre>
|
||||
<ul>
|
||||
<li>Routes compose by <code>keyedEntries</code>: a duplicate pattern is a reported conflict, the first by plugin name wins.</li>
|
||||
<li>Wraps (middleware) run in phases the owner defines (<code>observe</code>, <code>headers</code>, <code>transform</code>), by default only around their
|
||||
own plugin's routes.</li>
|
||||
<li>Request resolvers (who sent a request: cookie, loopback, relay) are hooks; WebSocket byte channels for terminals are
|
||||
a separate component.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="perf"><header class="opener"><div class="numeral">V</div><h2>Performance</h2><p class="lede">Measured on slayer, Node 24, esbuild from the repo, p2's plugins as they are.</p></header>
|
||||
<p class="dropcap">Built with the loader D settings above (each facet alone, host packages and <code>apps/p2/src</code> external, minified), on
|
||||
p2's real plugins:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Measurement</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>all 25 worker facets, one script each, cold</td>
|
||||
<td>237 ms, 294 KB in total, largest <code>models/worker.ts</code> 35 KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>all 20 server facets, cold</td>
|
||||
<td>167 ms, 236 KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>all 17 web facets, cold</td>
|
||||
<td>144 ms, 912 KB, of which 688 KB is the terminal's wasm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>one facet rebuilt after an edit (esbuild context, warm)</td>
|
||||
<td>11 ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>compile and evaluate all 25 worker scripts (<code>vm.Script</code>)</td>
|
||||
<td>23 ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>worker spawn to ready, p2 today (TypeScript through the source resolver, <code>nodeLoader</code>)</td>
|
||||
<td>549 ms (p2's measurement, <code>plugin-loading.md</code> §14.1)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>the same with one bundle per worker plugin set</td>
|
||||
<td>218 ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>base bundle plus one script per plugin through ESM (model B)</td>
|
||||
<td>254 ms; D is expected near it</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>The plugins are small; the time goes into the host packages and the runtime (85% of a worker's load time, p2 §14.1),
|
||||
which the base bundle loads once per process. A reload of one plugin costs one rebuild (about 11 ms warm) and one
|
||||
evaluation (about 1 ms per facet), then Chord's reload. The memory caveat is V8's compilation cache: every distinct
|
||||
compiled script stays cached, so scripts get a stable name per (facet, hash) and hosts that reload in place run with
|
||||
the cache off (+0.1 MiB instead of +5.6 MiB per 100 reloads, p2's measurement).</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="kit"><header class="opener"><div class="numeral">VI</div><h2>A Mini p2 from Chord</h2><p class="lede">The owner: building a small p2 with a web and a TUI surface and plugins should be trivial from Chord; optional components should come out of the box.</p></header>
|
||||
<p class="dropcap">p2 has about 10,700 lines of host code in <code>apps/p2/src/hosts</code> and its runtime: the server's HTTP and WebSocket
|
||||
entry, the worker supervisor, plugin discovery, the web and TUI bundles, the web shell, the TUI sandbox and launcher,
|
||||
the reload control. Almost none of it is p2-specific. The proposal: Chord ships it as optional components, each a
|
||||
subpath an app imports only if it wants it, none of them in Chord's kernel.</p>
|
||||
<h3 id="what-it-would-look-like">What it would look like</h3>
|
||||
<pre><code><span class="tk-c">// server.ts: a whole app with plugins, a web and a TUI surface</span>
|
||||
<span class="tk-k">import</span> { createHost } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">import</span> { discoverPlugins, pluginLoader } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>;
|
||||
<span class="tk-k">import</span> { httpServer } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/http"</span>;
|
||||
<span class="tk-k">import</span> { workerRoute } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/workers"</span>;
|
||||
<span class="tk-k">import</span> { webSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/web"</span>;
|
||||
<span class="tk-k">import</span> { tuiSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/tui"</span>;
|
||||
<span class="tk-k">import</span> { devReload } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/dev"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Server</span>, <span class="tk-t">Session</span>, core } <span class="tk-k">from</span> <span class="tk-s">"./core.ts"</span>; <span class="tk-c">// the app's base code: host kinds, principals, core facet</span>
|
||||
|
||||
<span class="tk-k">const</span> plugins = <span class="tk-k">await</span> <span class="tk-f">discoverPlugins</span>([<span class="tk-s">"./plugins"</span>, <span class="tk-f">join</span>(dataDir, <span class="tk-s">"plugins"</span>)]);
|
||||
<span class="tk-k">const</span> loader = <span class="tk-f">pluginLoader</span>({ hostPackages: core.hostPackages });
|
||||
|
||||
<span class="tk-k">const</span> server = <span class="tk-f">createHost</span><P>({ address: <span class="tk-t">Server</span>(), principal: { kind: <span class="tk-s">"server"</span> }, onError });
|
||||
server.<span class="tk-f">route</span>(<span class="tk-t">Session</span>, <span class="tk-f">workerRoute</span>({ entry: <span class="tk-s">"./worker.ts"</span>, idleMs: <span class="tk-n">300_000</span>, authorize: core.authorize }), ctx);
|
||||
<span class="tk-k">await</span> server.<span class="tk-f">load</span>([core.serverFacet, ...(<span class="tk-k">await</span> loader.<span class="tk-f">facets</span>(plugins, <span class="tk-s">"server"</span>))], ctx);
|
||||
|
||||
<span class="tk-k">const</span> http = <span class="tk-f">httpServer</span>(server, { port: <span class="tk-n">4321</span>, authenticate: core.authenticate }); <span class="tk-c">// routes from the Http registry</span>
|
||||
http.<span class="tk-f">surface</span>(<span class="tk-f">webSurface</span>({ loader, plugins, shell: core.webShell })); <span class="tk-c">// /: the page, its bundle, its socket</span>
|
||||
http.<span class="tk-f">surface</span>(<span class="tk-f">tuiSurface</span>({ loader, plugins })); <span class="tk-c">// the TUI launcher's bundle and socket</span>
|
||||
<span class="tk-f">devReload</span>({ server, loader, plugins, watch: <span class="tk-k">true</span> }); <span class="tk-c">// a changed plugin reloads everywhere</span></code></pre>
|
||||
<pre><code><span class="tk-c">// worker.ts</span>
|
||||
<span class="tk-k">import</span> { workerMain } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/workers"</span>;
|
||||
<span class="tk-k">await</span> <span class="tk-f">workerMain</span>(<span class="tk-k">async</span> ({ address, init, parent }) => {
|
||||
<span class="tk-k">const</span> worker = <span class="tk-f">createHost</span><P>({ address, principal: { kind: <span class="tk-s">"worker"</span>, session: init.id }, onError });
|
||||
worker.<span class="tk-f">connect</span>(parent, { principal: { kind: <span class="tk-s">"server"</span> }, trusted: <span class="tk-k">true</span> }, ctx);
|
||||
<span class="tk-k">await</span> worker.<span class="tk-f">load</span>([core.<span class="tk-f">workerFacet</span>(init), ...(<span class="tk-k">await</span> loader.<span class="tk-f">facets</span>(init.plugins, <span class="tk-s">"worker"</span>))], ctx);
|
||||
<span class="tk-k">return</span> worker;
|
||||
});</code></pre>
|
||||
<pre><code><span class="tk-c">// core.ts, the web shell: the app decides the layout; plugins fill registries</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Panels</span> = defineRegistry<<span class="tk-t">Record</span><string, <span class="tk-t">ComponentType</span>>, <span class="tk-t">Panel</span>[]>(<span class="tk-s">"app.panels"</span>, { version: <span class="tk-n">1</span> });
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> webShell = ({ host }: <span class="tk-t">ShellProps</span>) => {
|
||||
<span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(host, <span class="tk-t">Panels</span>); <span class="tk-c">// from chord/next/web: a hook over a registry value</span>
|
||||
<span class="tk-k">return</span> <<span class="tk-t">Tabs</span>>{panels.<span class="tk-f">map</span>((p) => <p.component key={p.name} />)}</<span class="tk-t">Tabs</span>>;
|
||||
};</code></pre>
|
||||
<p>And a plugin is what chapter I shows: a directory, a <code>package.json</code>, facets that serve types and contribute to the
|
||||
app's registries (<code>Http</code>, <code>Panels</code>, a TUI command list).</p>
|
||||
<h3 id="the-components">The components</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subpath</th>
|
||||
<th>What it does</th>
|
||||
<th>Replaces in p2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>chord/next/loader</code></td>
|
||||
<td><code>discoverPlugins</code> (directory listing, <code>package.json</code> names, no parsing), <code>buildFacet</code> (esbuild, host packages external), <code>evaluateFacet</code> (Node <code>vm</code>, browser <code><script></code>), <code>pluginFile</code>, npm/git install with <code>--ignore-scripts</code></td>
|
||||
<td><code>src/hosts/plugins.ts</code>, <code>bundles.ts</code>, the source resolver, <code>nodeLoader</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/http</code></td>
|
||||
<td>the Node HTTP server, the <code>Http</code> registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, <code>HEAD</code>), WebSocket entry calling <code>host.accept</code></td>
|
||||
<td><code>src/hosts/server.ts</code> (HTTP part), <code>runtime/http.ts</code>, <code>Routes</code>, <code>Upgrades</code>, <code>RequestResolvers</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/workers</code></td>
|
||||
<td><code>workerRoute</code> (fork a worker per address, IPC link, start message, idle stop from <code>host.activity</code>, crash restart with backoff), <code>workerMain</code> (the child's side)</td>
|
||||
<td><code>SessionHosts</code> in <code>runtime/server.ts</code>, <code>hosts/worker.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/web</code></td>
|
||||
<td>the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: <code>useObject</code> over <code>host.hold</code>, <code>useRegistry</code>, <code>useWatch</code>), serving the bundle by hash, reload on a new manifest</td>
|
||||
<td><code>hosts/web/*</code>, <code>runtime/web.tsx</code>, <code>src/web/reconnect.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/tui</code></td>
|
||||
<td>the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries</td>
|
||||
<td><code>hosts/tui-*.ts</code>, <code>runtime/tui.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/dev</code></td>
|
||||
<td>the source watcher marking plugins dirty, the reload control (<code>reload(plugins)</code> on every host in order: server, workers, then a manifest bump for pages and the TUI)</td>
|
||||
<td><code>performReload</code> in <code>hosts/server.ts</code>, the dev protocol</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>Each is ordinary code on top of Chord's public API: nothing in the kernel depends on it, a Durable Object deployment
|
||||
uses none of it, and an app can replace any one (its own HTTP server, its own shell). React is a host package of the web
|
||||
component, pi-tui of the TUI component; an app without them never imports them.</p>
|
||||
<h3 id="what-stays-the-app-39-s">What stays the app's</h3>
|
||||
<p>The principal type and authentication; the host kinds (<code>Server</code>, <code>Session</code>) and what a worker's start message holds;
|
||||
the core facet and core objects (catalog, harness); the web shell's layout and the TUI's look; which registries plugins
|
||||
contribute to beyond <code>Http</code>. That is roughly what p3's base code should be: a few hundred lines, not ten thousand.</p>
|
||||
<div class="note review"><b class="tag">Open 6: the app kit</b><p>(a) Chord ships these six components as optional subpaths of <code>@earendil-works/chord</code>, built in this order: <code>loader</code>
|
||||
and <code>dev</code> with 7b (reload needs them), <code>http</code> and <code>workers</code> next (p3 needs them first), <code>web</code> and <code>tui</code> after (lifted
|
||||
from p2's code, which works today). (b) Chord ships only <code>loader</code>; the rest is p3's base code. Recommended: (a), as the
|
||||
owner asked; each component gets its own slice spec and review, after 7b and before slice 9 or alongside p3's base.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="host"><header class="opener"><div class="numeral">VII</div><h2>One Host Reloads</h2><p class="lede">Four steps, all or nothing up to the commit.</p></header>
|
||||
<p class="dropcap">Design §21 fixes the shape. <strong>Decided.</strong></p>
|
||||
<pre class="plain"><code>1. load the new bundles; an import or syntax error rejects
|
||||
2. staging facet setups and every live object's new factory run with a recording `self`
|
||||
|
|
@ -184,7 +548,7 @@ barrier) and its rollback rule does not apply to the first load (a throwing firs
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="fates"><header class="opener"><div class="numeral">II</div><h2>Three Fates</h2><p class="lede">What happens to each live object at the commit.</p></header>
|
||||
<section class="chapter" id="fates"><header class="opener"><div class="numeral">VIII</div><h2>Three Fates</h2><p class="lede">What happens to each live object at the commit.</p></header>
|
||||
<p class="dropcap">The commit compares each live object's old and new declarations. <strong>Decided</strong> (design §21, owner 2026-10-26, the
|
||||
dependency model).</p>
|
||||
<table>
|
||||
|
|
@ -253,7 +617,7 @@ Recommended: (a); writables have no names, and position matching would be the fi
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="consumers"><header class="opener"><div class="numeral">III</div><h2>Consumers Across a Reload</h2><p class="lede">Holds, versions and old calls; 7a decided that nothing is ended for a code change.</p></header>
|
||||
<section class="chapter" id="consumers"><header class="opener"><div class="numeral">IX</div><h2>Consumers Across a Reload</h2><p class="lede">Holds, versions and old calls; 7a decided that nothing is ended for a code change.</p></header>
|
||||
<p class="dropcap">A reference on another host points at an object whose code may change under it. <strong>Decided</strong> (design §21, §22 and the
|
||||
dependency model):</p>
|
||||
<table>
|
||||
|
|
@ -312,7 +676,7 @@ old.</li>
|
|||
the provider's table. Small and <strong>decided</strong> in shape (Q10, <code>bindProvider</code>); the option's place is <code>host.serve(Type, factory, { accepts, linger })</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="jobs"><header class="opener"><div class="numeral">IV</div><h2>Jobs Across a Reload</h2><p class="lede">Runs belong to the object; their versions are mapped forward.</p></header>
|
||||
<section class="chapter" id="jobs"><header class="opener"><div class="numeral">X</div><h2>Jobs Across a Reload</h2><p class="lede">Runs belong to the object; their versions are mapped forward.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (design §15, owner 2026-10-24 and 2026-10-26):</p>
|
||||
<pre><code><span class="tk-k">const</span> <span class="tk-t">ExportJob</span> = <span class="tk-f">defineJob</span>(<span class="tk-s">"notes.export"</span>, {
|
||||
version: <span class="tk-n">2</span>,
|
||||
|
|
@ -333,7 +697,7 @@ that brings their version.</li>
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="registries"><header class="opener"><div class="numeral">V</div><h2>Registries Across a Reload</h2><p class="lede">A provider and its contributions are swapped in one batch.</p></header>
|
||||
<section class="chapter" id="registries"><header class="opener"><div class="numeral">XI</div><h2>Registries Across a Reload</h2><p class="lede">A provider and its contributions are swapped in one batch.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (slice 8 §6, design §17): the commit replaces a plugin's contributions and a registry's provider in one
|
||||
batch; staging composes nothing (D1). The record keeps the provider, facet contributions by plugin and object
|
||||
contributions by object apart, so a reload of one plugin replaces exactly its parts (today <code>RegistryRecord.serve</code>
|
||||
|
|
@ -341,7 +705,7 @@ refuses a second serve). Older readers across a reload of the provider follow it
|
|||
waits (7a) when the new provider no longer serves its version. <code>retryOn</code> watches are re-declared by the new factory.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="rollout"><header class="opener"><div class="numeral">VI</div><h2>Across Hosts</h2><p class="lede">The same reload on every host, in an order that cannot deadlock.</p></header>
|
||||
<section class="chapter" id="rollout"><header class="opener"><div class="numeral">XII</div><h2>Across Hosts</h2><p class="lede">The same reload on every host, in an order that cannot deadlock.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (design §21 and the dependency model): a revision is the complete code set; the server installs first,
|
||||
then workers, then clients; installs are serialized per host; a host that starts or reconnects installs the current
|
||||
revision first; a revision carries a code-set digest so an unchanged server restart reinstalls nothing; an install
|
||||
|
|
@ -368,41 +732,6 @@ context like everything else, and the app passes its deadline; a host option wou
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="loader"><header class="opener"><div class="numeral">VII</div><h2>Loader D</h2><p class="lede">How a plugin's new code gets into a running process.</p></header>
|
||||
<p class="dropcap">p2 designed it (<code>apps/p2/docs/plugin-loading.md</code> §9, §14); Chord has not built it. <strong>Decided in p2's design:</strong></p>
|
||||
<ul>
|
||||
<li><p><strong>One base bundle per surface</strong> holds every host package once: Chord, Durable, pi-ai, pi-tui, TypeBox, React and
|
||||
<code>react-dom</code>, plus the app's runtime. A change to it restarts the hosts.</p>
|
||||
</li>
|
||||
<li><p><strong>One script per facet</strong>, an esbuild bundle wrapped as a function of its host packages, which are external and
|
||||
passed in, so there is one Chord and one React per process:</p>
|
||||
<pre><code><span class="tk-c">// notes/worker.facet.js, built</span>
|
||||
(require, chord, durable, react) => {
|
||||
<span class="tk-k">const</span> { defineFacet, local, str } = chord; <span class="tk-c">// the host's instance, not a copy</span>
|
||||
<span class="tk-c">/* the plugin's own files, contracts included (bundled: their versions are the plugin's) */</span>
|
||||
<span class="tk-k">return</span> <span class="tk-f">defineFacet</span>((host, self) => { … });
|
||||
}</code></pre>
|
||||
</li>
|
||||
<li><p><strong>Evaluation:</strong> <code>vm.Script.runInThisContext</code> on Node (server, workers, TUI worker), a classic <code><script></code> plus one CSS
|
||||
file in the browser. No module map: an old generation is closures the GC reclaims once nothing holds them.</p>
|
||||
</li>
|
||||
<li><p><strong>Stable script names</strong> per (facet, content digest), with V8's compilation cache off on hosts that reload in place
|
||||
(measured: every distinct compile otherwise stays in memory, +5.6 MiB per 100 compiles of 72 KB).</p>
|
||||
</li>
|
||||
<li><p><strong>Contracts are never host packages:</strong> each facet bundles its own contract version, or <code>accepts</code> would never apply.</p>
|
||||
</li>
|
||||
<li><p><strong>Blockers</strong> to solve: <code>instanceof</code> across plugins, plugin files shared between plugins (copied with their module
|
||||
state), <code>import.meta.url</code>, Node builtins through the passed <code>require</code>, CSS and wasm assets.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="note review"><b class="tag">Open 5: where loader D lives</b><p>(a) Chord ships the facet-script format, its evaluation (Node <code>vm</code>, browser <code><script></code>) and an esbuild helper that
|
||||
marks host packages external; an app chooses its host packages. (b) Chord knows only <code>host.reload(facets)</code>;
|
||||
bundling and evaluation are entirely the app's (p3's base code). Recommended: (a): every Chord app gets live reload,
|
||||
and the blockers are solved once; Chord's kernel stays free of it (a separate subpath, <code>@earendil-works/chord/next/loader</code>),
|
||||
so Durable Objects, which forbid <code>eval</code>, compose statically as before.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="decisions"><header class="opener"><div class="numeral">∴</div><h2>Decisions</h2><p class="lede">What is settled for the spec, and what the owner decides first.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (spec material): the four steps and rollback per host; the three fates and the parking of a swap with a
|
||||
missing dependency; old calls on old handles (R15); derives matched by member name; the routine handover; new instance
|
||||
|
|
@ -410,7 +739,8 @@ ids on restart; broken objects retried on reload; the handover of a parked attem
|
|||
re-subscribed or freshly bound; unbound <code>version</code> and <code>not_served</code> instead of <code>gone</code>; old-version calls and adapted
|
||||
bindings as diffs; <code>accepts</code> in <code>host.serve</code>; jobs' version mapping, <code>blocked { version }</code> and retries on reload, runs
|
||||
aborted on a native restart; registries swapped in one batch; rollout order, serialized installs, the digest,
|
||||
<code>installing</code>, installs never waiting for activations; loader D's design.</p>
|
||||
<code>installing</code>, installs never waiting for activations; loader D's design: one script per facet, host packages passed in,
|
||||
contracts bundled, no module map, stable script names, plugins' npm dependencies installed with <code>--ignore-scripts</code>.</p>
|
||||
<p><strong>Open</strong> for the owner:</p>
|
||||
<table>
|
||||
<thead>
|
||||
|
|
@ -445,6 +775,11 @@ aborted on a native restart; registries swapped in one batch; rollout order, ser
|
|||
<td>where loader D lives: Chord (<code>chord/next/loader</code>), or the app</td>
|
||||
<td>Chord</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>the app kit: Chord ships loader, HTTP, workers, web, TUI and dev reload as optional subpaths, or only the loader</td>
|
||||
<td>Chord ships them, in that order</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue