From 24d3993b20251da6e214c0f9eae76440731436ec Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 3 Nov 2026 08:30:05 +0000 Subject: [PATCH] docs(chord): the Chord v2 tutorial, a todo app built with the app kit (example 61) --- packages/chord/docs/tutorial/book.md | 1341 ++++++++++++++++ packages/chord/docs/tutorial/index.html | 1378 +++++++++++++++++ .../chord/docs/tutorial/screens/edited.png | Bin 0 -> 14440 bytes .../chord/docs/tutorial/screens/harbor.png | Bin 0 -> 13613 bytes packages/chord/docs/tutorial/screens/page.png | Bin 0 -> 13078 bytes .../61-tutorial/later/harbor/package.json | 5 + .../61-tutorial/later/harbor/theme.ts | 21 + .../examples/61-tutorial/later/harbor/tui.ts | 6 + .../examples/61-tutorial/later/harbor/web.ts | 6 + .../chord/test/examples/61-tutorial/main.ts | 249 +++ .../61-tutorial/plugins/lists/lists.css | 9 + .../61-tutorial/plugins/lists/package.json | 5 + .../61-tutorial/plugins/lists/server.ts | 21 + .../examples/61-tutorial/plugins/lists/tui.ts | 17 + .../examples/61-tutorial/plugins/lists/web.ts | 53 + .../61-tutorial/plugins/lists/worker.test.ts | 62 + .../61-tutorial/plugins/lists/worker.ts | 96 ++ .../61-tutorial/plugins/login.server.ts | 5 + .../test/examples/61-tutorial/screenshots.ts | 95 ++ .../examples/61-tutorial/src/api/v1/index.ts | 74 + .../examples/61-tutorial/src/api/v1/server.ts | 10 + .../examples/61-tutorial/src/api/v1/tui.ts | 17 + .../examples/61-tutorial/src/api/v1/web.ts | 13 + .../examples/61-tutorial/src/principals.ts | 8 + .../test/examples/61-tutorial/src/tui/main.ts | 36 + .../test/examples/61-tutorial/src/web/main.ts | 33 + .../examples/61-tutorial/src/web/style.css | 5 + .../chord/test/examples/61-tutorial/start.ts | 76 + .../examples/61-tutorial/tutorial.test.ts | 116 ++ .../chord/test/examples/61-tutorial/worker.ts | 7 + tsconfig.json | 4 + 31 files changed, 3768 insertions(+) create mode 100644 packages/chord/docs/tutorial/book.md create mode 100644 packages/chord/docs/tutorial/index.html create mode 100644 packages/chord/docs/tutorial/screens/edited.png create mode 100644 packages/chord/docs/tutorial/screens/harbor.png create mode 100644 packages/chord/docs/tutorial/screens/page.png create mode 100644 packages/chord/test/examples/61-tutorial/later/harbor/package.json create mode 100644 packages/chord/test/examples/61-tutorial/later/harbor/theme.ts create mode 100644 packages/chord/test/examples/61-tutorial/later/harbor/tui.ts create mode 100644 packages/chord/test/examples/61-tutorial/later/harbor/web.ts create mode 100644 packages/chord/test/examples/61-tutorial/main.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/lists.css create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/package.json create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/server.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/web.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/login.server.ts create mode 100644 packages/chord/test/examples/61-tutorial/screenshots.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/api/v1/index.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/api/v1/server.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/api/v1/tui.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/api/v1/web.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/principals.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/tui/main.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/web/main.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/web/style.css create mode 100644 packages/chord/test/examples/61-tutorial/start.ts create mode 100644 packages/chord/test/examples/61-tutorial/tutorial.test.ts create mode 100644 packages/chord/test/examples/61-tutorial/worker.ts diff --git a/packages/chord/docs/tutorial/book.md b/packages/chord/docs/tutorial/book.md new file mode 100644 index 000000000..65556ca0c --- /dev/null +++ b/packages/chord/docs/tutorial/book.md @@ -0,0 +1,1341 @@ +--- +title: Together: build a shared todo app with Chord +cover: Build It / Together +kicker: A tutorial · Chord v2 and its app kit · 2026-11-03 +subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme and an edit applied while they are connected +stats: + - 9 | steps, one runnable example + - 4 | host kinds one plugin runs on + - 1 | worker process per list + - 0 | restarts for a new theme or an edit +note: | + The app is `packages/chord/test/examples/61-tutorial/`, runnable with the line in its `main.ts`. Every code block + below is quoted from its files, and every console output from what its steps print: + `test/examples/61-tutorial/tutorial.test.ts` fails when a quote or an output drifts. The design is + `packages/chord/docs/design.md`; where this tutorial says "the rule", design.md has the full statement. The last + chapter is about p2, whose code lives outside this repository. +colophon: Together · first edition · 2026-11-03 · Chord v2 slices 1-13, the app kit, example 61 +--- + +# Before we begin {.preface} + +Mara keeps the household's shopping list on her laptop. She wants it in a browser tab and in a terminal, live in both. +Ada, her flatmate, should be able to add coffee from her phone without an account: pick a name and go. And Mara wants +to change the app while it runs: add a dark theme in the evening, fix a small annoyance in how todos are added, without +anyone being thrown out. + +That is four kinds of program (a server, a process per list, a page, a terminal client) and three kinds of change (new +data, a new person, new code) in one small app. Wiring that by hand means a web server, a socket protocol, a way to +start and stop processes, sessions, a bundler for the page and a reload story for each. Chord does the wiring, and its +**app kit** (`@earendil-works/chord/app`) makes the decisions an app would otherwise make itself: where data lives, +how people sign in, how plugins are found and rolled out. + +This tutorial builds that app. Each chapter adds one thing, shows the code, shows what happens when it runs, and +states the rule behind it. The app is one directory, `test/examples/61-tutorial/`; run every step at once from +`packages/chord`: + +```sh +node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts +``` + +With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one, +that step says so and the rest runs. + +## Eight things to know + +:::findings +1. **An app is its API, its plugins and a start script.** `startServer` makes the data directory, the store, the + server host, HTTP with sign-in, the workers and the plugin roots; `startWorker` does a worker's half. +2. **A host is one participant.** The server, each list's worker, each browser tab and each terminal are hosts, + connected by links. Tabs and terminals are clients: they connect and ask, nobody asks them. +3. **An object is reached by its contract.** A contract names an object type, its params and its members; a `remote` + contract also says which host serves it. Any host asks for `Todos { list: "groceries" }`, and Chord routes the + request to that list's worker. +4. **Who calls is stamped, never sent.** Every call carries a principal the link stamped from the sign-in. A factory + picks a variant per principal; a todo records who added it from the principal, not from its arguments. +5. **A plugin has one file per host kind.** `server.ts`, `worker.ts`, `web.ts`, `tui.ts`: each declares what that kind + of host serves and contributes. Plugins import only the app's API. +6. **Plugins meet in registries.** The page shows `Panels`, the terminal `Screens`, the kit's `Themes` restyle both; + plugins contribute, one plugin (or the entry) composes. +7. **Changes apply when you say so.** Saving a file marks its plugin dirty; `applyChanges` builds and rolls out to the + server, the workers, the pages and the terminals, in place. +8. **Four traps:** load before use; a reference acts as whoever made it; await what you start; a watch starts at the + current value. +::: + +## Words used throughout + +A **host** is one participant of the app, with an **address** if others can reach it (the server, `List({ id })`) or +without one (a **client**: a page, a terminal). A **link** connects two hosts. A **principal** is who calls, typed by +the app and stamped by links. An **object** is state and methods at one identity; its **contract** (`local` for its own +host only, `remote` for any host) names its **params** (which instance) and members. A remote contract's **variants** +are the member sets different principals get. A **factory** declares an object's parts: **documents** and **tables** +(stored data, changed in **transactions**), **derives** (values computed from others), **natives** (things opened and +closed, such as a file or a database cursor). A **reference** is what asking for an object returns. A **facet** is a +plugin's code for one host kind; a **registry** is an extension point plugins **contribute** to. A **rollout** applies +new plugin code to the hosts in order. + +# One Server {#server lede="The API, the principals and a start script: the smallest app the kit runs, and who may come in."} + +An app built with the kit has a fixed shape: its API in `src/api/v/`, its built-in plugins in `plugins/`, a start +script and a worker entry (design §23, "The app kit"). Everything else is a plugin. This chapter writes the three +pieces of base code and starts the server. + +## The principals + +A **principal** says who is calling. The app defines the type; the kit needs a variant for the server, one for its +workers, and adds the owner, `{ kind: "owner" }`, which the app's type must include: + + +```ts +// The todo app's principals: its server and list workers, the kit's owner, and the users who signed in with a name. + +/** Who someone is in the todo app. */ +export type TodoPrincipal = + | { readonly kind: "server" } + | { readonly kind: "worker"; readonly list: string } + | { readonly kind: "owner" } + | { readonly kind: "user"; readonly id: string; readonly name: string }; +``` + +The user variant is for chapter VII. A principal travels with every call and is part of objects' identities, so it +stays small: a kind and an id. A user's profile, if the app had one, would be data in an object keyed by the id. + +## The API + +Plugins never import Chord. They import the app's **API**, a module the app names with a version in its path +(`@todo/app/v1`). It re-exports the part of Chord the app supports and adds what is the app's own: + + +```ts +export { BACKGROUND_CONTEXT } from "../../../../../../src/context/index.ts"; +export { + arr, + bool, + type Ctx, + defineTable, + eq, + int, + local, + method, + num, + obj, + PluginError, + query, + str, + type Theme, + Themes, +} from "../../../../../../src/index.ts"; +export type { TodoPrincipal } from "../../principals.ts"; +``` + +and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`: + + +```ts +/** Defines a facet of the todo app: its factories get the app's principals. */ +export function defineFacet(setup: (host: FacetHost, self: FacetSelf) => void) { + return chordFacet(setup); +} +``` + +The API is a **host package**: a plugin's build leaves it out, and each process hands the plugin its own instance when +it evaluates it. The plugin sees the very `Themes` registry and `List` host kind the process has, not a copy. A +version in the path lets the app ship `v2` beside `v1` later; one facet uses one version (design §21, "The plugin +model"). + +## The start script + +`startServer` takes what the app decides and does the rest. `start.ts` names the API with this process's modules, the +facet kinds (which files a plugin may have), and the principals: + + +```ts +export const API = { + name: "@todo/app", + versions: ["v1"], + modules: { "@todo/app/v1": v1, "@todo/app/v1/server": v1server }, +}; + +/** The app's facet kinds: a plugin has one file per kind it runs on (`server.ts`, `worker.ts`, `web.ts`, `tui.ts`). */ +export const KINDS = { + server: { platform: "node" }, + worker: { platform: "node" }, + web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } }, + tui: { platform: "node" }, +} as const; +``` + + +```ts +export function startTodo(dataDir: string, root: string, context: Context): Promise> { + return startServer( + { + name: "todo", + dataDir, + root, + listen: { host: "127.0.0.1", port: 0 }, + api: API, + kinds: KINDS, + principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, + workers: [ + { + kind: v1.List, + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + start: () => ({}), + // Who may reach a list's worker: everyone signed in, and the server itself. + authorize: (principal) => principal.kind !== "worker", + }, + ], + web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page }, + tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) }, + onError: (error) => console.error("todo:", error.kind, error.error), + }, + context, + ); +} +``` + +`workers` is chapter II, `web` chapter IV, `tui` chapter V. `listen` asks for a free port on loopback, as the example +does; a real app picks a port. The worker entry is three lines: + + +```ts +// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects +// to the server and loads the plugins' worker facets. + +import { startWorker } from "../../../src/node/app/index.ts"; +import { API, KINDS } from "./start.ts"; + +startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) }); +``` + +## Running it + +Step 1 of `main.ts` starts the app, tries the page without signing in, then does what a browser does with the owner +link: + + +```ts +const app = await startTodo(dataDir, root, context); +const link = new URL(app.ownerLink()); +const url = link.origin; +console.log("1. owner link:", `http://127.0.0.1:${link.pathname}#`); +console.log(" data directory:", (await readdir(dataDir)).sort().join(", ")); +console.log(" GET / signed out:", (await fetch(url)).status); +const signIn = await fetch(`${url}/_chord/owner`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: link.hash.slice(1) }), +}); +const cookie = signIn.headers.get("set-cookie")!.split(";")[0]!; +console.log(" the owner signs in:", signIn.status, cookie.replace(/=.*/, "=…")); +console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).status); +``` + + +```text +1. owner link: http://127.0.0.1:/_chord/owner# + data directory: logs, owner-token, plugins, server, workers + GET / signed out: 401 + the owner signs in: 200 chord-session=… + GET / signed in: 200 +``` + +`startServer` made the data directory: `server/` (its store, the cookie secret, its files), `workers/` (one directory +per worker), `plugins/` (the user's plugin root, beside the app's built-in one), `logs/` and `owner-token`. The page +answered 401: HTTP **denies by default**, before any plugin code runs, unless a route says `public: true`. + +The **owner link** (`/_chord/owner#`) is how the owner gets in: a single-use token, valid ten minutes, made at +each start. It sits in the URL's fragment, which browsers never send, so it never reaches a log; the link's page posts +it and receives a session cookie. A command-line client on the same machine reads `owner-token` instead (mode 0600, +chapter III). Nothing is the owner because it comes from loopback: any local process could. + +# A List on Its Own Worker {#worker lede="A host kind, a remote contract, a worker facet with a table and a live window, and the principal that adds a todo."} + +Each list gets its own process, a **worker**, with its own store: a list that misbehaves cannot take the others down, +and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that +keeps its todos. + +## Where a list lives + +A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. A `remote` +contract says, with `hostAddress`, at which address its objects live. Here is the app's contract for a list's todos, +in the API so that every host kind, the tests and command-line code can name it: + + +```ts +/** A todo list: a worker process of its own, with its own store. */ +export const List = hostKind("list", { id: str() }); + +/** A todo: its number in the list, its text, whether it is done, who added it and when. */ +export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() }); + +/** A todo (see `Item`). */ +export type Item = Static; + +/** + * A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one + * off (or on again); the owner also clears the done ones. + */ +export const Todos = remote("todo.todos", { + version: 1, + params: { list: str() }, + hostAddress: ({ list }) => List({ id: list }), + variants: { + member: { + state: { items: arr(Item) }, + methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) }, + }, + owner: { + state: { items: arr(Item) }, + methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() }, + }, + }, +}); +``` + +`params` select the instance: `Todos { list: "groceries" }` is one object per principal. Nobody who asks names a +host: the caller computes the address from the params and sends the request there; a host that cannot serve it passes +it on to its parent or to a route (design §3, "Host kinds and addresses"). The kit's `workers` option is that route: + + +```ts + workers: [ + { + kind: v1.List, + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + start: () => ({}), + // Who may reach a list's worker: everyone signed in, and the server itself. + authorize: (principal) => principal.kind !== "worker", + }, + ], +``` + +`authorize` runs for every request routed to a list before anything starts; the first request for an address starts +its worker, and a worker idle for five minutes (the default `idleMs`) stops. **Variants** are the member sets a +contract offers: the owner's `Todos` has `clear`, a member's does not. The factory picks one per principal. + +## The worker facet + +The lists plugin is a directory with a `package.json` (its name is the plugin's name) and one file per host kind it +runs on. `worker.ts` is its **worker facet**: what every list's worker serves. It keeps the todos in a **table** of the +worker's store: + + +```ts +/** Every todo of the list, by its number. */ +const Items = defineTable("todo.items", { + version: 1, + columns: { id: int(), text: str(), done: bool(), by: str(), at: num() }, + key: ["id"], +}); + +/** The newest todos first. */ +const NEWEST = query(Items).orderBy((item) => item.id, "desc"); + +/** How many todos the live window holds. */ +const WINDOW = 50; +``` + +A table holds many records with queries and pages; it lives in an object, never in a facet. Persisted data lives only +in `local` objects with empty params (one per host and type), so its address never depends on who asked (design §9, +"Lifetime, persistence"). That object is the plugin's store, `Store`, a local contract of its own file: + + +```ts +/** The list's todos, once per worker: empty params, so it lives as long as the worker and may keep data in its store. */ +const Store = local("todo.store", { + version: 1, + params: {}, + members: { + items: arr(Item), + add: method(obj({ text: str(), by: str() })), + tick: method(obj({ id: int(), done: bool() })), + clear: method(), + }, +}); +``` + +A `local` object with empty params lives as long as its host: it starts with the worker and is never ended by a count +of references. Its factory declares its parts and returns its members: + + +```ts + host.serve(Store, (_params, self) => { + const items = self.table(Items); + // The newest todos, oldest first, and the next todo's number: read once when the store goes live, then kept here. + const window = self.writable([]); + let next = 1; + self.open( + async (ctx) => { + const { items: newest } = await items.page(NEWEST, WINDOW, ctx); + window.set([...newest].reverse()); + next = (newest[0]?.id ?? 0) + 1; + }, + () => {}, + ); + return { + items: self.derive([window], ([value]) => value), +``` + +A **factory** is synchronous and only declares; Chord activates the object afterwards. `self.table` declares the +table. A table has no watch, so the store keeps a **live window** itself: `self.writable` holds the newest 50 todos, +filled by `self.open`, which runs during activation, and exposed through `self.derive` (a writable is an in-process +value, not a state member; a derive over it is). `self.open` is meant for **natives**, things opened and closed; the +window's first read is one, and chapter VIII shows its price. + +Writes go through **transactions**: `self.tx(fn, ctx)` runs `fn` with `tx`, and what it stages is committed at once +when `fn` returns, or not at all if it throws. The window follows after the commit; the store is the table's only +writer, so it knows every change: + + +```ts + add: async ({ text, by }, ctx) => { + const item = { id: next++, text, done: false, by, at: Date.now() }; + await self.tx(async (tx) => tx.put(items, item), ctx); + // In number order: two adds may commit in either order. + window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW)); + return null; +``` + +## The view per principal + +`Todos` is the remote face of the store, one object per principal. Its factory gets the principal the link stamped: + + +```ts + host.serve(Todos, (_params, principal, self) => { + const store = self.get(Store, {}); + // Who adds a todo comes from the principal the link stamped, never from the arguments. + const by = principal.kind === "user" ? principal.name : principal.kind; + const members = { + items: self.derive([store.items], ([items]) => items), + add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx), + tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx), + }; + if (principal.kind !== "owner") return { variant: "member", members }; + return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } }; + }); +``` + +`self.get(Store, {})` declares a dependency: the view goes live once the store is, and holds a **reference** to it +for its whole life. `by` comes from the principal. A client could put anything in the arguments of `add`; it cannot +change what its link stamped. That is the rule for every plugin: identity from the principal, never from arguments. + +## Running it + +Step 2 asks for the groceries list twice, from a root acting as the owner and from a root of the server itself: + + +```ts +const OWNER = { kind: "owner" } as const; +await app.host.root( + async (ctx) => { + const todos = await ctx.get(Todos, { list: "groceries" }); + await todos.add("milk", ctx); + await todos.add("eggs", ctx); + await todos.tick({ id: 1, done: true }, ctx); + await until(() => todos.items.value[0]?.done === true); + console.log("2. as the owner:", todos.variant, "|", show(todos.items.value)); + }, + context, + { principal: OWNER }, +); +await app.host.root(async (ctx) => { + const todos = await ctx.get(Todos, { list: "groceries" }); + await todos.add("bread", ctx); + await until(() => todos.items.value.length === 3); + console.log(" as the server:", todos.variant, "|", show(todos.items.value.slice(-1))); +}, context); +``` + + +```text +2. as the owner: owner | [x] milk (owner), [ ] eggs (owner) + as the server: member | [ ] bread (server) + workers: list|groceries + workers/list/list%7Cgroceries: store.sqlite, store.sqlite-shm, store.sqlite-wal +``` + +A **root** (`host.root(fn, ctx, { principal })`) is a call scope for code outside objects; its `ctx` carries the +principal and cancellation, and the references `ctx.get` takes end when `fn` settles. The first request started the +list's worker, `list|groceries`, with its own store in `workers/list/list%7Cgroceries/`. + +## Trap: who a reference acts as + +The two roots made the same call and got different todos: `milk (owner)` and `bread (server)`. **A reference acts as +whoever made it.** `ctx.get` in a root or a method acts as that context's principal; `host.get`, and `self.get` in a +`local` object, act as the host; `self.get` in a remote object acts as that object's principal (design §4). So a route +whose request came from the owner reaches the worker as the owner (chapter III), and a guest's call cannot borrow the +server's authority. The surprise is the reverse case: a server method that wants to act as the server towards a worker +must not use its caller's `ctx.get`; it takes the reference with `self.get` in a local server object, or in a root of +the server's own. + +## Trap: await what you start + +Every write above is awaited, and that is not style. What is started with a context ends with it: a root's scope seals +when `fn` settles, a method's context ends with its call. A `void todos.add(text, ctx)` inside a root that returns at +once is cancelled before it commits; Chord rejects it and reports `transaction_cancelled` through `onError`, so an +empty `catch` cannot hide a write that never happened (design §8). Fire and forget only with a context that does not +end, as the page does with `BACKGROUND_CONTEXT` in chapter IV. + +# Routes {#routes lede="A server facet: a JSON API for command-line clients, running as whoever sent the request."} + +The page will talk Chord over a WebSocket. A script on Mara's machine is happier with HTTP. A **server facet** +contributes routes to the kit's `Http` registry, which the API's server module re-exports: + + +```ts +export { Http } from "../../../../../../src/node/http/index.ts"; +``` + + +```ts +// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the +// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them. + +import { defineFacet, Todos } from "@todo/app/v1"; +import { Http } from "@todo/app/v1/server"; + +export default defineFacet((_host, self) => { + self.contribute(Http, { + routes: { + "GET /api/lists/:list": async (request, ctx) => { + const todos = await ctx.get(Todos, { list: request.params.list! }); + return { kind: "json", value: todos.items.value }; + }, + "POST /api/lists/:list": async (request, ctx) => { + const todos = await ctx.get(Todos, { list: request.params.list! }); + await todos.add(String(await request.json()), ctx); + return { kind: "json", value: "added" }; + }, + }, + }); +}); +``` + +A **registry** is an extension point: one owner declares how contributions combine (here the kit's HTTP component +builds a router), plugins contribute one value each. `self.contribute` in a facet adds the plugin's routes while the +plugin is loaded. A route runs in a root with the request's principal, so `ctx.get(Todos, …)` reaches the list's +worker as whoever signed in, and gets their variant. + +Step 3 is a command-line client with the owner token file: + + +```ts +const token = (await readFile(app.ownerTokenFile, "utf8")).trim(); +const cli = { authorization: `Bearer ${token}` }; +const posted = await fetch(`${url}/api/lists/groceries`, { + method: "POST", + headers: { ...cli, "content-type": "application/json" }, + body: JSON.stringify("butter"), +}); +console.log("3. POST /api/lists/groceries:", await posted.json()); +const listed = (await (await fetch(`${url}/api/lists/groceries`, { headers: cli })).json()) as Item[]; +console.log(" GET /api/lists/groceries:", show(listed)); +console.log(" GET without a token:", (await fetch(`${url}/api/lists/groceries`)).status); +``` + + +```text +3. POST /api/lists/groceries: added + GET /api/lists/groceries: [x] milk (owner), [ ] eggs (owner), [ ] bread (server), [ ] butter (owner) + GET without a token: 401 +``` + +`butter` is the owner's: the route acted as the request's principal. Without a token the kit answered 401 before the +route ran. Another machine has no token file; it **pairs** instead: it asks `POST /_chord/pair` for a code, the owner +approves the code in a signed-in browser, and the client's next poll returns its token (example 54). + +# A Page {#page lede="A web entry, a web facet with React, the panels registry, and why a watch starts at the current value."} + +A page is a client host in the browser. The kit builds its **base bundle** from the app's web entry at start (Chord, +React, the app's web modules), serves the plugins' web facets as small scripts beside it, and serves the page through +the app's template (design §23, "Web and TUI"). + +## The page's registry + +Plugins put panels on the page through a registry the API's web module declares, with Chord's React hooks re-exported +for them: + + +```ts +import type { ComponentType } from "react"; +import { defineRegistry } from "../../../../../../src/index.ts"; + +export { useHold, useWatch } from "../../../../../../src/react/index.ts"; + +/** A panel of the page: its title and its React component. */ +export type Panel = { readonly title: string; readonly Component: ComponentType }; + +/** The page's panels, one per contributing plugin, in plugin-name order. */ +export const Panels = defineRegistry("todo.panels", { version: 1 }); +``` + +The web entry serves `Panels` (its `compose` keeps the contributions in plugin-name order), renders every panel, and +starts the page's connection: + + +```ts +/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */ +const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) })); + +function Page() { + const panels = useRegistry(v1web.Panels) ?? []; + return h( + "main", + null, + h("h1", null, "Todo"), + ...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))), + ); +} + +void startPage({ + connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`), + modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx }, + core: [{ name: "@todo/web", facet: core }], + render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))), +}); +``` + +`startPage` makes the page's host, connects it to `/chord` (reconnecting after drops), loads the current plugin +scripts with the entry's core facets, and keeps them current. `HostProvider` gives the React tree that host. + +## The web facet + +`plugins/lists/web.ts` contributes a panel. Its component holds the list's `Todos` while it is mounted and re-renders +when its items change: + + +```ts +function TodoList() { + const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" }); + const items = useWatch(todos?.items) ?? []; + const [text, setText] = useState(""); + const add = (event: { preventDefault(): void }) => { + event.preventDefault(); + if (todos === undefined || text.trim() === "") return; + void todos.add(text, context); + setText(""); + }; + return h( + "div", + { className: "lists-panel" }, + h( + "ul", + null, + ...items.map((item) => + h( + "li", + { + key: item.id, + className: item.done ? "lists-done" : undefined, + onClick: () => void todos?.tick({ id: item.id, done: !item.done }, context), + }, + item.text, + h("span", { className: "lists-by" }, item.by), + ), + ), + ), + h( + "form", + { onSubmit: add }, + h("input", { + value: text, + placeholder: "Add a todo", + onChange: (event: { target: { value: string } }) => setText(event.target.value), + }), + ), + todos?.variant === "owner" + ? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done") + : null, + ); +} + +export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList })); +``` + +`useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first +render. `useWatch` follows a member. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call +is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has +`clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed +by the plugin's name (a `*.module.css` file would scope them instead): + + +```css +.lists-panel input { width: 100%; box-sizing: border-box; padding: 0.4rem; background: var(--bg); color: var(--fg); + border: 1px solid var(--accent); border-radius: var(--radius, 0); } +``` + +The template in `start.ts` puts the assets the kit made into the app's own HTML: + + +```ts +function page(request: { readonly nonce: string }, assets: PageAssets): string { + return `Todo${pageScripts(assets, request.nonce)}
`; +} +``` + +## Running it + +Step 4 reads the page as a browser gets it and watches the list from the server: + + +```ts +const html = await (await fetch(url, { headers: cli })).text(); +console.log("4. the page loads:"); +for (const [, asset] of html.matchAll(/(?:src|href)="([^"]+)"/g)) console.log(` ${hashless(asset!)}`); +const groceries = await app.host.get(Todos, { list: "groceries" }, context); +const watch = await groceries.items.watch(context); +console.log(" watch.value:", done(watch.value), "done"); +const changed = Promise.withResolvers(); +watch.start(async (call) => { + if (call.kind === "value") changed.resolve(call.value); +}); +await groceries.tick({ id: 2, done: true }, context); +console.log(" the listener's first call:", done(await changed.promise), "done"); +await watch.stop(); +``` + + +```text +4. the page loads: + /_chord/web/base..css + /_chord/web/plugin/@todo~lists..css + /_chord/web/plugins..js + /_chord/web/base..js + watch.value: 1 done + the listener's first call: 2 done +``` + +The page is the template, the base bundle (`base..js` and `.css`), one combined file with every plugin's web +script and one stylesheet per plugin with CSS: two scripts on a cold load, cacheable forever by their hashes. After the +owner opened the owner link in Chromium, the page shows the list: + +:::figure | The page after the owner signed in: the groceries list, live, each todo with who added it; the owner's variant shows "Clear done". +![The todo page](screens/page.png) +::: + +## Trap: a watch starts at the current value + +`watch.value` was the value when the watch was taken (one todo done); the listener's first call was the change after +it (two done). **A watch's listener gets only changes.** Code that renders from a listener alone draws nothing until +the first change. Read `handle.value` first, then `start` (design §11, "Watchables"). `useWatch` and the terminal's +`TuiScope.watch` do this for you; `self.on`, an object's routine, is the one consumer whose first call is the current +value. + +# A Terminal {#terminal lede="The same plugin in a terminal: a terminal facet with TuiScope, run in the launcher's sandbox."} + +The terminal client is a client host too, run by a **launcher**: a trusted Node program that signs in, downloads the +terminal's base bundle and the plugins' terminal scripts, and runs them in a **sandbox**, a child process with no file +system, network, processes or terminal of its own. The launcher relays its link and draws its frames. Code from the +server never touches Mara's machine directly (decision "The terminal's sandbox is a child process"). + +The API's terminal module declares the parts of the screen: + + +```ts +export type Screen = { + readonly title: string; + readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[]; +}; + +/** The terminal's parts, in plugin-name order. */ +export const Screens = defineRegistry("todo.screens", { version: 1 }); +``` + +The terminal entry serves `Screens` and redraws on every change. A **`TuiScope`** holds references, follows values and +registries, calls one callback on every change, and releases everything when disposed; it is the terminal's +counterpart of the React hooks, for any terminal library: + + +```ts +/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */ +const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) })); + +void startTerminalRuntime({ + modules: { "@todo/app/v1": v1, "@todo/app/v1/tui": v1tui }, + core: [{ name: "@todo/tui", facet: core }], + render: (runtime) => { + const { terminal, theme } = runtime; + const screens = new TuiScope(runtime.host, () => draw()).registry(v1tui.Screens); + let opened: { screens: readonly v1tui.Screen[]; scope: TuiScope; lines: (() => readonly string[])[] } | undefined; + const draw = (): void => { + const current = screens() ?? []; + if (opened?.screens !== current) { + opened?.scope.dispose(); + const scope = new TuiScope(runtime.host, () => draw()); + opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) }; + } + const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]); + terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`); + }; + theme.onChange(draw); + terminal.onInput((data) => { + if (data === "q") runtime.exit(0); + }); + draw(); + }, +}); +``` + +When a reload changes `Screens`, the entry disposes the old scope and opens the screens again. The lists plugin's +terminal facet is a few lines: + + +```ts +export default defineFacet((_host, self) => + self.contribute(Screens, { + title: "Groceries", + open: (scope, theme) => { + const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items); + return () => + (items() ?? []).map((item) => + item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`, + ); + }, + }), +); +``` + +`theme.fg("muted", …)` colours by role, never by a fixed colour. Step 5 runs the launcher on a scripted terminal: + + +```ts +if (sandboxAvailable()) { + const output = Object.assign(new PassThrough(), { columns: 60, rows: 12 }); + output.on("data", (chunk: Buffer) => frames.push(chunk.toString())); + terminal = await startTerminal({ + url, + credentials: { kind: "file", path: app.ownerTokenFile }, + input: new PassThrough(), + output, + colors: "truecolor", + }); + await until(() => screen(frames).includes("butter")); + console.log("5. the terminal shows:"); + for (const line of screen(frames).trim().split("\r\n")) console.log(` ${line}`); +} else console.log("5. (the terminal client needs Node 26.5.1 or later for its sandbox)"); +``` + + +```text +5. the terminal shows: + Groceries + [x] milk + [x] eggs + [ ] bread server + [ ] butter owner +``` + +# A Theme {#theme lede="A theme is data in a registry; a plugin added while the app runs restyles the page and the terminal in place."} + +Mara wants the evening look. A theme is data: colours by role, a few web extras, terminal overrides. The kit's `Themes` +registry collects them; each user picks one dark and one light theme, the system preference chooses (decision "Looks +and themes"). The Harbor theme is a plugin with its data in one file: + + +```ts +export const harbor: Theme = { + id: "harbor", + name: "Harbor", + mode: "dark", + colors: { + accent: "#f2a65a", + bg: "#12232e", + fg: "#d8e2e8", + muted: "#6b8796", + error: "#e5676b", + warning: "#e9c46a", + success: "#7fb685", + }, + web: { radius: "6px" }, + tui: { muted: "66" }, +}; +``` + +and two facets contributing it, one for pages and one for terminals (registries are per host, so each kind gets its +own contribution): + + +```ts +import { defineFacet, Themes } from "@todo/app/v1"; +import { harbor } from "./theme.ts"; + +export default defineFacet((_host, self) => self.contribute(Themes, harbor)); +``` + +The plugin is not in the app's root at start. Step 6 copies it in while everything runs and applies the change: + + +```ts +await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true }); +await until(() => app.plugins.state.value.set); +const added = await app.applyChanges(context); +console.log("6. harbor added, rolled out to:", added.steps.map(({ name }) => name).join(", ")); +const scripts = await app.host.get(WebScripts, {}, context); +console.log(" the page's plugins:", scripts.set.value.plugins.map(({ name }) => name).join(", ")); +scripts.release(); +``` + + +```text +6. harbor added, rolled out to: server, list, web, tui + the page's plugins: @todo/harbor, @todo/lists +``` + + +```text + the terminal redraws its title in Harbor's accent: "\u001b[38;2;242;166;90mGroceries" +``` + +A directory appearing in a plugin root marks the plugin set changed (`app.plugins.state.value.set`); nothing happens +until `applyChanges`, which builds the new plugin and rolls it out: the server, the list workers, then the page and +terminal surfaces. The page's host learned of the new script set through Chord, fetched Harbor's web script and loaded +it in place; the terminal did the same with its terminal script and redrew in Harbor's accent. With the browser +preferring dark: + +:::figure | The same page after the Harbor plugin was added and applied: no reload, the CSS variables on `:root` changed. +![The page in the Harbor theme](screens/harbor.png) +::: + +# A Second User {#user lede="A one-line login plugin, a client host of Ada's, and why a host must load before it asks."} + +Ada joins with a name. The kit ships an ephemeral login as a facet (`POST /_chord/login { name }` signs in as `{ kind: +"user", id, name }`, no account stored); the API's server module exports it for the app's principal type: + + +```ts +/** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */ +export const login = ephemeralLoginFacet(); +``` + +and the app adds it as a **single-file plugin**, a file named `..ts` at the root's top: + + +```ts +// The kit's pick-a-name login, added to the app as a single-file plugin: a server facet, one line. + +import { login } from "@todo/app/v1/server"; + +export default login; +``` + +Step 7 does what Ada's page does: logs in, then connects a client host of her own over the links endpoint with her +cookie: + + +```ts +const login = await fetch(`${url}/_chord/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "ada" }), +}); +const adaCookie = login.headers.get("set-cookie")!.split(";")[0]!; +const ada = createHost({ onError: (error) => console.error("ada:", error.kind) }); +try { + ada.hold(Todos, { list: "groceries" }, context); +} catch (error) { + console.log("7. ada's hold before load:", (error as Error).message); +} +await ada.load([], context); +const socket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie: adaCookie } }); +ada.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context); +const adas = await ada.get(Todos, { list: "groceries" }, context); +await adas.add("coffee", context); +console.log(" ada's variant:", adas.variant); +if (adas.variant === "member") { + // @ts-expect-error `clear` is the owner's: TypeScript refuses it, and so does the worker. + const clearing: Promise = adas.clear(context); + await clearing.catch((error: ChordError) => console.log(" ada's clear:", `${error.code}/${error.reason}`)); +} +await until(() => groceries.items.value.some(({ by }) => by === "ada")); +console.log(" the server's reference sees:", show(groceries.items.value.slice(-1))); +``` + + +```text +7. ada's hold before load: todo.todos: the host has not loaded; call host.load first (a host with nothing to load calls load([])) + ada's variant: member + ada's clear: invalid/member + the server's reference sees: [ ] coffee (ada) +``` + +## Trap: load before use + +Ada's first `hold` threw at once: **a host serves and binds nothing before `load`.** Requests made before it would wait +for a publication nobody started, so Chord refuses them with `not_ready` ("call `host.load` first"); a client with no +plugins of its own calls `load([])` (design §3). `startPage`, the launcher and the kit do it for you; code that makes +a host itself must not forget. + +Ada got the `member` variant; her `clear` is refused twice, by TypeScript (the `@ts-expect-error`) and by the worker +(`invalid/member`), because what a reference offers is decided by the provider's variant, not by the caller's code. +Her todo is hers: `coffee (ada)`. + +# Changing Code {#reload lede="An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate."} + +Ada typed " tea " with spaces, and an empty add should do nothing. The fix is in the lists plugin's worker facet. +Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it: + + +```ts +const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"; +const AFTER = `add: async (text: string, ctx: Ctx) => { + if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx); + return null; + },`; +const workerFile = join(root, "lists", "worker.ts"); +await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER)); +await until(() => app.plugins.state.value.dirty.includes("@todo/lists")); +const applied = await app.applyChanges(context); +console.log("8. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", ")); +for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) { + const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : []; + console.log(` on ${host.host}:`, [...new Set(fates)].join(", ")); +} +await adas.add(" tea ", context); +await until(() => groceries.items.value.at(-1)?.text === "tea"); +console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1))); +const count = groceries.items.value.length; +await adas.add(" ", context); +console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added"); +``` + + +```text +8. lists changed, rolled out to: server, list, web, tui + on list|groceries: todo.store restarted, todo.todos(groceries) parked + ada adds ' tea ': [ ] tea (ada) + ada adds ' ': ignored +``` + +The watcher marked `@todo/lists` dirty when the file changed; `applyChanges` rebuilt it (one esbuild call per facet) +and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording +`self` to see what they declare), commits it in one step, and gives each object of the changed plugin its **fate** +(design §21): + +| The object | Fate | +| --- | --- | +| opens no natives, and neither does its new factory | **swapped** in place: new methods, the same documents; running calls finish on the old code | +| opens natives | **restarted**: calls wait, running ones finish, natives close and open again with the new code | +| its new code waits for a dependency | **parked** until the dependency is live | +| its type was removed | **ended**; its holders wait until something serves it again | + +The store opens a native (the window's first read), so it restarted and read its window again from the table; the +views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of +`loading`, and her next add was trimmed. Had the edit broken the plugin, that plugin alone would have kept its old code +and the report would name the error. + +A contract that changes shape needs a new `version`; old consumers keep working when the provider maps their version +(`accepts`), otherwise they wait with `contract_mismatch` until they reload. A stored document or table that changes +shape bumps its own version with a migration. Contract versions protect callers, storage versions protect data. + +## On the page + +An edit of a web facet applies in place too: the page fetches the one changed script and reloads its host, with no +page reload. `screenshots.ts` adds a count of open todos to the panel while half a todo is typed: + + +```ts +const BEFORE = `h( + "ul",`; +``` + + +```text +wrote docs/tutorial/screens/page.png +wrote docs/tutorial/screens/harbor.png +wrote docs/tutorial/screens/edited.png +typed "oat mi"; after the edit: "" +the page did not reload: true +``` + +:::figure | After the edit of the web facet, applied in place: the count appears; the half-typed "oat mi" is gone, because the panel's component is new code and React mounted it anew. +![The page after the edit](screens/edited.png) +::: + +The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel's +own React state did not: a new component function is a new component to React. + +# Moving a List {#export lede="Export a worker's data while it runs, import it under another address."} + +A list's data is what its worker created: its store and its files. Step 9 exports the groceries list while its worker +runs and imports it as a new list: + + +```ts +const archive = join(base, "groceries.tar"); +await app.exportData(List({ id: "groceries" }), archive, context); +console.log("9. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing"); +await app.importData(archive, List({ id: "picnic" }), context); +const picnic = await app.host.get(Todos, { list: "picnic" }, context); +console.log(" imported as picnic:", show(picnic.items.value)); +picnic.release(); +await app.importData(archive, List({ id: "groceries" }), context).catch((error: Error) => { + console.log(" importing over groceries:", error.message.replace(base, "…")); +}); +``` + + +```text +9. exported groceries: an archive + imported as picnic: [x] milk (owner), [x] eggs (owner), [ ] bread (server), [ ] butter (owner), [ ] coffee (ada), [ ] tea (ada) + importing over groceries: importData: list|groceries has data (its worker runs) +``` + +`exportData` stops nothing: the running worker writes its own store at one consistent point (the store's export holds +its commit queue while it reads) and its files, as one archive with a manifest naming the app. With no worker running, +the server takes the worker's process lock and reads the store itself. `importData` refuses an address that has data; +replacing a list is a delete, then an import (decision "App kit naming and export"). Registering an imported list in +an index of the app's, if it had one, is the app's. + +# Testing One Plugin {#testing lede="testPlugin builds a plugin as production does and loads it alone on a host of its own."} + +The steps above test the whole app. Most rules are checked faster on one plugin. `testPlugin` builds the plugin as +production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and +loads it on a new host with an in-memory store, here at a list's address so it may serve `Todos`: + + +```ts + let t: TestedPlugin; + beforeEach(async () => { + t = await testPlugin( + fileURLToPath(new URL("./", import.meta.url)), + { + kind: "worker", + api: { name: "@todo/app", versions: ["v1"] }, + modules: { "@todo/app/v1": v1 }, + address: v1.List({ id: "groceries" }), + principal: { kind: "worker", list: "list|groceries" }, + }, + context, + ); + }); + afterEach(() => t.close(context)); +``` + +Each test runs code as a principal of its choice with `t.root`: + + +```ts + it("lets the owner clear the done todos", async () => { + await t.root( + async (ctx) => { + const todos = await ctx.get(v1.Todos, { list: "groceries" }); + if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`); + await todos.add("milk", ctx); + await todos.add("eggs", ctx); + await todos.tick({ id: 1, done: true }, ctx); + await todos.clear(ctx); + expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]); + }, + context, + { principal: OWNER }, + ); + expect(t.unserved()).toEqual([]); +``` + +What the plugin uses and no plugin of the test serves would wait unbound; `t.unserved()` lists those names, so a test +knows which stub facets to add. Registries it contributes to that nobody serves are collected (`t.contributions(R)`). +Run it like any test: + +```sh +node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts +``` + +The test imports `Todos` and `List` from the API module by path. It could not import a contract from a plugin's own +`contract.ts`: that file imports `@todo/app/v1`, a name only plugin builds resolve. A test of such a contract declares +it again (contracts are data: the same name, version and schema bind), as `docs/plugin-authoring.md` shows; this app +keeps the contract it shares in its API instead. + +# Writing a p2 Plugin {#p2 lede="What is different in p2, and one small plugin: a tool for the agent and a chip for the people."} + +p2, the coding agent's server with its web page and terminal, runs on Chord v2 but not on the app kit. It is older than +the kit and keeps its own decisions: its own data directory and SQLite files, its own sign-in, devices and sharing, +its own page and terminal bundles. It uses Chord's components directly: `createHost` with p2's principals, +`createWorkers` for one worker per session, `createPlugins` to find, build, watch and roll out its plugins. What a +plugin author sees is the same model: contracts, factories, registries, facets per host kind. + +| | The todo app (kit) | p2 | +| --- | --- | --- | +| host kinds | `Server`, `List({ id })` | `Server`, `Session({ id })` (`serverHost`, `sessionHost`) | +| principals | server, worker, owner, user | `owner` (with `access`), `guest` (with `sessions` and `revision`), `worker`, `server`, `host` | +| API | `@todo/app/v1`, `/server`, `/web`, `/tui` | `@p2/app/v1` (browser-safe), `/node` (server and worker), `/server`, `/worker`, `/web`, `/tui` | +| other host packages | React for the web | pi-ai, pi-durable, pi-schema, pi-env, pi-tui, `ws`, `yaml`: plugins import them directly | +| plugins | `plugins//`, `package.json` named `@todo/` | `apps/p2/plugins//`, `package.json` named `` | +| a Chord store | the kit's, per host: persisted documents and tables | none: durable data is Durable's (documents with a conversation) or p2's own files | +| page and terminal | kit surfaces, `Panels` and `Screens` | p2's bundles; the page's `Slots` registry, `useObject`, `useReadable` | +| applying changes | `app.applyChanges` | the owner's Reload button, `npm run reload` in `apps/p2`, the `reload_plugins` tool | + +## The scratchpad plugin + +The example gives the agent a `scratch_note` tool and shows the session's notes to everyone who may see the session. +Its contract places one object per session on the session's worker: + + +```ts +export const Scratchpad = remote("p2.scratchpad", { + version: 1, + params: { session: param.str() }, + hostAddress: sessionHost, + variants: { reader: { state: { notes: arr(str()) } } }, +}); +``` + +The worker facet holds the notes in a worker-wide store (a local object with empty params, as in chapter II). It gives +the agent its tool through p2's `Extensions` registry, a keyed registry of Durable extensions, and serves the view to +whoever `canView` the session: + + +```ts +const Notes = defineState("p2.scratchpad.notes", { + schema: obj({ items: arr(str()) }), + version: 1, + initial: () => ({ items: [] }), +}); +``` + +```ts +export default defineFacet((host) => { + host.serve(ScratchpadStore, (_params, self) => { + const notes = self.state(Notes); + self.contribute(Extensions, { + scratchpad: defineExtension({ + name: "scratchpad", + tools: [ + defineTool({ + name: "scratch_note", + description: + "Keep a short note for this session (a decision, an open question). Everyone viewing the session sees the notes.", + parameters: obj({ text: str({ minLength: 1, maxLength: 500 }) }), + replay: "unsafe", + execute: async ({ text }, _api, context) => { + await notes.change((draft) => { + draft.items.push(text); + }, context); + return { + output: [{ type: "text" as const, text: `Noted (${notes.value.items.length} in all).` }], + }; + }, + }), + ], + }), + }); + return { notes: self.derive([notes], ([value]) => value.items) }; + }); + host.serve(Scratchpad, ({ session }, principal, self) => { + if (!canView(principal, session)) throw forbidden("viewers only"); + const store = self.get(ScratchpadStore, {}); + return { variant: "reader", members: { notes: self.derive([store.notes], ([notes]) => notes) } }; + }); +}); +``` + +The notes are an in-memory document: p2 opens no Chord store, so `persist: true` is not available, and the notes last +as long as the session's worker. Data that must outlive it goes into a Durable document (p2's chat-mode keeps its switch +that way). The tool runs inside Durable with a Durable `Context`, which is Chord's own context type, so the store's +handle takes it and the write is awaited. The page gets a chip in the composer's bar through `Slots`: + + +```tsx +function NotesChip() { + const { session } = useRoute(); + const pad = useObject(Scratchpad, session === undefined ? undefined : { session }); + const notes = useReadable(pad?.notes) ?? []; + if (notes.length === 0) return null; + return ( + + Notes · {notes.length} + + ); +} + +export default defineFacet((_host, self) => self.contribute(Slots, { "composer:scratchpad": NotesChip })); +``` + +## Testing it in p2 + +p2's tests load facets on a bare host with `testHost` (`test/p2/helpers.ts`), stub what they need, and reach the host +as any principal over a memory link with `fixture.client(principal)`: + + +```ts + it("keeps the agent's notes and shows them to the session's viewers", async () => { + const extensions = await fixture.host.registry(Extensions, BACKGROUND_CONTEXT); + const tool = extensions.value.get("scratchpad")!.tools![0]!; + await tool.execute({ text: "ship on friday" }, undefined as never, BACKGROUND_CONTEXT); + const pad = await fixture.client(OWNER).get(Scratchpad, { session: "s1" }, BACKGROUND_CONTEXT); + assert.deepEqual(await until(pad.notes, (notes) => notes?.length === 1), ["ship on friday"]); + }); +``` + +Run from `apps/p2` with `node --import ./src/source.ts --test test/p2/scratchpad.test.ts`. Load the plugin into a running +p2 with the owner's Reload button or `npm run reload -- scratchpad`; a new plugin directory is picked up by the same +reload. The trap of chapter II applies with p2's names: a server method that must reach a session's worker as the +server runs in a root of the server's own (`serverOf(self)().root`), not through its caller's `ctx.get`. + +# Traps and Open Questions {.epilogue lede="The four traps once more, and what in Chord v2 was hard to explain while writing this."} + +**The traps.** Load before use: a host binds nothing before `load`; a client with nothing to load calls `load([])`. A +reference acts as whoever made it: `ctx.get` as the caller, `host.get` and a local object's `self.get` as the host. +Await what you start: a write started with a context that ends is cancelled and reported `transaction_cancelled`. A +watch starts at the current value: read `handle.value`, then `start`; the listener gets changes only. + +**Hard to explain, found while writing the example.** Each is a candidate for a fix in Chord rather than a paragraph in +a tutorial. + +- **Integer table keys do not type-check.** `KeyOf` in `src/store/table.ts` still tests TypeBox's + `"~kind": "Integer"`, which pi-schema's `int()` lacks, so the key of an `int` key column is typed `string`: + `tx.get(items, { id })` with a number fails `tsc`. The store here uses `updateWhere` with `eq` instead of + `tx.update`. +- **A one-time read at activation is a native.** The live window over a table needs a first read before the object is + live; `self.open` is the only place, so the store counts as holding a native and restarts at every reload of its + plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view + repeats this pattern (example 59's chat does too). +- **A reloaded web plugin loses its own React state.** Decision "Slice 11, P4" promises an in-place reload "keeping what + is on screen (scroll, a half-typed message)". The page does not reload, but the changed plugin's components are new + functions, so React mounts them anew and their state (the half-typed todo) is gone. +- **Plugin contracts are hard to test from outside builds.** A test or script cannot import a plugin's + `contract.ts` (it imports the app's API by a package name only builds resolve), so contracts are declared twice or + moved into the API. +- **No public `invalid`.** Plugins throw `PluginError` with codes of their own; refusing bad arguments with Chord's + `invalid/args`, as the built-in checks do, is not open to them. + +# The App's Files {.appendix} + +| File | Kind | What it is | +| --- | --- | --- | +| `src/principals.ts` | base | the principal type | +| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part, `defineFacet`, `List`, `Item`, `Todos` | +| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` | +| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useWatch` | +| `src/api/v1/tui.ts` | base, tui | `Screens` | +| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` | +| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels | +| `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens | +| `plugins/lists/` | plugin | worker (table, store, views), server (routes), web (panel), tui (screen), its test | +| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login | +| `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals | +| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks | + +# Reference {.appendix} + +| Call | Chapter | What it does | +| --- | --- | --- | +| `startServer

({ name, dataDir, root, listen, api, kinds, principal, workers, web, tui, onError }, ctx)` | I | the app's server, with the kit | +| `startWorker({ api, kinds, onError })` | I | a worker's entry | +| `app.ownerLink()`, `app.ownerTokenFile` | I, III | the owner's sign-in, for a browser and for local clients | +| `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts | +| `host.serve(Type, factory)`; factory `self`: `table`, `writable`, `open`, `derive`, `tx`, `get`, `contribute` | II | an object's parts | +| `host.root(fn, ctx, { principal })`, `ctx.get(Type, params)` | II | code outside objects, as a principal | +| `self.contribute(Http, { routes })` | III | routes, denied by default | +| `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page | +| `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal | +| `self.contribute(Themes, theme)` | VI | a theme | +| `ephemeralLoginFacet

()`, `host.load([], ctx)`, `host.connect(link, { principal }, ctx)` | VII | a second user's client | +| `app.plugins.state`, `app.applyChanges(ctx)` | VI, VIII | marks and rollouts | +| `app.exportData(address, path, ctx)`, `app.importData(path, address, ctx)` | IX | moving a worker's data | +| `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | X | one plugin alone | diff --git a/packages/chord/docs/tutorial/index.html b/packages/chord/docs/tutorial/index.html new file mode 100644 index 000000000..4a74485f3 --- /dev/null +++ b/packages/chord/docs/tutorial/index.html @@ -0,0 +1,1378 @@ + + +Together: build a shared todo app with Chord + + + + +

+
+
A tutorial · Chord v2 and its app kit · 2026-11-03
+

Build It
Together

+

One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme and an edit applied while they are connected

+
9steps, one runnable example
4host kinds one plugin runs on
1worker process per list
0restarts for a new theme or an edit
+

The app is packages/chord/test/examples/61-tutorial/, runnable with the line in its main.ts. Every code block +below is quoted from its files, and every console output from what its steps print: +test/examples/61-tutorial/tutorial.test.ts fails when a quote or an output drifts. The design is +packages/chord/docs/design.md; where this tutorial says "the rule", design.md has the full statement. The last +chapter is about p2, whose code lives outside this repository.

+
+
+ + +
+

Before we begin

+

Mara keeps the household's shopping list on her laptop. She wants it in a browser tab and in a terminal, live in both. +Ada, her flatmate, should be able to add coffee from her phone without an account: pick a name and go. And Mara wants +to change the app while it runs: add a dark theme in the evening, fix a small annoyance in how todos are added, without +anyone being thrown out.

+

That is four kinds of program (a server, a process per list, a page, a terminal client) and three kinds of change (new +data, a new person, new code) in one small app. Wiring that by hand means a web server, a socket protocol, a way to +start and stop processes, sessions, a bundler for the page and a reload story for each. Chord does the wiring, and its +app kit (@earendil-works/chord/app) makes the decisions an app would otherwise make itself: where data lives, +how people sign in, how plugins are found and rolled out.

+

This tutorial builds that app. Each chapter adds one thing, shows the code, shows what happens when it runs, and +states the rule behind it. The app is one directory, test/examples/61-tutorial/; run every step at once from +packages/chord:

+
node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts
+

With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one, +that step says so and the rest runs.

+

Eight things to know

+
    +
  1. An app is its API, its plugins and a start script. startServer makes the data directory, the store, the +server host, HTTP with sign-in, the workers and the plugin roots; startWorker does a worker's half.
  2. +
  3. A host is one participant. The server, each list's worker, each browser tab and each terminal are hosts, +connected by links. Tabs and terminals are clients: they connect and ask, nobody asks them.
  4. +
  5. An object is reached by its contract. A contract names an object type, its params and its members; a remote +contract also says which host serves it. Any host asks for Todos { list: "groceries" }, and Chord routes the +request to that list's worker.
  6. +
  7. Who calls is stamped, never sent. Every call carries a principal the link stamped from the sign-in. A factory +picks a variant per principal; a todo records who added it from the principal, not from its arguments.
  8. +
  9. A plugin has one file per host kind. server.ts, worker.ts, web.ts, tui.ts: each declares what that kind +of host serves and contributes. Plugins import only the app's API.
  10. +
  11. Plugins meet in registries. The page shows Panels, the terminal Screens, the kit's Themes restyle both; +plugins contribute, one plugin (or the entry) composes.
  12. +
  13. Changes apply when you say so. Saving a file marks its plugin dirty; applyChanges builds and rolls out to the +server, the workers, the pages and the terminals, in place.
  14. +
  15. Four traps: load before use; a reference acts as whoever made it; await what you start; a watch starts at the +current value.
  16. +
+

Words used throughout

+

A host is one participant of the app, with an address if others can reach it (the server, List({ id })) or +without one (a client: a page, a terminal). A link connects two hosts. A principal is who calls, typed by +the app and stamped by links. An object is state and methods at one identity; its contract (local for its own +host only, remote for any host) names its params (which instance) and members. A remote contract's variants +are the member sets different principals get. A factory declares an object's parts: documents and tables +(stored data, changed in transactions), derives (values computed from others), natives (things opened and +closed, such as a file or a database cursor). A reference is what asking for an object returns. A facet is a +plugin's code for one host kind; a registry is an extension point plugins contribute to. A rollout applies +new plugin code to the hosts in order.

+
+ +
I

One Server

The API, the principals and a start script: the smallest app the kit runs, and who may come in.

+

An app built with the kit has a fixed shape: its API in src/api/v<n>/, its built-in plugins in plugins/, a start +script and a worker entry (design §23, "The app kit"). Everything else is a plugin. This chapter writes the three +pieces of base code and starts the server.

+

The principals

+

A principal says who is calling. The app defines the type; the kit needs a variant for the server, one for its +workers, and adds the owner, { kind: "owner" }, which the app's type must include:

+ +
// The todo app's principals: its server and list workers, the kit's owner, and the users who signed in with a name.
+
+/** Who someone is in the todo app. */
+export type TodoPrincipal =
+	| { readonly kind: "server" }
+	| { readonly kind: "worker"; readonly list: string }
+	| { readonly kind: "owner" }
+	| { readonly kind: "user"; readonly id: string; readonly name: string };
+

The user variant is for chapter VII. A principal travels with every call and is part of objects' identities, so it +stays small: a kind and an id. A user's profile, if the app had one, would be data in an object keyed by the id.

+

The API

+

Plugins never import Chord. They import the app's API, a module the app names with a version in its path +(@todo/app/v1). It re-exports the part of Chord the app supports and adds what is the app's own:

+ +
export { BACKGROUND_CONTEXT } from "../../../../../../src/context/index.ts";
+export {
+	arr,
+	bool,
+	type Ctx,
+	defineTable,
+	eq,
+	int,
+	local,
+	method,
+	num,
+	obj,
+	PluginError,
+	query,
+	str,
+	type Theme,
+	Themes,
+} from "../../../../../../src/index.ts";
+export type { TodoPrincipal } from "../../principals.ts";
+

and a defineFacet bound to the principal type, so every factory sees a TodoPrincipal:

+ +
/** Defines a facet of the todo app: its factories get the app's principals. */
+export function defineFacet(setup: (host: FacetHost<TodoPrincipal>, self: FacetSelf) => void) {
+	return chordFacet<TodoPrincipal>(setup);
+}
+

The API is a host package: a plugin's build leaves it out, and each process hands the plugin its own instance when +it evaluates it. The plugin sees the very Themes registry and List host kind the process has, not a copy. A +version in the path lets the app ship v2 beside v1 later; one facet uses one version (design §21, "The plugin +model").

+

The start script

+

startServer takes what the app decides and does the rest. start.ts names the API with this process's modules, the +facet kinds (which files a plugin may have), and the principals:

+ +
export const API = {
+	name: "@todo/app",
+	versions: ["v1"],
+	modules: { "@todo/app/v1": v1, "@todo/app/v1/server": v1server },
+};
+
+/** The app's facet kinds: a plugin has one file per kind it runs on (`server.ts`, `worker.ts`, `web.ts`, `tui.ts`). */
+export const KINDS = {
+	server: { platform: "node" },
+	worker: { platform: "node" },
+	web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } },
+	tui: { platform: "node" },
+} as const;
+ +
export function startTodo(dataDir: string, root: string, context: Context): Promise<App<TodoPrincipal>> {
+	return startServer<TodoPrincipal>(
+		{
+			name: "todo",
+			dataDir,
+			root,
+			listen: { host: "127.0.0.1", port: 0 },
+			api: API,
+			kinds: KINDS,
+			principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) },
+			workers: [
+				{
+					kind: v1.List,
+					entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
+					execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
+					start: () => ({}),
+					// Who may reach a list's worker: everyone signed in, and the server itself.
+					authorize: (principal) => principal.kind !== "worker",
+				},
+			],
+			web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page },
+			tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
+			onError: (error) => console.error("todo:", error.kind, error.error),
+		},
+		context,
+	);
+}
+

workers is chapter II, web chapter IV, tui chapter V. listen asks for a free port on loopback, as the example +does; a real app picks a port. The worker entry is three lines:

+ +
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects
+// to the server and loads the plugins' worker facets.
+
+import { startWorker } from "../../../src/node/app/index.ts";
+import { API, KINDS } from "./start.ts";
+
+startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) });
+

Running it

+

Step 1 of main.ts starts the app, tries the page without signing in, then does what a browser does with the owner +link:

+ +
const app = await startTodo(dataDir, root, context);
+const link = new URL(app.ownerLink());
+const url = link.origin;
+console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
+console.log("   data directory:", (await readdir(dataDir)).sort().join(", "));
+console.log("   GET / signed out:", (await fetch(url)).status);
+const signIn = await fetch(`${url}/_chord/owner`, {
+	method: "POST",
+	headers: { "content-type": "application/json" },
+	body: JSON.stringify({ token: link.hash.slice(1) }),
+});
+const cookie = signIn.headers.get("set-cookie")!.split(";")[0]!;
+console.log("   the owner signs in:", signIn.status, cookie.replace(/=.*/, "=…"));
+console.log("   GET / signed in:", (await fetch(url, { headers: { cookie } })).status);
+ +
1. owner link: http://127.0.0.1:<port>/_chord/owner#<token>
+   data directory: logs, owner-token, plugins, server, workers
+   GET / signed out: 401
+   the owner signs in: 200 chord-session=…
+   GET / signed in: 200
+

startServer made the data directory: server/ (its store, the cookie secret, its files), workers/ (one directory +per worker), plugins/ (the user's plugin root, beside the app's built-in one), logs/ and owner-token. The page +answered 401: HTTP denies by default, before any plugin code runs, unless a route says public: true.

+

The owner link (/_chord/owner#<token>) is how the owner gets in: a single-use token, valid ten minutes, made at +each start. It sits in the URL's fragment, which browsers never send, so it never reaches a log; the link's page posts +it and receives a session cookie. A command-line client on the same machine reads owner-token instead (mode 0600, +chapter III). Nothing is the owner because it comes from loopback: any local process could.

+
+ +
II

A List on Its Own Worker

A host kind, a remote contract, a worker facet with a table and a live window, and the principal that adds a todo.

+

Each list gets its own process, a worker, with its own store: a list that misbehaves cannot take the others down, +and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that +keeps its todos.

+

Where a list lives

+

A host kind declares addressable hosts with params; List({ id: "groceries" }) is an address. A remote +contract says, with hostAddress, at which address its objects live. Here is the app's contract for a list's todos, +in the API so that every host kind, the tests and command-line code can name it:

+ +
/** A todo list: a worker process of its own, with its own store. */
+export const List = hostKind("list", { id: str() });
+
+/** A todo: its number in the list, its text, whether it is done, who added it and when. */
+export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
+
+/** A todo (see `Item`). */
+export type Item = Static<typeof Item>;
+
+/**
+ * A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
+ * off (or on again); the owner also clears the done ones.
+ */
+export const Todos = remote("todo.todos", {
+	version: 1,
+	params: { list: str() },
+	hostAddress: ({ list }) => List({ id: list }),
+	variants: {
+		member: {
+			state: { items: arr(Item) },
+			methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
+		},
+		owner: {
+			state: { items: arr(Item) },
+			methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
+		},
+	},
+});
+

params select the instance: Todos { list: "groceries" } is one object per principal. Nobody who asks names a +host: the caller computes the address from the params and sends the request there; a host that cannot serve it passes +it on to its parent or to a route (design §3, "Host kinds and addresses"). The kit's workers option is that route:

+ +
			workers: [
+				{
+					kind: v1.List,
+					entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
+					execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
+					start: () => ({}),
+					// Who may reach a list's worker: everyone signed in, and the server itself.
+					authorize: (principal) => principal.kind !== "worker",
+				},
+			],
+

authorize runs for every request routed to a list before anything starts; the first request for an address starts +its worker, and a worker idle for five minutes (the default idleMs) stops. Variants are the member sets a +contract offers: the owner's Todos has clear, a member's does not. The factory picks one per principal.

+

The worker facet

+

The lists plugin is a directory with a package.json (its name is the plugin's name) and one file per host kind it +runs on. worker.ts is its worker facet: what every list's worker serves. It keeps the todos in a table of the +worker's store:

+ +
/** Every todo of the list, by its number. */
+const Items = defineTable("todo.items", {
+	version: 1,
+	columns: { id: int(), text: str(), done: bool(), by: str(), at: num() },
+	key: ["id"],
+});
+
+/** The newest todos first. */
+const NEWEST = query(Items).orderBy((item) => item.id, "desc");
+
+/** How many todos the live window holds. */
+const WINDOW = 50;
+

A table holds many records with queries and pages; it lives in an object, never in a facet. Persisted data lives only +in local objects with empty params (one per host and type), so its address never depends on who asked (design §9, +"Lifetime, persistence"). That object is the plugin's store, Store, a local contract of its own file:

+ +
/** The list's todos, once per worker: empty params, so it lives as long as the worker and may keep data in its store. */
+const Store = local("todo.store", {
+	version: 1,
+	params: {},
+	members: {
+		items: arr(Item),
+		add: method(obj({ text: str(), by: str() })),
+		tick: method(obj({ id: int(), done: bool() })),
+		clear: method(),
+	},
+});
+

A local object with empty params lives as long as its host: it starts with the worker and is never ended by a count +of references. Its factory declares its parts and returns its members:

+ +
	host.serve(Store, (_params, self) => {
+		const items = self.table(Items);
+		// The newest todos, oldest first, and the next todo's number: read once when the store goes live, then kept here.
+		const window = self.writable<Item[]>([]);
+		let next = 1;
+		self.open(
+			async (ctx) => {
+				const { items: newest } = await items.page(NEWEST, WINDOW, ctx);
+				window.set([...newest].reverse());
+				next = (newest[0]?.id ?? 0) + 1;
+			},
+			() => {},
+		);
+		return {
+			items: self.derive([window], ([value]) => value),
+

A factory is synchronous and only declares; Chord activates the object afterwards. self.table declares the +table. A table has no watch, so the store keeps a live window itself: self.writable holds the newest 50 todos, +filled by self.open, which runs during activation, and exposed through self.derive (a writable is an in-process +value, not a state member; a derive over it is). self.open is meant for natives, things opened and closed; the +window's first read is one, and chapter VIII shows its price.

+

Writes go through transactions: self.tx(fn, ctx) runs fn with tx, and what it stages is committed at once +when fn returns, or not at all if it throws. The window follows after the commit; the store is the table's only +writer, so it knows every change:

+ +
			add: async ({ text, by }, ctx) => {
+				const item = { id: next++, text, done: false, by, at: Date.now() };
+				await self.tx(async (tx) => tx.put(items, item), ctx);
+				// In number order: two adds may commit in either order.
+				window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW));
+				return null;
+

The view per principal

+

Todos is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:

+ +
	host.serve(Todos, (_params, principal, self) => {
+		const store = self.get(Store, {});
+		// Who adds a todo comes from the principal the link stamped, never from the arguments.
+		const by = principal.kind === "user" ? principal.name : principal.kind;
+		const members = {
+			items: self.derive([store.items], ([items]) => items),
+			add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),
+			tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx),
+		};
+		if (principal.kind !== "owner") return { variant: "member", members };
+		return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } };
+	});
+

self.get(Store, {}) declares a dependency: the view goes live once the store is, and holds a reference to it +for its whole life. by comes from the principal. A client could put anything in the arguments of add; it cannot +change what its link stamped. That is the rule for every plugin: identity from the principal, never from arguments.

+

Running it

+

Step 2 asks for the groceries list twice, from a root acting as the owner and from a root of the server itself:

+ +
const OWNER = { kind: "owner" } as const;
+await app.host.root(
+	async (ctx) => {
+		const todos = await ctx.get(Todos, { list: "groceries" });
+		await todos.add("milk", ctx);
+		await todos.add("eggs", ctx);
+		await todos.tick({ id: 1, done: true }, ctx);
+		await until(() => todos.items.value[0]?.done === true);
+		console.log("2. as the owner:", todos.variant, "|", show(todos.items.value));
+	},
+	context,
+	{ principal: OWNER },
+);
+await app.host.root(async (ctx) => {
+	const todos = await ctx.get(Todos, { list: "groceries" });
+	await todos.add("bread", ctx);
+	await until(() => todos.items.value.length === 3);
+	console.log("   as the server:", todos.variant, "|", show(todos.items.value.slice(-1)));
+}, context);
+ +
2. as the owner: owner | [x] milk (owner), [ ] eggs (owner)
+   as the server: member | [ ] bread (server)
+   workers: list|groceries
+   workers/list/list%7Cgroceries: store.sqlite, store.sqlite-shm, store.sqlite-wal
+

A root (host.root(fn, ctx, { principal })) is a call scope for code outside objects; its ctx carries the +principal and cancellation, and the references ctx.get takes end when fn settles. The first request started the +list's worker, list|groceries, with its own store in workers/list/list%7Cgroceries/.

+

Trap: who a reference acts as

+

The two roots made the same call and got different todos: milk (owner) and bread (server). A reference acts as +whoever made it. ctx.get in a root or a method acts as that context's principal; host.get, and self.get in a +local object, act as the host; self.get in a remote object acts as that object's principal (design §4). So a route +whose request came from the owner reaches the worker as the owner (chapter III), and a guest's call cannot borrow the +server's authority. The surprise is the reverse case: a server method that wants to act as the server towards a worker +must not use its caller's ctx.get; it takes the reference with self.get in a local server object, or in a root of +the server's own.

+

Trap: await what you start

+

Every write above is awaited, and that is not style. What is started with a context ends with it: a root's scope seals +when fn settles, a method's context ends with its call. A void todos.add(text, ctx) inside a root that returns at +once is cancelled before it commits; Chord rejects it and reports transaction_cancelled through onError, so an +empty catch cannot hide a write that never happened (design §8). Fire and forget only with a context that does not +end, as the page does with BACKGROUND_CONTEXT in chapter IV.

+
+ +
III

Routes

A server facet: a JSON API for command-line clients, running as whoever sent the request.

+

The page will talk Chord over a WebSocket. A script on Mara's machine is happier with HTTP. A server facet +contributes routes to the kit's Http registry, which the API's server module re-exports:

+ +
export { Http } from "../../../../../../src/node/http/index.ts";
+ +
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
+// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
+
+import { defineFacet, Todos } from "@todo/app/v1";
+import { Http } from "@todo/app/v1/server";
+
+export default defineFacet((_host, self) => {
+	self.contribute(Http, {
+		routes: {
+			"GET /api/lists/:list": async (request, ctx) => {
+				const todos = await ctx.get(Todos, { list: request.params.list! });
+				return { kind: "json", value: todos.items.value };
+			},
+			"POST /api/lists/:list": async (request, ctx) => {
+				const todos = await ctx.get(Todos, { list: request.params.list! });
+				await todos.add(String(await request.json()), ctx);
+				return { kind: "json", value: "added" };
+			},
+		},
+	});
+});
+

A registry is an extension point: one owner declares how contributions combine (here the kit's HTTP component +builds a router), plugins contribute one value each. self.contribute in a facet adds the plugin's routes while the +plugin is loaded. A route runs in a root with the request's principal, so ctx.get(Todos, …) reaches the list's +worker as whoever signed in, and gets their variant.

+

Step 3 is a command-line client with the owner token file:

+ +
const token = (await readFile(app.ownerTokenFile, "utf8")).trim();
+const cli = { authorization: `Bearer ${token}` };
+const posted = await fetch(`${url}/api/lists/groceries`, {
+	method: "POST",
+	headers: { ...cli, "content-type": "application/json" },
+	body: JSON.stringify("butter"),
+});
+console.log("3. POST /api/lists/groceries:", await posted.json());
+const listed = (await (await fetch(`${url}/api/lists/groceries`, { headers: cli })).json()) as Item[];
+console.log("   GET /api/lists/groceries:", show(listed));
+console.log("   GET without a token:", (await fetch(`${url}/api/lists/groceries`)).status);
+ +
3. POST /api/lists/groceries: added
+   GET /api/lists/groceries: [x] milk (owner), [ ] eggs (owner), [ ] bread (server), [ ] butter (owner)
+   GET without a token: 401
+

butter is the owner's: the route acted as the request's principal. Without a token the kit answered 401 before the +route ran. Another machine has no token file; it pairs instead: it asks POST /_chord/pair for a code, the owner +approves the code in a signed-in browser, and the client's next poll returns its token (example 54).

+
+ +
IV

A Page

A web entry, a web facet with React, the panels registry, and why a watch starts at the current value.

+

A page is a client host in the browser. The kit builds its base bundle from the app's web entry at start (Chord, +React, the app's web modules), serves the plugins' web facets as small scripts beside it, and serves the page through +the app's template (design §23, "Web and TUI").

+

The page's registry

+

Plugins put panels on the page through a registry the API's web module declares, with Chord's React hooks re-exported +for them:

+ +
import type { ComponentType } from "react";
+import { defineRegistry } from "../../../../../../src/index.ts";
+
+export { useHold, useWatch } from "../../../../../../src/react/index.ts";
+
+/** A panel of the page: its title and its React component. */
+export type Panel = { readonly title: string; readonly Component: ComponentType };
+
+/** The page's panels, one per contributing plugin, in plugin-name order. */
+export const Panels = defineRegistry<Panel, readonly Panel[]>("todo.panels", { version: 1 });
+

The web entry serves Panels (its compose keeps the contributions in plugin-name order), renders every panel, and +starts the page's connection:

+ +
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */
+const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) }));
+
+function Page() {
+	const panels = useRegistry(v1web.Panels) ?? [];
+	return h(
+		"main",
+		null,
+		h("h1", null, "Todo"),
+		...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))),
+	);
+}
+
+void startPage({
+	connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`),
+	modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx },
+	core: [{ name: "@todo/web", facet: core }],
+	render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))),
+});
+

startPage makes the page's host, connects it to /chord (reconnecting after drops), loads the current plugin +scripts with the entry's core facets, and keeps them current. HostProvider gives the React tree that host.

+

The web facet

+

plugins/lists/web.ts contributes a panel. Its component holds the list's Todos while it is mounted and re-renders +when its items change:

+ +
function TodoList() {
+	const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
+	const items = useWatch(todos?.items) ?? [];
+	const [text, setText] = useState("");
+	const add = (event: { preventDefault(): void }) => {
+		event.preventDefault();
+		if (todos === undefined || text.trim() === "") return;
+		void todos.add(text, context);
+		setText("");
+	};
+	return h(
+		"div",
+		{ className: "lists-panel" },
+		h(
+			"ul",
+			null,
+			...items.map((item) =>
+				h(
+					"li",
+					{
+						key: item.id,
+						className: item.done ? "lists-done" : undefined,
+						onClick: () => void todos?.tick({ id: item.id, done: !item.done }, context),
+					},
+					item.text,
+					h("span", { className: "lists-by" }, item.by),
+				),
+			),
+		),
+		h(
+			"form",
+			{ onSubmit: add },
+			h("input", {
+				value: text,
+				placeholder: "Add a todo",
+				onChange: (event: { target: { value: string } }) => setText(event.target.value),
+			}),
+		),
+		todos?.variant === "owner"
+			? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done")
+			: null,
+	);
+}
+
+export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList }));
+

useHold holds a reference while the component is mounted and releases it on unmount; it is undefined on the first +render. useWatch follows a member. Clicks call methods with BACKGROUND_CONTEXT, which never ends, so a click's call +is not cancelled when the handler returns. todos?.variant === "owner" narrows the reference: only the owner's has +clear, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed +by the plugin's name (a *.module.css file would scope them instead):

+ +
.lists-panel input { width: 100%; box-sizing: border-box; padding: 0.4rem; background: var(--bg); color: var(--fg);
+	border: 1px solid var(--accent); border-radius: var(--radius, 0); }
+

The template in start.ts puts the assets the kit made into the app's own HTML:

+ +
function page(request: { readonly nonce: string }, assets: PageAssets): string {
+	return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Todo</title>${pageScripts(assets, request.nonce)}</head><body><div id="root"></div></body></html>`;
+}
+

Running it

+

Step 4 reads the page as a browser gets it and watches the list from the server:

+ +
const html = await (await fetch(url, { headers: cli })).text();
+console.log("4. the page loads:");
+for (const [, asset] of html.matchAll(/(?:src|href)="([^"]+)"/g)) console.log(`   ${hashless(asset!)}`);
+const groceries = await app.host.get(Todos, { list: "groceries" }, context);
+const watch = await groceries.items.watch(context);
+console.log("   watch.value:", done(watch.value), "done");
+const changed = Promise.withResolvers<readonly Item[]>();
+watch.start(async (call) => {
+	if (call.kind === "value") changed.resolve(call.value);
+});
+await groceries.tick({ id: 2, done: true }, context);
+console.log("   the listener's first call:", done(await changed.promise), "done");
+await watch.stop();
+ +
4. the page loads:
+   /_chord/web/base.<hash>.css
+   /_chord/web/plugin/@todo~lists.<hash>.css
+   /_chord/web/plugins.<hash>.js
+   /_chord/web/base.<hash>.js
+   watch.value: 1 done
+   the listener's first call: 2 done
+

The page is the template, the base bundle (base.<hash>.js and .css), one combined file with every plugin's web +script and one stylesheet per plugin with CSS: two scripts on a cold load, cacheable forever by their hashes. After the +owner opened the owner link in Chromium, the page shows the list:

+

The todo page

+
Figure 1. The page after the owner signed in: the groceries list, live, each todo with who added it; the owner's variant shows "Clear done".
+

Trap: a watch starts at the current value

+

watch.value was the value when the watch was taken (one todo done); the listener's first call was the change after +it (two done). A watch's listener gets only changes. Code that renders from a listener alone draws nothing until +the first change. Read handle.value first, then start (design §11, "Watchables"). useWatch and the terminal's +TuiScope.watch do this for you; self.on, an object's routine, is the one consumer whose first call is the current +value.

+
+ +
V

A Terminal

The same plugin in a terminal: a terminal facet with TuiScope, run in the launcher's sandbox.

+

The terminal client is a client host too, run by a launcher: a trusted Node program that signs in, downloads the +terminal's base bundle and the plugins' terminal scripts, and runs them in a sandbox, a child process with no file +system, network, processes or terminal of its own. The launcher relays its link and draws its frames. Code from the +server never touches Mara's machine directly (decision "The terminal's sandbox is a child process").

+

The API's terminal module declares the parts of the screen:

+ +
export type Screen = {
+	readonly title: string;
+	readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[];
+};
+
+/** The terminal's parts, in plugin-name order. */
+export const Screens = defineRegistry<Screen, readonly Screen[]>("todo.screens", { version: 1 });
+

The terminal entry serves Screens and redraws on every change. A TuiScope holds references, follows values and +registries, calls one callback on every change, and releases everything when disposed; it is the terminal's +counterpart of the React hooks, for any terminal library:

+ +
/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */
+const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) }));
+
+void startTerminalRuntime({
+	modules: { "@todo/app/v1": v1, "@todo/app/v1/tui": v1tui },
+	core: [{ name: "@todo/tui", facet: core }],
+	render: (runtime) => {
+		const { terminal, theme } = runtime;
+		const screens = new TuiScope(runtime.host, () => draw()).registry(v1tui.Screens);
+		let opened: { screens: readonly v1tui.Screen[]; scope: TuiScope; lines: (() => readonly string[])[] } | undefined;
+		const draw = (): void => {
+			const current = screens() ?? [];
+			if (opened?.screens !== current) {
+				opened?.scope.dispose();
+				const scope = new TuiScope(runtime.host, () => draw());
+				opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) };
+			}
+			const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]);
+			terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`);
+		};
+		theme.onChange(draw);
+		terminal.onInput((data) => {
+			if (data === "q") runtime.exit(0);
+		});
+		draw();
+	},
+});
+

When a reload changes Screens, the entry disposes the old scope and opens the screens again. The lists plugin's +terminal facet is a few lines:

+ +
export default defineFacet((_host, self) =>
+	self.contribute(Screens, {
+		title: "Groceries",
+		open: (scope, theme) => {
+			const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items);
+			return () =>
+				(items() ?? []).map((item) =>
+					item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
+				);
+		},
+	}),
+);
+

theme.fg("muted", …) colours by role, never by a fixed colour. Step 5 runs the launcher on a scripted terminal:

+ +
if (sandboxAvailable()) {
+	const output = Object.assign(new PassThrough(), { columns: 60, rows: 12 });
+	output.on("data", (chunk: Buffer) => frames.push(chunk.toString()));
+	terminal = await startTerminal({
+		url,
+		credentials: { kind: "file", path: app.ownerTokenFile },
+		input: new PassThrough(),
+		output,
+		colors: "truecolor",
+	});
+	await until(() => screen(frames).includes("butter"));
+	console.log("5. the terminal shows:");
+	for (const line of screen(frames).trim().split("\r\n")) console.log(`   ${line}`);
+} else console.log("5. (the terminal client needs Node 26.5.1 or later for its sandbox)");
+ +
5. the terminal shows:
+   Groceries
+   [x] milk
+   [x] eggs
+   [ ] bread server
+   [ ] butter owner
+
+ +
VI

A Theme

A theme is data in a registry; a plugin added while the app runs restyles the page and the terminal in place.

+

Mara wants the evening look. A theme is data: colours by role, a few web extras, terminal overrides. The kit's Themes +registry collects them; each user picks one dark and one light theme, the system preference chooses (decision "Looks +and themes"). The Harbor theme is a plugin with its data in one file:

+ +
export const harbor: Theme = {
+	id: "harbor",
+	name: "Harbor",
+	mode: "dark",
+	colors: {
+		accent: "#f2a65a",
+		bg: "#12232e",
+		fg: "#d8e2e8",
+		muted: "#6b8796",
+		error: "#e5676b",
+		warning: "#e9c46a",
+		success: "#7fb685",
+	},
+	web: { radius: "6px" },
+	tui: { muted: "66" },
+};
+

and two facets contributing it, one for pages and one for terminals (registries are per host, so each kind gets its +own contribution):

+ +
import { defineFacet, Themes } from "@todo/app/v1";
+import { harbor } from "./theme.ts";
+
+export default defineFacet((_host, self) => self.contribute(Themes, harbor));
+

The plugin is not in the app's root at start. Step 6 copies it in while everything runs and applies the change:

+ +
await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true });
+await until(() => app.plugins.state.value.set);
+const added = await app.applyChanges(context);
+console.log("6. harbor added, rolled out to:", added.steps.map(({ name }) => name).join(", "));
+const scripts = await app.host.get(WebScripts, {}, context);
+console.log("   the page's plugins:", scripts.set.value.plugins.map(({ name }) => name).join(", "));
+scripts.release();
+ +
6. harbor added, rolled out to: server, list, web, tui
+   the page's plugins: @todo/harbor, @todo/lists
+ +
   the terminal redraws its title in Harbor's accent: "\u001b[38;2;242;166;90mGroceries"
+

A directory appearing in a plugin root marks the plugin set changed (app.plugins.state.value.set); nothing happens +until applyChanges, which builds the new plugin and rolls it out: the server, the list workers, then the page and +terminal surfaces. The page's host learned of the new script set through Chord, fetched Harbor's web script and loaded +it in place; the terminal did the same with its terminal script and redrew in Harbor's accent. With the browser +preferring dark:

+

The page in the Harbor theme

+
Figure 2. The same page after the Harbor plugin was added and applied: no reload, the CSS variables on :root changed.
+
+ +
VII

A Second User

A one-line login plugin, a client host of Ada's, and why a host must load before it asks.

+

Ada joins with a name. The kit ships an ephemeral login as a facet (POST /_chord/login { name } signs in as { kind: "user", id, name }, no account stored); the API's server module exports it for the app's principal type:

+ +
/** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */
+export const login = ephemeralLoginFacet<TodoPrincipal>();
+

and the app adds it as a single-file plugin, a file named <name>.<kind>.ts at the root's top:

+ +
// The kit's pick-a-name login, added to the app as a single-file plugin: a server facet, one line.
+
+import { login } from "@todo/app/v1/server";
+
+export default login;
+

Step 7 does what Ada's page does: logs in, then connects a client host of her own over the links endpoint with her +cookie:

+ +
const login = await fetch(`${url}/_chord/login`, {
+	method: "POST",
+	headers: { "content-type": "application/json" },
+	body: JSON.stringify({ name: "ada" }),
+});
+const adaCookie = login.headers.get("set-cookie")!.split(";")[0]!;
+const ada = createHost<TodoPrincipal>({ onError: (error) => console.error("ada:", error.kind) });
+try {
+	ada.hold(Todos, { list: "groceries" }, context);
+} catch (error) {
+	console.log("7. ada's hold before load:", (error as Error).message);
+}
+await ada.load([], context);
+const socket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie: adaCookie } });
+ada.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
+const adas = await ada.get(Todos, { list: "groceries" }, context);
+await adas.add("coffee", context);
+console.log("   ada's variant:", adas.variant);
+if (adas.variant === "member") {
+	// @ts-expect-error `clear` is the owner's: TypeScript refuses it, and so does the worker.
+	const clearing: Promise<null> = adas.clear(context);
+	await clearing.catch((error: ChordError) => console.log("   ada's clear:", `${error.code}/${error.reason}`));
+}
+await until(() => groceries.items.value.some(({ by }) => by === "ada"));
+console.log("   the server's reference sees:", show(groceries.items.value.slice(-1)));
+ +
7. ada's hold before load: todo.todos: the host has not loaded; call host.load first (a host with nothing to load calls load([]))
+   ada's variant: member
+   ada's clear: invalid/member
+   the server's reference sees: [ ] coffee (ada)
+

Trap: load before use

+

Ada's first hold threw at once: a host serves and binds nothing before load. Requests made before it would wait +for a publication nobody started, so Chord refuses them with not_ready ("call host.load first"); a client with no +plugins of its own calls load([]) (design §3). startPage, the launcher and the kit do it for you; code that makes +a host itself must not forget.

+

Ada got the member variant; her clear is refused twice, by TypeScript (the @ts-expect-error) and by the worker +(invalid/member), because what a reference offers is decided by the provider's variant, not by the caller's code. +Her todo is hers: coffee (ada).

+
+ +
VIII

Changing Code

An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.

+

Ada typed " tea " with spaces, and an empty add should do nothing. The fix is in the lists plugin's worker facet. +Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it:

+ +
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
+const AFTER = `add: async (text: string, ctx: Ctx) => {
+				if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
+				return null;
+			},`;
+const workerFile = join(root, "lists", "worker.ts");
+await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
+await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
+const applied = await app.applyChanges(context);
+console.log("8. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
+for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
+	const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : [];
+	console.log(`   on ${host.host}:`, [...new Set(fates)].join(", "));
+}
+await adas.add("  tea  ", context);
+await until(() => groceries.items.value.at(-1)?.text === "tea");
+console.log("   ada adds '  tea  ':", show(groceries.items.value.slice(-1)));
+const count = groceries.items.value.length;
+await adas.add(" ", context);
+console.log("   ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added");
+ +
8. lists changed, rolled out to: server, list, web, tui
+   on list|groceries: todo.store restarted, todo.todos(groceries) parked
+   ada adds '  tea  ': [ ] tea (ada)
+   ada adds ' ': ignored
+

The watcher marked @todo/lists dirty when the file changed; applyChanges rebuilt it (one esbuild call per facet) +and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording +self to see what they declare), commits it in one step, and gives each object of the changed plugin its fate +(design §21):

+ + + + + + + + + + + + + + + + + + + + + + + +
The objectFate
opens no natives, and neither does its new factoryswapped in place: new methods, the same documents; running calls finish on the old code
opens nativesrestarted: calls wait, running ones finish, natives close and open again with the new code
its new code waits for a dependencyparked until the dependency is live
its type was removedended; its holders wait until something serves it again
+

The store opens a native (the window's first read), so it restarted and read its window again from the table; the +views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of +loading, and her next add was trimmed. Had the edit broken the plugin, that plugin alone would have kept its old code +and the report would name the error.

+

A contract that changes shape needs a new version; old consumers keep working when the provider maps their version +(accepts), otherwise they wait with contract_mismatch until they reload. A stored document or table that changes +shape bumps its own version with a migration. Contract versions protect callers, storage versions protect data.

+

On the page

+

An edit of a web facet applies in place too: the page fetches the one changed script and reloads its host, with no +page reload. screenshots.ts adds a count of open todos to the panel while half a todo is typed:

+ +
const BEFORE = `h(
+			"ul",`;
+ +
wrote docs/tutorial/screens/page.png
+wrote docs/tutorial/screens/harbor.png
+wrote docs/tutorial/screens/edited.png
+typed "oat mi"; after the edit: ""
+the page did not reload: true
+

The page after the edit

+
Figure 3. After the edit of the web facet, applied in place: the count appears; the half-typed "oat mi" is gone, because the panel's component is new code and React mounted it anew.
+

The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel's +own React state did not: a new component function is a new component to React.

+
+ +
IX

Moving a List

Export a worker's data while it runs, import it under another address.

+

A list's data is what its worker created: its store and its files. Step 9 exports the groceries list while its worker +runs and imports it as a new list:

+ +
const archive = join(base, "groceries.tar");
+await app.exportData(List({ id: "groceries" }), archive, context);
+console.log("9. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
+await app.importData(archive, List({ id: "picnic" }), context);
+const picnic = await app.host.get(Todos, { list: "picnic" }, context);
+console.log("   imported as picnic:", show(picnic.items.value));
+picnic.release();
+await app.importData(archive, List({ id: "groceries" }), context).catch((error: Error) => {
+	console.log("   importing over groceries:", error.message.replace(base, "…"));
+});
+ +
9. exported groceries: an archive
+   imported as picnic: [x] milk (owner), [x] eggs (owner), [ ] bread (server), [ ] butter (owner), [ ] coffee (ada), [ ] tea (ada)
+   importing over groceries: importData: list|groceries has data (its worker runs)
+

exportData stops nothing: the running worker writes its own store at one consistent point (the store's export holds +its commit queue while it reads) and its files, as one archive with a manifest naming the app. With no worker running, +the server takes the worker's process lock and reads the store itself. importData refuses an address that has data; +replacing a list is a delete, then an import (decision "App kit naming and export"). Registering an imported list in +an index of the app's, if it had one, is the app's.

+
+ +
X

Testing One Plugin

testPlugin builds a plugin as production does and loads it alone on a host of its own.

+

The steps above test the whole app. Most rules are checked faster on one plugin. testPlugin builds the plugin as +production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and +loads it on a new host with an in-memory store, here at a list's address so it may serve Todos:

+ +
	let t: TestedPlugin<TodoPrincipal>;
+	beforeEach(async () => {
+		t = await testPlugin<TodoPrincipal>(
+			fileURLToPath(new URL("./", import.meta.url)),
+			{
+				kind: "worker",
+				api: { name: "@todo/app", versions: ["v1"] },
+				modules: { "@todo/app/v1": v1 },
+				address: v1.List({ id: "groceries" }),
+				principal: { kind: "worker", list: "list|groceries" },
+			},
+			context,
+		);
+	});
+	afterEach(() => t.close(context));
+

Each test runs code as a principal of its choice with t.root:

+ +
	it("lets the owner clear the done todos", async () => {
+		await t.root(
+			async (ctx) => {
+				const todos = await ctx.get(v1.Todos, { list: "groceries" });
+				if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
+				await todos.add("milk", ctx);
+				await todos.add("eggs", ctx);
+				await todos.tick({ id: 1, done: true }, ctx);
+				await todos.clear(ctx);
+				expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]);
+			},
+			context,
+			{ principal: OWNER },
+		);
+		expect(t.unserved()).toEqual([]);
+

What the plugin uses and no plugin of the test serves would wait unbound; t.unserved() lists those names, so a test +knows which stub facets to add. Registries it contributes to that nobody serves are collected (t.contributions(R)). +Run it like any test:

+
node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
+

The test imports Todos and List from the API module by path. It could not import a contract from a plugin's own +contract.ts: that file imports @todo/app/v1, a name only plugin builds resolve. A test of such a contract declares +it again (contracts are data: the same name, version and schema bind), as docs/plugin-authoring.md shows; this app +keeps the contract it shares in its API instead.

+
+ +
XI

Writing a p2 Plugin

What is different in p2, and one small plugin: a tool for the agent and a chip for the people.

+

p2, the coding agent's server with its web page and terminal, runs on Chord v2 but not on the app kit. It is older than +the kit and keeps its own decisions: its own data directory and SQLite files, its own sign-in, devices and sharing, +its own page and terminal bundles. It uses Chord's components directly: createHost with p2's principals, +createWorkers for one worker per session, createPlugins to find, build, watch and roll out its plugins. What a +plugin author sees is the same model: contracts, factories, registries, facets per host kind.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
The todo app (kit)p2
host kindsServer, List({ id })Server, Session({ id }) (serverHost, sessionHost)
principalsserver, worker, owner, userowner (with access), guest (with sessions and revision), worker, server, host
API@todo/app/v1, /server, /web, /tui@p2/app/v1 (browser-safe), /node (server and worker), /server, /worker, /web, /tui
other host packagesReact for the webpi-ai, pi-durable, pi-schema, pi-env, pi-tui, ws, yaml: plugins import them directly
pluginsplugins/<name>/, package.json named @todo/<name>apps/p2/plugins/<name>/, package.json named <name>
a Chord storethe kit's, per host: persisted documents and tablesnone: durable data is Durable's (documents with a conversation) or p2's own files
page and terminalkit surfaces, Panels and Screensp2's bundles; the page's Slots registry, useObject, useReadable
applying changesapp.applyChangesthe owner's Reload button, npm run reload in apps/p2, the reload_plugins tool
+

The scratchpad plugin

+

The example gives the agent a scratch_note tool and shows the session's notes to everyone who may see the session. +Its contract places one object per session on the session's worker:

+ +
export const Scratchpad = remote("p2.scratchpad", {
+	version: 1,
+	params: { session: param.str() },
+	hostAddress: sessionHost,
+	variants: { reader: { state: { notes: arr(str()) } } },
+});
+

The worker facet holds the notes in a worker-wide store (a local object with empty params, as in chapter II). It gives +the agent its tool through p2's Extensions registry, a keyed registry of Durable extensions, and serves the view to +whoever canView the session:

+ +
const Notes = defineState("p2.scratchpad.notes", {
+	schema: obj({ items: arr(str()) }),
+	version: 1,
+	initial: () => ({ items: [] }),
+});
+ +
export default defineFacet((host) => {
+	host.serve(ScratchpadStore, (_params, self) => {
+		const notes = self.state(Notes);
+		self.contribute(Extensions, {
+			scratchpad: defineExtension({
+				name: "scratchpad",
+				tools: [
+					defineTool({
+						name: "scratch_note",
+						description:
+							"Keep a short note for this session (a decision, an open question). Everyone viewing the session sees the notes.",
+						parameters: obj({ text: str({ minLength: 1, maxLength: 500 }) }),
+						replay: "unsafe",
+						execute: async ({ text }, _api, context) => {
+							await notes.change((draft) => {
+								draft.items.push(text);
+							}, context);
+							return {
+								output: [{ type: "text" as const, text: `Noted (${notes.value.items.length} in all).` }],
+							};
+						},
+					}),
+				],
+			}),
+		});
+		return { notes: self.derive([notes], ([value]) => value.items) };
+	});
+	host.serve(Scratchpad, ({ session }, principal, self) => {
+		if (!canView(principal, session)) throw forbidden("viewers only");
+		const store = self.get(ScratchpadStore, {});
+		return { variant: "reader", members: { notes: self.derive([store.notes], ([notes]) => notes) } };
+	});
+});
+

The notes are an in-memory document: p2 opens no Chord store, so persist: true is not available, and the notes last +as long as the session's worker. Data that must outlive it goes into a Durable document (p2's chat-mode keeps its switch +that way). The tool runs inside Durable with a Durable Context, which is Chord's own context type, so the store's +handle takes it and the write is awaited. The page gets a chip in the composer's bar through Slots:

+ +
function NotesChip() {
+	const { session } = useRoute();
+	const pad = useObject(Scratchpad, session === undefined ? undefined : { session });
+	const notes = useReadable(pad?.notes) ?? [];
+	if (notes.length === 0) return null;
+	return (
+		<span className="chip" title={notes.join("\n")}>
+			Notes · {notes.length}
+		</span>
+	);
+}
+
+export default defineFacet((_host, self) => self.contribute(Slots, { "composer:scratchpad": NotesChip }));
+

Testing it in p2

+

p2's tests load facets on a bare host with testHost (test/p2/helpers.ts), stub what they need, and reach the host +as any principal over a memory link with fixture.client(principal):

+ +
	it("keeps the agent's notes and shows them to the session's viewers", async () => {
+		const extensions = await fixture.host.registry(Extensions, BACKGROUND_CONTEXT);
+		const tool = extensions.value.get("scratchpad")!.tools![0]!;
+		await tool.execute({ text: "ship on friday" }, undefined as never, BACKGROUND_CONTEXT);
+		const pad = await fixture.client(OWNER).get(Scratchpad, { session: "s1" }, BACKGROUND_CONTEXT);
+		assert.deepEqual(await until(pad.notes, (notes) => notes?.length === 1), ["ship on friday"]);
+	});
+

Run from apps/p2 with node --import ./src/source.ts --test test/p2/scratchpad.test.ts. Load the plugin into a running +p2 with the owner's Reload button or npm run reload -- scratchpad; a new plugin directory is picked up by the same +reload. The trap of chapter II applies with p2's names: a server method that must reach a session's worker as the +server runs in a root of the server's own (serverOf(self)().root), not through its caller's ctx.get.

+
+ +
∴

Traps and Open Questions

The four traps once more, and what in Chord v2 was hard to explain while writing this.

+

The traps. Load before use: a host binds nothing before load; a client with nothing to load calls load([]). A +reference acts as whoever made it: ctx.get as the caller, host.get and a local object's self.get as the host. +Await what you start: a write started with a context that ends is cancelled and reported transaction_cancelled. A +watch starts at the current value: read handle.value, then start; the listener gets changes only.

+

Hard to explain, found while writing the example. Each is a candidate for a fix in Chord rather than a paragraph in +a tutorial.

+
    +
  • Integer table keys do not type-check. KeyOf in src/store/table.ts still tests TypeBox's +"~kind": "Integer", which pi-schema's int() lacks, so the key of an int key column is typed string: +tx.get(items, { id }) with a number fails tsc. The store here uses updateWhere with eq instead of +tx.update.
  • +
  • A one-time read at activation is a native. The live window over a table needs a first read before the object is +live; self.open is the only place, so the store counts as holding a native and restarts at every reload of its +plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view +repeats this pattern (example 59's chat does too).
  • +
  • A reloaded web plugin loses its own React state. Decision "Slice 11, P4" promises an in-place reload "keeping what +is on screen (scroll, a half-typed message)". The page does not reload, but the changed plugin's components are new +functions, so React mounts them anew and their state (the half-typed todo) is gone.
  • +
  • Plugin contracts are hard to test from outside builds. A test or script cannot import a plugin's +contract.ts (it imports the app's API by a package name only builds resolve), so contracts are declared twice or +moved into the API.
  • +
  • No public invalid. Plugins throw PluginError with codes of their own; refusing bad arguments with Chord's +invalid/args, as the built-in checks do, is not open to them.
  • +
+
+ +
A

The App's Files

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileKindWhat it is
src/principals.tsbasethe principal type
src/api/v1/index.tsbase, every kind@todo/app/v1: Chord's part, defineFacet, List, Item, Todos
src/api/v1/server.tsbase, serverHttp, the ephemeral login
src/api/v1/web.tsbase, webPanels, useHold, useWatch
src/api/v1/tui.tsbase, tuiScreens
start.ts, worker.tsbasestartServer, startWorker
src/web/main.ts, src/web/style.cssbase, web entrystartPage, the panels
src/tui/main.tsbase, terminal entrystartTerminalRuntime, the screens
plugins/lists/pluginworker (table, store, views), server (routes), web (panel), tui (screen), its test
plugins/login.server.tssingle-file pluginthe kit's ephemeral login
later/harbor/plugin, added in step 6the Harbor theme for pages and terminals
main.ts, screenshots.ts, tutorial.test.tsexamplethe steps, the screenshots, the checks
+
+ +
B

Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CallChapterWhat it does
startServer<P>({ name, dataDir, root, listen, api, kinds, principal, workers, web, tui, onError }, ctx)Ithe app's server, with the kit
startWorker({ api, kinds, onError })Ia worker's entry
app.ownerLink(), app.ownerTokenFileI, IIIthe owner's sign-in, for a browser and for local clients
hostKind(name, params), remote(name, { version, params, hostAddress, variants }), local(name, { version, params, members })IIhost kinds and contracts
host.serve(Type, factory); factory self: table, writable, open, derive, tx, get, contributeIIan object's parts
host.root(fn, ctx, { principal }), ctx.get(Type, params)IIcode outside objects, as a principal
self.contribute(Http, { routes })IIIroutes, denied by default
startPage, HostProvider, useHold, useWatch, useRegistryIVthe page
startTerminalRuntime, TuiScope, startTerminalVthe terminal
self.contribute(Themes, theme)VIa theme
ephemeralLoginFacet<P>(), host.load([], ctx), host.connect(link, { principal }, ctx)VIIa second user's client
app.plugins.state, app.applyChanges(ctx)VI, VIIImarks and rollouts
app.exportData(address, path, ctx), app.importData(path, address, ctx)IXmoving a worker's data
testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)Xone plugin alone
+
+ +

Together · first edition · 2026-11-03 · Chord v2 slices 1-13, the app kit, example 61

+
+ + diff --git a/packages/chord/docs/tutorial/screens/edited.png b/packages/chord/docs/tutorial/screens/edited.png new file mode 100644 index 0000000000000000000000000000000000000000..b04a94b9b434c074e49f482cced74876dff27a06 GIT binary patch literal 14440 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 literal 0 HcmV?d00001 diff --git a/packages/chord/docs/tutorial/screens/harbor.png b/packages/chord/docs/tutorial/screens/harbor.png new file mode 100644 index 0000000000000000000000000000000000000000..c42171b8ef8c5ad8afef142417c92f321a2d5620 GIT binary patch literal 13613 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 self.contribute(Themes, harbor)); diff --git a/packages/chord/test/examples/61-tutorial/later/harbor/web.ts b/packages/chord/test/examples/61-tutorial/later/harbor/web.ts new file mode 100644 index 000000000..62e2e1e81 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/later/harbor/web.ts @@ -0,0 +1,6 @@ +// The Harbor theme on pages. + +import { defineFacet, Themes } from "@todo/app/v1"; +import { harbor } from "./theme.ts"; + +export default defineFacet((_host, self) => self.contribute(Themes, harbor)); diff --git a/packages/chord/test/examples/61-tutorial/main.ts b/packages/chord/test/examples/61-tutorial/main.ts new file mode 100644 index 000000000..20b69b895 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/main.ts @@ -0,0 +1,249 @@ +// The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each +// numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker, +// routes, the page, the terminal, a theme plugin added while the app runs, a second user, an edit applied in place, +// and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts` +// shows the page in Chromium. +// Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running): +// node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts +import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { WebSocket } from "ws"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts"; +import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts"; +import { type Item, List, Todos } from "./src/api/v1/index.ts"; +import type { TodoPrincipal } from "./src/principals.ts"; +import { startTodo, todoDirectories } from "./start.ts"; + +const { base, dataDir, root } = await todoDirectories(); + +// 1. Start the app: the kit makes the data directory, the server's store and host, HTTP and the owner link. Nobody is +// signed in yet, so the page answers 401. The owner opens the link: its page posts the token from the URL's fragment +// and gets a session cookie. +const app = await startTodo(dataDir, root, context); +const link = new URL(app.ownerLink()); +const url = link.origin; +console.log("1. owner link:", `http://127.0.0.1:${link.pathname}#`); +console.log(" data directory:", (await readdir(dataDir)).sort().join(", ")); +console.log(" GET / signed out:", (await fetch(url)).status); +const signIn = await fetch(`${url}/_chord/owner`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: link.hash.slice(1) }), +}); +const cookie = signIn.headers.get("set-cookie")!.split(";")[0]!; +console.log(" the owner signs in:", signIn.status, cookie.replace(/=.*/, "=…")); +console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).status); + +// 2. A list on its own worker. Code acting as the owner gets the groceries list's `Todos`: the request goes to the +// list's address, and the first one starts its worker. The same call in a root of the server acts as the server. +const OWNER = { kind: "owner" } as const; +await app.host.root( + async (ctx) => { + const todos = await ctx.get(Todos, { list: "groceries" }); + await todos.add("milk", ctx); + await todos.add("eggs", ctx); + await todos.tick({ id: 1, done: true }, ctx); + await until(() => todos.items.value[0]?.done === true); + console.log("2. as the owner:", todos.variant, "|", show(todos.items.value)); + }, + context, + { principal: OWNER }, +); +await app.host.root(async (ctx) => { + const todos = await ctx.get(Todos, { list: "groceries" }); + await todos.add("bread", ctx); + await until(() => todos.items.value.length === 3); + console.log(" as the server:", todos.variant, "|", show(todos.items.value.slice(-1))); +}, context); +const workers = join(dataDir, "workers", "list"); +const [groceriesDir] = await readdir(workers); +console.log(" workers:", Object.keys(app.workers.get("list")!.state.value).join(", ")); +console.log(` workers/list/${groceriesDir}:`, (await readdir(join(workers, groceriesDir!))).sort().join(", ")); + +// 3. Routes. A command-line client on this machine sends the owner token file as a bearer token; the route's +// `ctx.get` acts as the request's principal, so the todo is the owner's. +const token = (await readFile(app.ownerTokenFile, "utf8")).trim(); +const cli = { authorization: `Bearer ${token}` }; +const posted = await fetch(`${url}/api/lists/groceries`, { + method: "POST", + headers: { ...cli, "content-type": "application/json" }, + body: JSON.stringify("butter"), +}); +console.log("3. POST /api/lists/groceries:", await posted.json()); +const listed = (await (await fetch(`${url}/api/lists/groceries`, { headers: cli })).json()) as Item[]; +console.log(" GET /api/lists/groceries:", show(listed)); +console.log(" GET without a token:", (await fetch(`${url}/api/lists/groceries`)).status); + +// 4. The page: the app's template with the base bundle, the plugins' combined script and their stylesheets. A watch +// starts at the current value; its listener gets only what changes after. +const html = await (await fetch(url, { headers: cli })).text(); +console.log("4. the page loads:"); +for (const [, asset] of html.matchAll(/(?:src|href)="([^"]+)"/g)) console.log(` ${hashless(asset!)}`); +const groceries = await app.host.get(Todos, { list: "groceries" }, context); +const watch = await groceries.items.watch(context); +console.log(" watch.value:", done(watch.value), "done"); +const changed = Promise.withResolvers(); +watch.start(async (call) => { + if (call.kind === "value") changed.resolve(call.value); +}); +await groceries.tick({ id: 2, done: true }, context); +console.log(" the listener's first call:", done(await changed.promise), "done"); +await watch.stop(); + +// 5. The terminal client (Node 26.5.1 or later): the launcher signs in with the owner token file, downloads the +// terminal's base bundle and the plugins' terminal scripts and runs them in its sandbox, here on a scripted terminal. +const frames: string[] = []; +let terminal: RunningTerminal | undefined; +if (sandboxAvailable()) { + const output = Object.assign(new PassThrough(), { columns: 60, rows: 12 }); + output.on("data", (chunk: Buffer) => frames.push(chunk.toString())); + terminal = await startTerminal({ + url, + credentials: { kind: "file", path: app.ownerTokenFile }, + input: new PassThrough(), + output, + colors: "truecolor", + }); + await until(() => screen(frames).includes("butter")); + console.log("5. the terminal shows:"); + for (const line of screen(frames).trim().split("\r\n")) console.log(` ${line}`); +} else console.log("5. (the terminal client needs Node 26.5.1 or later for its sandbox)"); + +// 6. A theme plugin added while the app runs: a new directory in the plugins root marks the set changed, and applying +// the changes builds it and rolls it out. Pages and terminals restyle in place. +await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true }); +await until(() => app.plugins.state.value.set); +const added = await app.applyChanges(context); +console.log("6. harbor added, rolled out to:", added.steps.map(({ name }) => name).join(", ")); +const scripts = await app.host.get(WebScripts, {}, context); +console.log(" the page's plugins:", scripts.set.value.plugins.map(({ name }) => name).join(", ")); +scripts.release(); +if (terminal !== undefined) { + await until(() => frames.join("").includes("\x1b[38;2;242;166;90mGroceries")); + console.log( + " the terminal redraws its title in Harbor's accent:", + JSON.stringify("\x1b[38;2;242;166;90mGroceries"), + ); +} + +// 7. A second user. Ada picks a name (the kit's ephemeral login) and connects a client host of her own over the links +// endpoint, as a page does. A host serves nothing before `load`: a client with nothing to load calls `load([])`. +const login = await fetch(`${url}/_chord/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "ada" }), +}); +const adaCookie = login.headers.get("set-cookie")!.split(";")[0]!; +const ada = createHost({ onError: (error) => console.error("ada:", error.kind) }); +try { + ada.hold(Todos, { list: "groceries" }, context); +} catch (error) { + console.log("7. ada's hold before load:", (error as Error).message); +} +await ada.load([], context); +const socket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie: adaCookie } }); +ada.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context); +const adas = await ada.get(Todos, { list: "groceries" }, context); +await adas.add("coffee", context); +console.log(" ada's variant:", adas.variant); +if (adas.variant === "member") { + // @ts-expect-error `clear` is the owner's: TypeScript refuses it, and so does the worker. + const clearing: Promise = adas.clear(context); + await clearing.catch((error: ChordError) => console.log(" ada's clear:", `${error.code}/${error.reason}`)); +} +await until(() => groceries.items.value.some(({ by }) => by === "ada")); +console.log(" the server's reference sees:", show(groceries.items.value.slice(-1))); + +// 8. An edit applied in place: the lists plugin's worker facet trims a todo's text and ignores an empty one. The +// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and +// the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts +// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's +// reference follows them. +// The edit: `add` in the `Todos` view, before and after. +const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"; +const AFTER = `add: async (text: string, ctx: Ctx) => { + if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx); + return null; + },`; +const workerFile = join(root, "lists", "worker.ts"); +await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER)); +await until(() => app.plugins.state.value.dirty.includes("@todo/lists")); +const applied = await app.applyChanges(context); +console.log("8. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", ")); +for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) { + const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : []; + console.log(` on ${host.host}:`, [...new Set(fates)].join(", ")); +} +await adas.add(" tea ", context); +await until(() => groceries.items.value.at(-1)?.text === "tea"); +console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1))); +const count = groceries.items.value.length; +await adas.add(" ", context); +console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added"); + +// 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing +// the archive makes a new list with the same todos. An address that has data is never overwritten. +const archive = join(base, "groceries.tar"); +await app.exportData(List({ id: "groceries" }), archive, context); +console.log("9. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing"); +await app.importData(archive, List({ id: "picnic" }), context); +const picnic = await app.host.get(Todos, { list: "picnic" }, context); +console.log(" imported as picnic:", show(picnic.items.value)); +picnic.release(); +await app.importData(archive, List({ id: "groceries" }), context).catch((error: Error) => { + console.log(" importing over groceries:", error.message.replace(base, "…")); +}); + +if (process.argv.includes("--serve")) { + console.log(`Open ${app.ownerLink()} (Ctrl-C to stop); terminal: 57-tui's terminal.ts with ${app.ownerTokenFile}`); +} else { + await terminal?.stop(); + groceries.release(); + adas.release(); + await ada.close(context); + await app.close(context); + await rm(base, { recursive: true, force: true }); + console.log("10. closed"); +} + +/** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */ +function show(items: readonly Item[]): string { + return items.map((item) => `${item.done ? "[x]" : "[ ]"} ${item.text} (${item.by})`).join(", "); +} + +/** How many of `items` are done. */ +function done(items: readonly Item[]): number { + return items.filter((item) => item.done).length; +} + +/** Waits until `check` holds. */ +async function until(check: () => boolean): Promise { + while (!check()) await new Promise((resolve) => setTimeout(resolve, 10)); +} + +/** An asset's URL with its content hash masked, so the output is the same at every run. */ +function hashless(asset: string): string { + return asset.replace(/\.[0-9a-f]{8,}\./, ".."); +} + +/** The terminal's last frame as text (its colours stripped). */ +function screen(written: readonly string[]): string { + return written + .join("") + .split("\x1b[2J\x1b[H") + .at(-1)! + .replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** Whether this Node can run the terminal's sandbox. */ +function sandboxAvailable(): boolean { + try { + requireSandboxNode(); + return true; + } catch { + return false; + } +} diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/lists.css b/packages/chord/test/examples/61-tutorial/plugins/lists/lists.css new file mode 100644 index 000000000..a2078475c --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/lists.css @@ -0,0 +1,9 @@ +/* The lists panel: the active theme's colours (the kit writes them on :root), class names prefixed with the plugin's. */ +.lists-panel ul { list-style: none; padding: 0; margin: 0 0 0.75rem; } +.lists-panel li { padding: 0.35rem 0; border-bottom: 1px solid var(--muted); cursor: pointer; } +.lists-done { color: var(--muted); text-decoration: line-through; } +.lists-by { float: right; color: var(--muted); font-size: 0.8em; } +.lists-panel input { width: 100%; box-sizing: border-box; padding: 0.4rem; background: var(--bg); color: var(--fg); + border: 1px solid var(--accent); border-radius: var(--radius, 0); } +.lists-panel button { margin-top: 0.5rem; background: none; color: var(--accent); border: 1px solid var(--accent); + border-radius: var(--radius, 0); padding: 0.3rem 0.6rem; } diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/package.json b/packages/chord/test/examples/61-tutorial/plugins/lists/package.json new file mode 100644 index 000000000..b2d5d9a80 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/package.json @@ -0,0 +1,5 @@ +{ + "name": "@todo/lists", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts new file mode 100644 index 000000000..784346692 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts @@ -0,0 +1,21 @@ +// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the +// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them. + +import { defineFacet, Todos } from "@todo/app/v1"; +import { Http } from "@todo/app/v1/server"; + +export default defineFacet((_host, self) => { + self.contribute(Http, { + routes: { + "GET /api/lists/:list": async (request, ctx) => { + const todos = await ctx.get(Todos, { list: request.params.list! }); + return { kind: "json", value: todos.items.value }; + }, + "POST /api/lists/:list": async (request, ctx) => { + const todos = await ctx.get(Todos, { list: request.params.list! }); + await todos.add(String(await request.json()), ctx); + return { kind: "json", value: "added" }; + }, + }, + }); +}); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts new file mode 100644 index 000000000..957fe472d --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts @@ -0,0 +1,17 @@ +// The lists plugin in the terminal: the groceries list's todos, live; done ones muted. + +import { defineFacet, Todos } from "@todo/app/v1"; +import { Screens } from "@todo/app/v1/tui"; + +export default defineFacet((_host, self) => + self.contribute(Screens, { + title: "Groceries", + open: (scope, theme) => { + const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items); + return () => + (items() ?? []).map((item) => + item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`, + ); + }, + }), +); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts new file mode 100644 index 000000000..ec2f3dadc --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts @@ -0,0 +1,53 @@ +// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for +// the owner "Clear done". The list is the page's `#hash`, `groceries` without one. + +import { BACKGROUND_CONTEXT as context, defineFacet, Todos } from "@todo/app/v1"; +import { Panels, useHold, useWatch } from "@todo/app/v1/web"; +import { createElement as h, useState } from "react"; +import "./lists.css"; + +function TodoList() { + const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" }); + const items = useWatch(todos?.items) ?? []; + const [text, setText] = useState(""); + const add = (event: { preventDefault(): void }) => { + event.preventDefault(); + if (todos === undefined || text.trim() === "") return; + void todos.add(text, context); + setText(""); + }; + return h( + "div", + { className: "lists-panel" }, + h( + "ul", + null, + ...items.map((item) => + h( + "li", + { + key: item.id, + className: item.done ? "lists-done" : undefined, + onClick: () => void todos?.tick({ id: item.id, done: !item.done }, context), + }, + item.text, + h("span", { className: "lists-by" }, item.by), + ), + ), + ), + h( + "form", + { onSubmit: add }, + h("input", { + value: text, + placeholder: "Add a todo", + onChange: (event: { target: { value: string } }) => setText(event.target.value), + }), + ), + todos?.variant === "owner" + ? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done") + : null, + ); +} + +export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList })); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts new file mode 100644 index 000000000..f1126478c --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts @@ -0,0 +1,62 @@ +// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the +// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code +// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it. +// Run from packages/chord: +// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts"; +import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts"; +import * as v1 from "../../src/api/v1/index.ts"; +import type { TodoPrincipal } from "../../src/principals.ts"; + +const ADA = { kind: "user", id: "u1", name: "ada" } as const; +const OWNER = { kind: "owner" } as const; + +describe("the lists plugin on a list's worker", () => { + let t: TestedPlugin; + beforeEach(async () => { + t = await testPlugin( + fileURLToPath(new URL("./", import.meta.url)), + { + kind: "worker", + api: { name: "@todo/app", versions: ["v1"] }, + modules: { "@todo/app/v1": v1 }, + address: v1.List({ id: "groceries" }), + principal: { kind: "worker", list: "list|groceries" }, + }, + context, + ); + }); + afterEach(() => t.close(context)); + + it("adds a todo as the principal that called", async () => { + await t.root( + async (ctx) => { + const todos = await ctx.get(v1.Todos, { list: "groceries" }); + expect(todos.variant).toBe("member"); + await todos.add("coffee", ctx); + expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]); + }, + context, + { principal: ADA }, + ); + }); + + it("lets the owner clear the done todos", async () => { + await t.root( + async (ctx) => { + const todos = await ctx.get(v1.Todos, { list: "groceries" }); + if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`); + await todos.add("milk", ctx); + await todos.add("eggs", ctx); + await todos.tick({ id: 1, done: true }, ctx); + await todos.clear(ctx); + expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]); + }, + context, + { principal: OWNER }, + ); + expect(t.unserved()).toEqual([]); + }); +}); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts new file mode 100644 index 000000000..cec65d57e --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts @@ -0,0 +1,96 @@ +// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and +// read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50 +// todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view +// adds todos as that principal, and gives the owner `clear`. + +import { + arr, + bool, + type Ctx, + defineFacet, + defineTable, + eq, + Item, + int, + local, + method, + num, + obj, + query, + str, + Todos, +} from "@todo/app/v1"; + +/** Every todo of the list, by its number. */ +const Items = defineTable("todo.items", { + version: 1, + columns: { id: int(), text: str(), done: bool(), by: str(), at: num() }, + key: ["id"], +}); + +/** The newest todos first. */ +const NEWEST = query(Items).orderBy((item) => item.id, "desc"); + +/** How many todos the live window holds. */ +const WINDOW = 50; + +/** The list's todos, once per worker: empty params, so it lives as long as the worker and may keep data in its store. */ +const Store = local("todo.store", { + version: 1, + params: {}, + members: { + items: arr(Item), + add: method(obj({ text: str(), by: str() })), + tick: method(obj({ id: int(), done: bool() })), + clear: method(), + }, +}); + +export default defineFacet((host) => { + host.serve(Store, (_params, self) => { + const items = self.table(Items); + // The newest todos, oldest first, and the next todo's number: read once when the store goes live, then kept here. + const window = self.writable([]); + let next = 1; + self.open( + async (ctx) => { + const { items: newest } = await items.page(NEWEST, WINDOW, ctx); + window.set([...newest].reverse()); + next = (newest[0]?.id ?? 0) + 1; + }, + () => {}, + ); + return { + items: self.derive([window], ([value]) => value), + add: async ({ text, by }, ctx) => { + const item = { id: next++, text, done: false, by, at: Date.now() }; + await self.tx(async (tx) => tx.put(items, item), ctx); + // In number order: two adds may commit in either order. + window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW)); + return null; + }, + tick: async ({ id, done }, ctx) => { + await self.tx(async (tx) => tx.updateWhere(items, (item) => eq(item.id, id), { done }), ctx); + window.set(window.value.map((item) => (item.id === id ? { ...item, done } : item))); + return null; + }, + clear: async (ctx) => { + await self.tx(async (tx) => tx.deleteWhere(items, (item) => eq(item.done, true)), ctx); + window.set(window.value.filter((item) => !item.done)); + return null; + }, + }; + }); + host.serve(Todos, (_params, principal, self) => { + const store = self.get(Store, {}); + // Who adds a todo comes from the principal the link stamped, never from the arguments. + const by = principal.kind === "user" ? principal.name : principal.kind; + const members = { + items: self.derive([store.items], ([items]) => items), + add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx), + tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx), + }; + if (principal.kind !== "owner") return { variant: "member", members }; + return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } }; + }); +}); diff --git a/packages/chord/test/examples/61-tutorial/plugins/login.server.ts b/packages/chord/test/examples/61-tutorial/plugins/login.server.ts new file mode 100644 index 000000000..6b7cbe6a1 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/login.server.ts @@ -0,0 +1,5 @@ +// The kit's pick-a-name login, added to the app as a single-file plugin: a server facet, one line. + +import { login } from "@todo/app/v1/server"; + +export default login; diff --git a/packages/chord/test/examples/61-tutorial/screenshots.ts b/packages/chord/test/examples/61-tutorial/screenshots.ts new file mode 100644 index 000000000..869e1b87c --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/screenshots.ts @@ -0,0 +1,95 @@ +// Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner +// signed in with the owner link, the page in the Harbor theme added while it runs, and the page after an edit of the +// lists plugin's web facet applied in place. +// Run from packages/chord, with Chromium (`CHROMIUM_BIN`, else `chromium-docker` on the `PATH`): +// node --conditions=source --experimental-strip-types test/examples/61-tutorial/screenshots.ts docs/tutorial/screens +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { Browser, CHROMIUM } from "../../node/web/browser.ts"; +import { Todos } from "./src/api/v1/index.ts"; +import { startTodo, todoDirectories } from "./start.ts"; + +const out = resolve(process.argv[2] ?? "screens"); +if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN"); +await mkdir(out, { recursive: true }); +const { base, dataDir, root } = await todoDirectories(); +const app = await startTodo(dataDir, root, context); + +// The list as the tutorial leaves it after chapter 7: the owner's todos, one by the server, one by Ada. +const as = async (principal: Parameters[2], texts: readonly string[]): Promise => { + await app.host.root( + async (ctx) => { + const todos = await ctx.get(Todos, { list: "groceries" }); + for (const text of texts) await todos.add(text, ctx); + }, + context, + principal, + ); +}; +await as({ principal: { kind: "owner" } }, ["milk", "eggs"]); +await as(undefined, ["bread"]); +await as({ principal: { kind: "owner" } }, ["butter"]); +await as({ principal: { kind: "user", id: "u1", name: "ada" } }, ["coffee"]); +await app.host.root( + async (ctx) => (await ctx.get(Todos, { list: "groceries" })).tick({ id: 1, done: true }, ctx), + context, + { principal: { kind: "owner" } }, +); + +/** The edit of step 3: a line counting the open todos above the list. */ +const BEFORE = `h( + "ul",`; +const AFTER = `h("p", { className: "lists-open" }, \`\${items.filter((item) => !item.done).length} open\`), + h( + "ul",`; + +const browser = await Browser.start(); +const shoot = async (name: string): Promise => { + await new Promise((done) => setTimeout(done, 300)); + const { data } = (await browser.call("Page.captureScreenshot", { format: "png" })) as { readonly data: string }; + await writeFile(join(out, name), Buffer.from(data, "base64")); + console.log(`wrote ${join(out, name)}`); +}; +try { + await browser.call("Emulation.setDeviceMetricsOverride", { + width: 560, + height: 430, + deviceScaleFactor: 1, + mobile: false, + }); + + // 1. The owner opens the owner link: the kit's page signs them in and goes to the app's page. + await browser.open(app.ownerLink()); + await browser.wait("document.querySelectorAll('.lists-panel li').length === 5"); + await shoot("page.png"); + + // 2. The Harbor theme plugin added and applied; the browser prefers dark, so the page takes Harbor. + await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true }); + while (!app.plugins.state.value.set) await new Promise((done) => setTimeout(done, 10)); + await app.applyChanges(context); + await browser.call("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "dark" }] }); + await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'"); + await shoot("harbor.png"); + + // 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos. + await browser.evaluate("document.querySelector('.lists-panel input').focus()"); + await browser.type("oat mi"); + // A mark on the page's window: a page that reloaded would have lost it. + await browser.evaluate("window.marked = true"); + const typed = await browser.evaluate("document.querySelector('.lists-panel input').value"); + const file = join(root, "lists", "web.ts"); + await writeFile(file, (await readFile(file, "utf8")).replace(BEFORE, AFTER)); + while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10)); + await app.applyChanges(context); + await browser.wait("document.querySelector('.lists-open') !== null"); + await shoot("edited.png"); + const after = await browser.evaluate("document.querySelector('.lists-panel input').value"); + console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`); + console.log("the page did not reload:", await browser.evaluate("window.marked === true")); +} finally { + await browser.close(); + await app.close(context); + await rm(base, { recursive: true, force: true }); +} diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts new file mode 100644 index 000000000..6eaff52a9 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts @@ -0,0 +1,74 @@ +// The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet` +// typed with the app's principals, the app's host kind (a list, one worker each) and its contract for a list's todos. +// Plugins import this module, never Chord: their builds leave it out and each process passes its own instance in. + +import { + arr, + bool, + defineFacet as chordFacet, + type FacetHost, + type FacetSelf, + hostKind, + int, + method, + num, + obj, + remote, + type Static, + str, +} from "../../../../../../src/index.ts"; +import type { TodoPrincipal } from "../../principals.ts"; + +export { BACKGROUND_CONTEXT } from "../../../../../../src/context/index.ts"; +export { + arr, + bool, + type Ctx, + defineTable, + eq, + int, + local, + method, + num, + obj, + PluginError, + query, + str, + type Theme, + Themes, +} from "../../../../../../src/index.ts"; +export type { TodoPrincipal } from "../../principals.ts"; + +/** Defines a facet of the todo app: its factories get the app's principals. */ +export function defineFacet(setup: (host: FacetHost, self: FacetSelf) => void) { + return chordFacet(setup); +} + +/** A todo list: a worker process of its own, with its own store. */ +export const List = hostKind("list", { id: str() }); + +/** A todo: its number in the list, its text, whether it is done, who added it and when. */ +export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() }); + +/** A todo (see `Item`). */ +export type Item = Static; + +/** + * A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one + * off (or on again); the owner also clears the done ones. + */ +export const Todos = remote("todo.todos", { + version: 1, + params: { list: str() }, + hostAddress: ({ list }) => List({ id: list }), + variants: { + member: { + state: { items: arr(Item) }, + methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) }, + }, + owner: { + state: { items: arr(Item) }, + methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() }, + }, + }, +}); diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts new file mode 100644 index 000000000..ce3b171d9 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts @@ -0,0 +1,10 @@ +// The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, and the +// kit's pick-a-name login as a facet a one-line plugin exports. + +import { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts"; +import type { TodoPrincipal } from "../../principals.ts"; + +export { Http } from "../../../../../../src/node/http/index.ts"; + +/** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */ +export const login = ephemeralLoginFacet(); diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/tui.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/tui.ts new file mode 100644 index 000000000..84d4d52cd --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/tui.ts @@ -0,0 +1,17 @@ +// The todo app's API v1 for terminal facets (`@todo/app/v1/tui`): the `Screens` registry the terminal draws, one part +// per contributing plugin. + +import { defineRegistry } from "../../../../../../src/index.ts"; +import type { TerminalTheme, TuiScope } from "../../../../../../src/tui/index.ts"; + +/** + * A part of the terminal's screen: its title, and `open`, which holds what the part shows in `scope` (released when the + * terminal closes it) and returns a reader of its lines, coloured by role with `theme`. + */ +export type Screen = { + readonly title: string; + readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[]; +}; + +/** The terminal's parts, in plugin-name order. */ +export const Screens = defineRegistry("todo.screens", { version: 1 }); diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts new file mode 100644 index 000000000..d68103d9f --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts @@ -0,0 +1,13 @@ +// The todo app's API v1 for web facets (`@todo/app/v1/web`): the `Panels` registry the page shows, and Chord's React +// hooks (a plugin imports them from here: Chord itself is not a package plugins may name). + +import type { ComponentType } from "react"; +import { defineRegistry } from "../../../../../../src/index.ts"; + +export { useHold, useWatch } from "../../../../../../src/react/index.ts"; + +/** A panel of the page: its title and its React component. */ +export type Panel = { readonly title: string; readonly Component: ComponentType }; + +/** The page's panels, one per contributing plugin, in plugin-name order. */ +export const Panels = defineRegistry("todo.panels", { version: 1 }); diff --git a/packages/chord/test/examples/61-tutorial/src/principals.ts b/packages/chord/test/examples/61-tutorial/src/principals.ts new file mode 100644 index 000000000..e3844887e --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/principals.ts @@ -0,0 +1,8 @@ +// The todo app's principals: its server and list workers, the kit's owner, and the users who signed in with a name. + +/** Who someone is in the todo app. */ +export type TodoPrincipal = + | { readonly kind: "server" } + | { readonly kind: "worker"; readonly list: string } + | { readonly kind: "owner" } + | { readonly kind: "user"; readonly id: string; readonly name: string }; diff --git a/packages/chord/test/examples/61-tutorial/src/tui/main.ts b/packages/chord/test/examples/61-tutorial/src/tui/main.ts new file mode 100644 index 000000000..0d8a4eaf8 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/tui/main.ts @@ -0,0 +1,36 @@ +// The todo app's terminal entry, the base bundle the launcher's sandbox runs: `startTerminalRuntime` with the app's +// terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in +// a scope of their own, opened again when a reload changes them; `q` quits. + +import { defineFacet } from "../../../../../src/index.ts"; +import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts"; +import * as v1 from "../api/v1/index.ts"; +import * as v1tui from "../api/v1/tui.ts"; + +/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */ +const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) })); + +void startTerminalRuntime({ + modules: { "@todo/app/v1": v1, "@todo/app/v1/tui": v1tui }, + core: [{ name: "@todo/tui", facet: core }], + render: (runtime) => { + const { terminal, theme } = runtime; + const screens = new TuiScope(runtime.host, () => draw()).registry(v1tui.Screens); + let opened: { screens: readonly v1tui.Screen[]; scope: TuiScope; lines: (() => readonly string[])[] } | undefined; + const draw = (): void => { + const current = screens() ?? []; + if (opened?.screens !== current) { + opened?.scope.dispose(); + const scope = new TuiScope(runtime.host, () => draw()); + opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) }; + } + const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]); + terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`); + }; + theme.onChange(draw); + terminal.onInput((data) => { + if (data === "q") runtime.exit(0); + }); + draw(); + }, +}); diff --git a/packages/chord/test/examples/61-tutorial/src/web/main.ts b/packages/chord/test/examples/61-tutorial/src/web/main.ts new file mode 100644 index 000000000..53d0d42c1 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/web/main.ts @@ -0,0 +1,33 @@ +// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and +// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute. + +import * as React from "react"; +import { createElement as h } from "react"; +import * as jsx from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; +import { defineFacet } from "../../../../../src/index.ts"; +import { HostProvider, useRegistry } from "../../../../../src/react/index.ts"; +import { startPage } from "../../../../../src/web/index.ts"; +import * as v1 from "../api/v1/index.ts"; +import * as v1web from "../api/v1/web.ts"; +import "./style.css"; + +/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */ +const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) })); + +function Page() { + const panels = useRegistry(v1web.Panels) ?? []; + return h( + "main", + null, + h("h1", null, "Todo"), + ...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))), + ); +} + +void startPage({ + connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`), + modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx }, + core: [{ name: "@todo/web", facet: core }], + render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))), +}); diff --git a/packages/chord/test/examples/61-tutorial/src/web/style.css b/packages/chord/test/examples/61-tutorial/src/web/style.css new file mode 100644 index 000000000..d7cc22856 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/web/style.css @@ -0,0 +1,5 @@ +/* The page's look: the active theme's colours, which the kit writes on :root as CSS variables. */ +body { margin: 0; font: 15px system-ui, sans-serif; background: var(--bg); color: var(--fg); } +main { max-width: 26rem; margin: 1.5rem auto; padding: 0 1rem; } +h1 { color: var(--accent); font-size: 1.4rem; margin: 0 0 1rem; } +h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } diff --git a/packages/chord/test/examples/61-tutorial/start.ts b/packages/chord/test/examples/61-tutorial/start.ts new file mode 100644 index 000000000..4715e1d10 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/start.ts @@ -0,0 +1,76 @@ +// The todo app's start: what the app decides itself, given to the kit's `startServer`: its name and data directory, +// its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client. The kit +// does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls +// it with the data directory from its command line and prints the owner link. + +import { cp, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Context } from "../../../src/context/index.ts"; +import { type App, startServer } from "../../../src/node/app/index.ts"; +import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts"; +import * as v1 from "./src/api/v1/index.ts"; +import * as v1server from "./src/api/v1/server.ts"; +import type { TodoPrincipal } from "./src/principals.ts"; + +/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */ +export const API = { + name: "@todo/app", + versions: ["v1"], + modules: { "@todo/app/v1": v1, "@todo/app/v1/server": v1server }, +}; + +/** The app's facet kinds: a plugin has one file per kind it runs on (`server.ts`, `worker.ts`, `web.ts`, `tui.ts`). */ +export const KINDS = { + server: { platform: "node" }, + worker: { platform: "node" }, + web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } }, + tui: { platform: "node" }, +} as const; + +/** The page: the app's own HTML around the assets the kit made (the base bundle, the plugins' scripts and styles). */ +function page(request: { readonly nonce: string }, assets: PageAssets): string { + return `Todo${pageScripts(assets, request.nonce)}
`; +} + +/** Starts the todo app on a free loopback port, with its data in `dataDir` and its built-in plugins in `root`. */ +export function startTodo(dataDir: string, root: string, context: Context): Promise> { + return startServer( + { + name: "todo", + dataDir, + root, + listen: { host: "127.0.0.1", port: 0 }, + api: API, + kinds: KINDS, + principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, + workers: [ + { + kind: v1.List, + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + start: () => ({}), + // Who may reach a list's worker: everyone signed in, and the server itself. + authorize: (principal) => principal.kind !== "worker", + }, + ], + web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page }, + tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) }, + onError: (error) => console.error("todo:", error.kind, error.error), + }, + context, + ); +} + +/** A data directory and a copy of the app's plugins, in a new temporary directory. */ +export async function todoDirectories(): Promise<{ + readonly base: string; + readonly dataDir: string; + readonly root: string; +}> { + const base = await mkdtemp(join(tmpdir(), "todo-")); + const root = join(base, "plugins"); + await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true }); + return { base, dataDir: join(base, "data"), root }; +} diff --git a/packages/chord/test/examples/61-tutorial/tutorial.test.ts b/packages/chord/test/examples/61-tutorial/tutorial.test.ts new file mode 100644 index 000000000..bdd8c3077 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/tutorial.test.ts @@ -0,0 +1,116 @@ +// The tutorial's checks (`docs/tutorial/book.md`): every TypeScript, JSON and CSS block of the book is quoted from this +// example's files (`` above it) and fails here when the file no longer has those lines; the +// example's steps run (`main.ts`, and `screenshots.ts` with Chromium), and every console output the book shows +// (``, ``) is among the lines they print, in order. The terminal's +// output (``) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`). +// The p2 chapter's blocks (``) quote p2's sources, which live outside this repository. +// Run from packages/chord: +// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/tutorial.test.ts +import { execFile } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { TUI_NODE } from "../../node/tui/support.ts"; +import { CHROMIUM } from "../../node/web/browser.ts"; + +const HERE = fileURLToPath(new URL("./", import.meta.url)); +const BOOK = readFileSync(new URL("../../../docs/tutorial/book.md", import.meta.url), "utf8"); + +/** A fenced block of the book: its language, the marker comment right above it (if any), its text and line. */ +type Block = { + readonly lang: string; + readonly marker: string | undefined; + readonly text: string; + readonly line: number; +}; + +const BLOCKS = blocksOf(BOOK); + +/** Every fenced block of `book`, with the `` marker on the line before its opening fence. */ +function blocksOf(book: string): Block[] { + const lines = book.split("\n"); + const found: Block[] = []; + for (let n = 0; n < lines.length; n++) { + const open = /^```(\w*)$/.exec(lines[n]!); + if (open === null) continue; + const end = lines.indexOf("```", n + 1); + const marker = /^$/.exec(lines[n - 1] ?? "")?.[1]; + found.push({ lang: open[1]!, marker, text: lines.slice(n + 1, end).join("\n"), line: n + 1 }); + n = end; + } + return found; +} + +/** The blocks whose marker is `: `, with the value. */ +function marked(kind: string): { readonly block: Block; readonly value: string }[] { + return BLOCKS.flatMap((block) => { + const value = block.marker?.startsWith(`${kind}: `) ? block.marker.slice(kind.length + 2) : undefined; + return value === undefined ? [] : [{ block, value }]; + }); +} + +/** Fails unless every line of `block` is among `printed`'s lines, in order. */ +function expectPrinted(block: Block, printed: string): void { + const lines = printed.split("\n").map((line) => line.trimEnd()); + let at = 0; + for (const line of block.text.split("\n")) { + const found = lines.indexOf(line.trimEnd(), at); + expect(found, `book.md:${block.line}: not printed (in this order): ${line}`).toBeGreaterThanOrEqual(0); + at = found + 1; + } +} + +/** Runs one of the example's scripts with `node` from packages/chord: what it printed. */ +async function run(node: string, script: string, ...args: string[]): Promise { + const flags = ["--conditions=source", "--experimental-strip-types", "--no-warnings"]; + const { stdout } = await promisify(execFile)(node, [...flags, join(HERE, script), ...args], { + cwd: join(HERE, "../../.."), + timeout: 180_000, + }); + return stdout; +} + +describe("the tutorial's code blocks", () => { + it("quotes every TypeScript, JSON and CSS block from a file", () => { + const unquoted = BLOCKS.filter( + ({ lang, marker }) => + ["ts", "tsx", "json", "css"].includes(lang) && + !marker?.startsWith("quote: ") && + !marker?.startsWith("p2: "), + ); + expect(unquoted.map(({ line }) => `book.md:${line}`)).toEqual([]); + }); + + it("matches the example's files", () => { + const quotes = marked("quote"); + expect(quotes.length).toBeGreaterThan(20); + for (const { block, value } of quotes) { + const file = readFileSync(join(HERE, value), "utf8"); + expect(file.includes(block.text), `book.md:${block.line}: ${value} no longer has this block`).toBe(true); + } + }); +}); + +describe("the tutorial's steps", () => { + it("prints what the book shows", { timeout: 200_000 }, async () => { + const printed = await run(TUI_NODE ?? process.execPath, "main.ts"); + for (const { block } of marked("output").filter(({ value }) => value === "main")) expectPrinted(block, printed); + if (TUI_NODE === undefined) return; + for (const { block } of marked("output").filter(({ value }) => value === "tui")) expectPrinted(block, printed); + }); + + it.skipIf(CHROMIUM === undefined)("takes the screenshots", { timeout: 200_000 }, async () => { + const out = mkdtempSync(join(tmpdir(), "todo-screens-")); + try { + const printed = await run(process.execPath, "screenshots.ts", out); + for (const { block } of marked("output").filter(({ value }) => value === "screenshots")) { + expectPrinted(block, printed.replaceAll(out, "docs/tutorial/screens")); + } + } finally { + rmSync(out, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/chord/test/examples/61-tutorial/worker.ts b/packages/chord/test/examples/61-tutorial/worker.ts new file mode 100644 index 000000000..74b186b05 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/worker.ts @@ -0,0 +1,7 @@ +// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects +// to the server and loads the plugins' worker facets. + +import { startWorker } from "../../../src/node/app/index.ts"; +import { API, KINDS } from "./start.ts"; + +startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) }); diff --git a/tsconfig.json b/tsconfig.json index 114c8cafc..2b9bbdb05 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,10 @@ "@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"], "@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"], "@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"], + "@todo/app/*": [ + "./packages/chord/test/examples/61-tutorial/src/api/*.ts", + "./packages/chord/test/examples/61-tutorial/src/api/*/index.ts" + ], "@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],