mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(chord): tutorial on hosts and plugins
This commit is contained in:
parent
e22468919b
commit
081f28bad7
2 changed files with 782 additions and 0 deletions
319
packages/chord/docs/next/guide-plugins-hosts/book.md
Normal file
319
packages/chord/docs/next/guide-plugins-hosts/book.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
---
|
||||
title: Hosts and Plugins: a tutorial for Chord v2
|
||||
cover: Hosts / and Plugins
|
||||
kicker: A guide · Chord v2 · 2026-10-31
|
||||
subtitle: Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs
|
||||
stats:
|
||||
- 3 | kinds of host in an app: server, worker, client
|
||||
- 1 | file per host kind in a plugin
|
||||
- 0 | restarts to apply a plugin edit
|
||||
- 1 | rule: plugins talk only through object types and registries
|
||||
note: |
|
||||
A tutorial for people building apps on Chord v2, written by the redesign session from `design.md`, `slice-7c.md`,
|
||||
`slice-10.md`, the decision log and the code in `packages/chord/src/next` and `src/next-node`. API names are the
|
||||
current ones; where slice 10's fix round or the app kit (`chord/next/app`, being designed) will change what app code
|
||||
writes, a note says so.
|
||||
colophon: Hosts and Plugins · first edition · 2026-10-31 · Chord v2 slices 1-10
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
You want to build a notes app. You type a note in a browser tab, and every other tab sees it at once. Each project
|
||||
gets its own notes in its own process, so a project that misbehaves cannot take the others down. And you, or an agent
|
||||
you work with, add features while the app runs: a search, an export button, a word count, without restarting
|
||||
anything.
|
||||
|
||||
Chord gives you three things for that. **Hosts**: the processes of your app (the server, a worker per project, the
|
||||
browser tabs), connected to each other. **Objects**: the state and methods your app is made of, living on one host and
|
||||
used from any other. **Plugins**: directories of code that declare objects for each kind of host, loaded and
|
||||
reloaded while the app runs.
|
||||
|
||||
This book builds the app in that order. Each chapter adds one concept and shows complete code.
|
||||
|
||||
## Seven things to know
|
||||
|
||||
:::findings
|
||||
1. **A host is one participant.** The server, each worker and each browser tab is a host; hosts connect over links.
|
||||
2. **An object has a type, and a type is a contract.** The contract names the object's state and methods; any host can
|
||||
ask for an object by type and params, and Chord finds where it lives.
|
||||
3. **A facet declares, an object acts.** A facet is a plugin's code for one host kind: it says which object types this
|
||||
host serves. The objects hold the state and do the work.
|
||||
4. **A plugin is a directory.** A `package.json` with the plugin's name, and one file per host kind: `server.ts`,
|
||||
`worker.ts`, `web.tsx`.
|
||||
5. **The server builds, every host evaluates.** The plugins component builds each facet into a small script; the
|
||||
server, workers and tabs load the scripts for their kind.
|
||||
6. **Plugins meet through types and registries.** One plugin uses another's objects through its contract, or
|
||||
contributes to a registry another defines. Nothing else crosses between plugins.
|
||||
7. **An edit is a rollout.** Change a file, build, roll out: the server, then every worker, swap the plugin's objects in
|
||||
place; a broken edit keeps the old code running and reports why.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **host** is one participant of the app, made with `createHost`. A **host kind** (`Server`, `Session`) describes
|
||||
addressable hosts; an **address** (`Session({ id: "p1" })`) names one. An **object type** is a contract: `local` (used
|
||||
on its own host only) or `remote` (used from any host). An **object** is one instance of a type, per params. A
|
||||
**facet** is a plugin's declarations for one host kind. A **plugin** is a directory with a `package.json`. A
|
||||
**reference** is what you get when you ask for an object (`host.get`, `self.get`, `ctx.get`). A **registry** is an
|
||||
extension point: one plugin defines it, others contribute. A **rollout** applies a build to the hosts in order.
|
||||
|
||||
# One Host {#host lede="The smallest app: one process, one object type, one call."}
|
||||
|
||||
Start without plugins. One host, one object that keeps notes:
|
||||
|
||||
```ts
|
||||
import { createHost, defineFacet, defineState, local, method, obj, arr, str } from "@earendil-works/chord/next";
|
||||
|
||||
// the contract: what a Notes object looks like from outside
|
||||
const Notes = local("notes.store", {
|
||||
version: 1,
|
||||
params: {}, // one object per host: no params
|
||||
members: {
|
||||
list: obj({ items: arr(str()) }), // state anyone can read and watch
|
||||
add: method(obj({ text: str() })), // a method: arguments checked by Chord
|
||||
},
|
||||
});
|
||||
|
||||
// the document the object keeps
|
||||
const NotesDoc = defineState("notes", { schema: obj({ items: arr(str()) }), version: 1, initial: () => ({ items: [] }) });
|
||||
|
||||
// the facet: which types this host serves, and how to make each object
|
||||
const facet = defineFacet((host) => {
|
||||
host.serve(Notes, (_params, self) => {
|
||||
const doc = self.state(NotesDoc);
|
||||
return {
|
||||
list: doc,
|
||||
add: async ({ text }, ctx) => self.tx(async (tx) => { (await tx.draft(doc)).items.push(text); }, ctx),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const server = createHost({ onError: console.error });
|
||||
await server.load([{ name: "@app/notes", facet }], ctx);
|
||||
const notes = await server.get(Notes, {}, ctx); // a reference: live, with its state
|
||||
await notes.add({ text: "buy milk" }, ctx);
|
||||
console.log(notes.list.value); // { items: ["buy milk"] }
|
||||
```
|
||||
|
||||
Four things happened. The **contract** `Notes` says what the object offers. The **facet** says this host serves
|
||||
`Notes`, with a **factory** that declares the object's parts: a document, and a method writing it in a transaction.
|
||||
`load` publishes the facet. `get` returns a **reference** once the object is live; its members are the contract's.
|
||||
|
||||
The factory only declares: it is synchronous and opens nothing. Chord activates the object (loads its documents, opens
|
||||
what it asked to open) and then serves calls. That is why the same factory can run again when the code changes.
|
||||
|
||||
# Several Hosts {#hosts lede="A server, a worker per project, browser tabs: how they find each other's objects."}
|
||||
|
||||
Now give each project its own process. Declare the kinds of addressable host, and make notes a `remote` type that
|
||||
lives on its project's worker:
|
||||
|
||||
```ts
|
||||
export const Server = hostKind("server");
|
||||
export const Project = hostKind("project", { id: str() });
|
||||
|
||||
export const Notes = remote("notes", {
|
||||
version: 1,
|
||||
params: { project: str() },
|
||||
hostAddress: ({ project }) => Project({ id: project }), // where an object of these params lives
|
||||
variants: { editor: { state: { list: NotesSchema }, methods: { add: method(AddArgs) } } },
|
||||
});
|
||||
```
|
||||
|
||||
`hostAddress` is the whole routing story. A tab asks for `Notes { project: "p1" }`. Its host computes the address,
|
||||
`Project(p1)`, and sends the request to its parent, the server. The server has a **route** for `Project`: it starts (or
|
||||
finds) the worker for `p1` and passes the request on. The worker serves `Notes`. The tab gets a reference whose state
|
||||
follows the worker's document, one frame per change.
|
||||
|
||||
:::flow wide animate | A tab holds a project's notes: the server routes by address to the project's worker and relays, without reading the data.
|
||||
row "Clients" blue
|
||||
box tab1 "tab A" "holds Notes { project: p1 }"
|
||||
box tab2 "tab B" "holds Notes { project: p1 }"
|
||||
row "Server" accent
|
||||
box server "server" "route Project → worker"
|
||||
row "Workers" muted
|
||||
box w1 "worker p1" "serves Notes { p1 }"
|
||||
box w2 "worker p2" "serves Notes { p2 }"
|
||||
arrow tab1 -> server "hold"
|
||||
arrow tab2 -> server "hold"
|
||||
arrow server -> w1 "relay"
|
||||
:::
|
||||
|
||||
Each host is created the same way, with what it is:
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
|
||||
|
||||
// worker.ts (started by the server for one project)
|
||||
const worker = createHost<P>({ address: Project({ id }), principal: { kind: "worker", project: id }, onError });
|
||||
worker.connect(parentLink, { principal: { kind: "server" }, trusted: true }, ctx);
|
||||
|
||||
// a browser tab: no address, it is a client
|
||||
const tab = createHost<P>({ onError });
|
||||
tab.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } }, ctx);
|
||||
```
|
||||
|
||||
`P` is your app's type of **principal**, who is calling: an owner, a guest, the server, a worker. Links stamp it;
|
||||
a `remote` object is made per principal, so a guest can get a read-only variant of the same notes.
|
||||
|
||||
:::note | Coming with the app kit
|
||||
Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components today
|
||||
(`createWorkers`, `chord.http`), wired by the app. The opinionated app kit (`chord/next/app`, being designed) will do
|
||||
this wiring for you: `startApp(...)` and `startWorker()`.
|
||||
:::
|
||||
|
||||
# Plugins {#plugins lede="Move the code into a directory that can be added, edited and removed while the app runs."}
|
||||
|
||||
So far the facets were part of the app's own code. A **plugin** is the same code in its own directory:
|
||||
|
||||
```text
|
||||
plugins/notes/
|
||||
package.json { "name": "@app/notes" }
|
||||
contract.ts the Notes contract (other plugins import it)
|
||||
worker.ts the facet for workers: serves Notes
|
||||
server.ts the facet for the server: an export route
|
||||
web.tsx the facet for browser tabs: a notes panel
|
||||
```
|
||||
|
||||
The plugin's name is its `package.json` name. Each file named after a **host kind** is that kind's facet, its
|
||||
default export a `defineFacet(...)`. A plugin has only the files it needs: a plugin that only adds a route has a
|
||||
`server.ts` and nothing else.
|
||||
|
||||
```ts
|
||||
// plugins/notes/worker.ts
|
||||
import { defineFacet } from "@app/core";
|
||||
import { Notes } from "./contract.ts";
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Notes, ({ project }, principal, self) => {
|
||||
const doc = self.state(NotesDoc);
|
||||
return { variant: "editor", members: { list: doc, add: async ({ text }, ctx) => /* … */ } };
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`@app/core` is your app's **base code**, imported by every plugin as a host package (the plugin's build leaves it
|
||||
out; every host passes its own copy in). It holds what plugins share and never reloads: host kinds, the principal
|
||||
type, a `defineFacet` bound to it.
|
||||
|
||||
# Loading Plugins {#loading lede="Discover, build, evaluate, load: what happens between a directory and a running object."}
|
||||
|
||||
The **plugins component** does the work. You tell it where plugins are and what each host kind needs:
|
||||
|
||||
```ts
|
||||
const plugins = createPlugins({
|
||||
roots: ["./plugins"],
|
||||
kinds: {
|
||||
server: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
|
||||
worker: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
|
||||
web: { platform: "browser", hostPackages: ["@earendil-works/chord/next", "@app/core", "react", "react/jsx-runtime"] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
|
||||
const report = await plugins.load(server, {
|
||||
kind: "server",
|
||||
core: [{ name: "@app/core", facet: coreServerFacet }],
|
||||
modules: { "@earendil-works/chord/next": chord, "@app/core": core },
|
||||
}, ctx);
|
||||
console.log(report.failed); // plugins left out, with why: [] when all loaded
|
||||
```
|
||||
|
||||
:::steps | What `plugins.load` does
|
||||
+++ Discover
|
||||
It lists `./plugins` one level deep and reads each `package.json`. A directory without one, or with two `server`
|
||||
files, is reported, not loaded.
|
||||
+++ Build
|
||||
For each plugin with a `server` facet, esbuild builds one script from `server.ts` and everything it imports: the
|
||||
plugin's own files, another plugin's contract files, its npm dependencies. Host packages stay out. A syntax error is a
|
||||
build failure, never a crash.
|
||||
+++ Evaluate
|
||||
The script runs and returns the facet. Its `import "@app/core"` gets the server's own `@app/core`, so a `ChordError`
|
||||
from a plugin is the same class as the server's.
|
||||
+++ Load
|
||||
`host.load` runs every facet's setup and publishes all of them at once. A plugin whose build, evaluation or setup
|
||||
fails is left out and listed in `report.failed`; the others load. Its objects' users wait, and the next edit loads it.
|
||||
:::
|
||||
|
||||
A worker never builds: the server builds the worker facets too, and each worker evaluates the scripts it is sent.
|
||||
|
||||
# Plugins Using Plugins {#together lede="Two ways one plugin uses another, and the one it may not."}
|
||||
|
||||
A search plugin wants to search the notes. It **imports the contract** and asks for the object:
|
||||
|
||||
```ts
|
||||
// plugins/search/worker.ts
|
||||
import { Notes } from "../notes/contract.ts"; // copied into search's script: contracts only
|
||||
|
||||
host.serve(Search, ({ project }, principal, self) => {
|
||||
const notes = self.get(Notes, { project }); // a dependency: Search goes live once Notes is
|
||||
const index = self.derive([notes.list], ([list]) => buildIndex(list.items));
|
||||
return { variant: "main", members: { find: async ({ q }) => lookup(index.value, q) } };
|
||||
});
|
||||
```
|
||||
|
||||
`self.get` in a factory declares a dependency: Chord makes `Search` live only once `Notes` is, and if the notes
|
||||
plugin is missing, `Search` waits with a clear reason ("waiting for notes: not served here") instead of failing.
|
||||
|
||||
The other way is a **registry**: an extension point one plugin defines and others contribute to. The app's core
|
||||
defines `Panels`, and every plugin with a UI contributes one:
|
||||
|
||||
```tsx
|
||||
// plugins/notes/web.tsx
|
||||
export default defineFacet((host, self) => self.contribute(Panels, { title: "Notes", Component: NotesPanel }));
|
||||
```
|
||||
|
||||
What a plugin may not do is reach into another's data. Search never reads notes' documents; it uses the `Notes`
|
||||
object's state. That rule is what lets the notes plugin change its storage without breaking search.
|
||||
|
||||
# Changing a Plugin {#reload lede="Edit, build, roll out: how new code takes over while every tab stays connected."}
|
||||
|
||||
You change the notes plugin: notes now have a timestamp. The watcher sees the saved file and marks the plugin dirty.
|
||||
You (or an agent) build and roll out:
|
||||
|
||||
```ts
|
||||
const built = await plugins.build(plugins.state.value.dirty, ctx);
|
||||
const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, ctx);
|
||||
```
|
||||
|
||||
The rollout goes host by host, server first, then every running worker. On each host Chord compares the new plugin
|
||||
set with the loaded one, runs the new factories against a recording `self` to see what each object would declare, and
|
||||
then gives every object of the changed plugin its fate:
|
||||
|
||||
| The object | What happens |
|
||||
| --- | --- |
|
||||
| holds no natives (open files, sockets), the new code neither | **swapped** in place: new methods, same documents; running calls finish on the old code |
|
||||
| holds natives | **restarted**: calls wait, running ones finish, natives close and reopen with the new code |
|
||||
| its type was removed | **ended**; its users wait until something serves it again |
|
||||
|
||||
The tab's reference follows the new object and gets one frame with the difference. Nothing reconnects. If the edit
|
||||
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
|
||||
every other plugin of the rollout applies.
|
||||
|
||||
:::note | Versions
|
||||
A change to a contract (a new field in `Notes`'s state) gets a new `version`. A tab built with the old contract keeps
|
||||
working if the provider maps the old version (`accepts`); otherwise it waits with "version mismatch" until it reloads.
|
||||
:::
|
||||
|
||||
# Base Code and Plugins {.epilogue lede="What reloads, what restarts, and where the line is."}
|
||||
|
||||
Everything in `plugins/` reloads: edit, build, roll out. Everything in your base code (`src/`, `@app/core`: host
|
||||
kinds, principals, the core facet, the app's startup) changes only with a restart, and the watcher tells you so.
|
||||
Keep base code small: host kinds, principals, and what every plugin needs. Put features in plugins.
|
||||
|
||||
The app kit being designed next (`chord/next/app`) will make the base code nearly empty: `startApp` and `startWorker`
|
||||
with Chord's conventions for hosts, users, authentication and storage, so an app is mostly plugins.
|
||||
|
||||
# Reference {.appendix}
|
||||
|
||||
| Call | What it does |
|
||||
| --- | --- |
|
||||
| `createHost<P>({ address?, principal?, store?, onError })` | a host: addressable with an address, else a client |
|
||||
| `host.load([{ name, facet }], ctx)` / `host.reload(...)` | publish facets / apply a new plugin set |
|
||||
| `host.get(Type, params, ctx)` / `host.hold(...)` | a live reference / a reference at once that follows its object |
|
||||
| `local(name, { version, params, members })` / `remote(name, { version, params, hostAddress, variants })` | contracts |
|
||||
| `defineFacet((host, self) => …)`, `host.serve(Type, factory)`, `self.contribute(R, value)` | a facet |
|
||||
| factory `self`: `state`, `derive`, `get`, `registry`, `open`, `on`, `onLive`, `job`, `tx` | an object's parts |
|
||||
| `createPlugins({ roots, kinds, onError }, ctx)` | the plugins component |
|
||||
| `plugins.load(host, { kind, core, modules }, ctx)`, `build(names)`, `rollout(steps, built)` | load, build, roll out |
|
||||
| `hostKind(name, params?)`, `Kind(params)` | host kinds and addresses |
|
||||
463
packages/chord/docs/next/guide-plugins-hosts/index.html
Normal file
463
packages/chord/docs/next/guide-plugins-hosts/index.html
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hosts and Plugins: a tutorial for Chord v2</title>
|
||||
<style>
|
||||
|
||||
:root{--paper:#fbf8f2;--ink:#2a2723;--muted:#7b7468;--rule:#e3dbcc;--accent:#b4472f;--blue:#2e6a8a;--sand:#c2b18c;--soft:#f4eee2}
|
||||
*{box-sizing:border-box}
|
||||
html{background:#efe9dd}
|
||||
body{margin:0;color:var(--ink);font:19px/1.68 'Iowan Old Style','Palatino Linotype',Palatino,'Book Antiqua',Georgia,serif;
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||
.book{max-width:860px;margin:0 auto;background:var(--paper);box-shadow:0 0 0 1px #e1d9c8,0 30px 80px -40px rgba(60,40,10,.35);padding:0 0 4rem}
|
||||
.inner{padding:0 90px}
|
||||
h1,h2,h3{font-weight:400;line-height:1.15}
|
||||
h2{font-size:2.1rem;margin:.2em 0 .3em;letter-spacing:-.01em}
|
||||
h3{font-size:1.05rem;text-transform:uppercase;letter-spacing:.14em;margin:2.4em 0 .6em;color:var(--ink);font-weight:600;
|
||||
font-family:'Avenir Next','Helvetica Neue',sans-serif}
|
||||
p{margin:0 0 1.05em;hyphens:auto;text-align:left}
|
||||
code{font:.8em/1 'SF Mono',Menlo,Consolas,monospace;background:#f1ebde;padding:.1em .35em;border-radius:3px}
|
||||
pre{font:.74rem/1.55 'SF Mono',Menlo,Consolas,monospace;background:#f4eee2;border-left:3px solid var(--sand);padding:1rem 1.2rem;margin:1.4rem 0 1.8rem;
|
||||
overflow-x:auto;border-radius:0 4px 4px 0;white-space:pre;tab-size:2}
|
||||
pre code{background:none;padding:0;font-size:1em}
|
||||
em{font-style:italic}
|
||||
a{color:var(--blue)}
|
||||
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;padding:90px;background:
|
||||
radial-gradient(circle at 85% 15%,rgba(180,71,47,.13),transparent 40%),radial-gradient(circle at 10% 90%,rgba(46,106,138,.12),transparent 45%),var(--paper);
|
||||
border-bottom:1px solid var(--rule)}
|
||||
.kicker{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
|
||||
.cover h1{font-size:5.2rem;line-height:.98;margin:.35em 0 .25em;letter-spacing:-.025em}
|
||||
.subtitle{font-size:1.45rem;font-style:italic;color:#4a453e;max-width:30em;text-align:left}
|
||||
.cover-stats,.stats{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;margin:2.2rem 0}
|
||||
.stats{margin:1.6rem 0 1.8rem;padding:1.1rem 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
|
||||
.stat b{display:block;font-size:2.1rem;font-weight:400;line-height:1.1;font-variant-numeric:lining-nums}
|
||||
.stat span{font:.78rem/1.3 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);letter-spacing:.03em;display:block;max-width:14em}
|
||||
.cover-note{font-size:.9rem;color:var(--muted);max-width:34em;text-align:left;margin-top:2rem}
|
||||
.toc{padding:4rem 0 2rem}
|
||||
.toc ol{list-style:none;padding:0;margin:1rem 0;columns:2;column-gap:3rem}
|
||||
.toc li{padding:.3rem 0;border-bottom:1px dotted var(--rule);break-inside:avoid}
|
||||
.toc a{color:var(--ink);text-decoration:none}
|
||||
.toc a:hover{color:var(--accent)}
|
||||
.preface{padding-top:2rem}
|
||||
.findings{padding-left:1.4em}
|
||||
.findings li{margin:.55em 0;padding-left:.3em}
|
||||
.findings li::marker{color:var(--accent);font-weight:600}
|
||||
.chapter{padding-top:4.5rem;margin-top:3rem;border-top:1px solid var(--rule)}
|
||||
.opener{text-align:center;margin-bottom:2.6rem}
|
||||
.numeral{font-size:4.2rem;color:var(--accent);line-height:1;font-variant:small-caps;letter-spacing:.05em}
|
||||
.opener h2{font-size:2.5rem;margin:.25em 0}
|
||||
.lede{font-style:italic;color:#5a544a;font-size:1.12rem;max-width:30em;margin:0 auto;text-align:center}
|
||||
.dropcap::first-letter{float:left;font-size:4.1em;line-height:.82;padding:.06em .09em 0 0;color:var(--accent)}
|
||||
.fig{margin:2rem 0 2.2rem}
|
||||
.fig.wide{margin-left:-50px;margin-right:-50px}
|
||||
.fig svg{display:block;margin:0 auto}
|
||||
figcaption{font:.8rem/1.45 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:.7rem;padding-left:.8rem;border-left:2px solid var(--rule)}
|
||||
.fignum{color:var(--ink);font-weight:600}
|
||||
.note{font:.86rem/1.55 'Avenir Next','Helvetica Neue',sans-serif;color:#5b5549;background:#f4eee2;border-left:3px solid var(--sand);
|
||||
padding:.9rem 1.1rem;margin:1.8rem 0;border-radius:0 4px 4px 0}
|
||||
.note.review{border-left-color:var(--accent)}
|
||||
.note b.tag{font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--accent);display:block;margin-bottom:.3rem}
|
||||
.note code{font-size:.85em}
|
||||
table{width:100%;border-collapse:collapse;margin:1.4rem 0 1.8rem;font:.8rem/1.4 'Avenir Next','Helvetica Neue',sans-serif}
|
||||
th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.68rem;border-bottom:1.5px solid var(--ink);padding:.45rem .5rem}
|
||||
td{padding:.42rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
||||
td code{font-size:.92em}
|
||||
table.num td:not(:first-child),table.num th:not(:first-child){text-align:right}
|
||||
.epilogue p{font-size:1.04em}
|
||||
.appendix{font-size:.92em}
|
||||
.appendix ul,.chapter ul{padding-left:1.2em}
|
||||
.appendix li,.chapter li{margin:.4em 0}
|
||||
.colophon{text-align:center;font:.78rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:4rem;letter-spacing:.05em}
|
||||
@media (max-width:760px){.inner{padding:0 22px}.cover{padding:40px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}
|
||||
.toc ol{columns:1}p{text-align:left}body{font-size:17px}}
|
||||
@media print{html{background:#fff}.book{box-shadow:none}.chapter{break-before:page}.fig{break-inside:avoid}}
|
||||
.transcript p b{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:.3em}
|
||||
pre .tk-c{color:#8d8576;font-style:italic}pre .tk-s{color:#5f7a3d}pre .tk-n{color:#a0662a}pre .tk-k{color:#b4472f}pre .tk-t{color:#2e6a8a}pre .tk-f{color:#7a3f6a}
|
||||
h4{font:600 .82rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.06em;margin:1.8em 0 .5em;color:var(--muted)}
|
||||
.inner ul,.inner ol:not(.findings){padding-left:1.2em}
|
||||
.inner li{margin:.35em 0}
|
||||
blockquote{margin:1.4rem 0;padding:.2rem 0 .2rem 1.2rem;border-left:3px solid var(--rule);color:#5a544a;font-style:italic}
|
||||
.fig svg text{dominant-baseline:auto}
|
||||
.fig .replay{font:600 .7rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
|
||||
background:none;border:1px solid var(--rule);border-radius:3px;padding:.2rem .55rem;margin:0 0 0 1rem;cursor:pointer;float:right}
|
||||
.fig .replay:hover{border-color:var(--accent)}
|
||||
.anim.armed .step{opacity:0}
|
||||
.anim.armed.play .step{animation:stepIn .45s ease-out both;animation-delay:calc(var(--i) * var(--pace, .45s))}
|
||||
.anim.armed.play .step.edge{animation-name:edgeIn}
|
||||
@keyframes stepIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
@keyframes edgeIn{from{opacity:0}to{opacity:1}}
|
||||
.steps{margin:2rem 0 2.2rem;border:1px solid var(--rule);border-radius:6px;background:#fffdf8}
|
||||
.steps-head{display:flex;align-items:center;gap:.8rem;padding:.6rem 1rem;border-bottom:1px solid var(--rule);
|
||||
font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
|
||||
.steps-head .title{flex:1}
|
||||
.steps-head .count{color:var(--muted);letter-spacing:.06em}
|
||||
.steps-head button{font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--ink);background:var(--soft);border:1px solid var(--rule);
|
||||
border-radius:3px;padding:.25rem .65rem;cursor:pointer}
|
||||
.steps-head button:disabled{opacity:.35;cursor:default}
|
||||
.step-panel{padding:1rem 1.2rem .4rem}
|
||||
.step-panel>h4:first-child{margin-top:.2rem;color:var(--ink)}
|
||||
.steps.js .step-panel{display:none}
|
||||
.steps.js .step-panel.on{display:block}
|
||||
.steps:not(.js) .steps-head button,.steps:not(.js) .steps-head .count{display:none}
|
||||
.steps:not(.js) .step-panel+.step-panel{border-top:1px dashed var(--rule)}
|
||||
.fig figcaption{display:flow-root}
|
||||
.fig svg{max-width:100%;height:auto}
|
||||
table{max-width:100%}
|
||||
@media (max-width:760px){.inner{padding:0 20px}.cover{padding:48px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}table{display:block;overflow-x:auto}.toc ol{columns:1}}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body><div class="book">
|
||||
<section class="cover">
|
||||
<div class="kicker">A guide · Chord v2 · 2026-10-31</div>
|
||||
<h1>Hosts<br>and Plugins</h1>
|
||||
<p class="subtitle">Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs</p>
|
||||
<div class="cover-stats"><div class="stat"><b>3</b><span>kinds of host in an app: server, worker, client</span></div><div class="stat"><b>1</b><span>file per host kind in a plugin</span></div><div class="stat"><b>0</b><span>restarts to apply a plugin edit</span></div><div class="stat"><b>1</b><span>rule: plugins talk only through object types and registries</span></div></div>
|
||||
<div class="cover-note"><p>A tutorial for people building apps on Chord v2, written by the redesign session from <code>design.md</code>, <code>slice-7c.md</code>,
|
||||
<code>slice-10.md</code>, the decision log and the code in <code>packages/chord/src/next</code> and <code>src/next-node</code>. API names are the
|
||||
current ones; where slice 10's fix round or the app kit (<code>chord/next/app</code>, being designed) will change what app code
|
||||
writes, a note says so.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||||
<li><a href="#host">I. One Host</a></li>
|
||||
<li><a href="#hosts">II. Several Hosts</a></li>
|
||||
<li><a href="#plugins">III. Plugins</a></li>
|
||||
<li><a href="#loading">IV. Loading Plugins</a></li>
|
||||
<li><a href="#together">V. Plugins Using Plugins</a></li>
|
||||
<li><a href="#reload">VI. Changing a Plugin</a></li>
|
||||
<li><a href="#base-code-and-plugins">Base Code and Plugins</a></li>
|
||||
<li><a href="#reference">Appendix A: Reference</a></li>
|
||||
</ol></nav>
|
||||
|
||||
<section class="preface" id="before-we-begin">
|
||||
<h2>Before we begin</h2>
|
||||
<p class="dropcap">You want to build a notes app. You type a note in a browser tab, and every other tab sees it at once. Each project
|
||||
gets its own notes in its own process, so a project that misbehaves cannot take the others down. And you, or an agent
|
||||
you work with, add features while the app runs: a search, an export button, a word count, without restarting
|
||||
anything.</p>
|
||||
<p>Chord gives you three things for that. <strong>Hosts</strong>: the processes of your app (the server, a worker per project, the
|
||||
browser tabs), connected to each other. <strong>Objects</strong>: the state and methods your app is made of, living on one host and
|
||||
used from any other. <strong>Plugins</strong>: directories of code that declare objects for each kind of host, loaded and
|
||||
reloaded while the app runs.</p>
|
||||
<p>This book builds the app in that order. Each chapter adds one concept and shows complete code.</p>
|
||||
<h3 id="seven-things-to-know">Seven things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>A host is one participant.</strong> The server, each worker and each browser tab is a host; hosts connect over links.</li>
|
||||
<li><strong>An object has a type, and a type is a contract.</strong> The contract names the object's state and methods; any host can
|
||||
ask for an object by type and params, and Chord finds where it lives.</li>
|
||||
<li><strong>A facet declares, an object acts.</strong> A facet is a plugin's code for one host kind: it says which object types this
|
||||
host serves. The objects hold the state and do the work.</li>
|
||||
<li><strong>A plugin is a directory.</strong> A <code>package.json</code> with the plugin's name, and one file per host kind: <code>server.ts</code>,
|
||||
<code>worker.ts</code>, <code>web.tsx</code>.</li>
|
||||
<li><strong>The server builds, every host evaluates.</strong> The plugins component builds each facet into a small script; the
|
||||
server, workers and tabs load the scripts for their kind.</li>
|
||||
<li><strong>Plugins meet through types and registries.</strong> One plugin uses another's objects through its contract, or
|
||||
contributes to a registry another defines. Nothing else crosses between plugins.</li>
|
||||
<li><strong>An edit is a rollout.</strong> Change a file, build, roll out: the server, then every worker, swap the plugin's objects in
|
||||
place; a broken edit keeps the old code running and reports why.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p>A <strong>host</strong> is one participant of the app, made with <code>createHost</code>. A <strong>host kind</strong> (<code>Server</code>, <code>Session</code>) describes
|
||||
addressable hosts; an <strong>address</strong> (<code>Session({ id: "p1" })</code>) names one. An <strong>object type</strong> is a contract: <code>local</code> (used
|
||||
on its own host only) or <code>remote</code> (used from any host). An <strong>object</strong> is one instance of a type, per params. A
|
||||
<strong>facet</strong> is a plugin's declarations for one host kind. A <strong>plugin</strong> is a directory with a <code>package.json</code>. A
|
||||
<strong>reference</strong> is what you get when you ask for an object (<code>host.get</code>, <code>self.get</code>, <code>ctx.get</code>). A <strong>registry</strong> is an
|
||||
extension point: one plugin defines it, others contribute. A <strong>rollout</strong> applies a build to the hosts in order.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="host"><header class="opener"><div class="numeral">I</div><h2>One Host</h2><p class="lede">The smallest app: one process, one object type, one call.</p></header>
|
||||
<p class="dropcap">Start without plugins. One host, one object that keeps notes:</p>
|
||||
<pre><code><span class="tk-k">import</span> { createHost, defineFacet, defineState, local, method, obj, arr, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
|
||||
<span class="tk-c">// the contract: what a Notes object looks like from outside</span>
|
||||
<span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">local</span>(<span class="tk-s">"notes.store"</span>, {
|
||||
version: <span class="tk-n">1</span>,
|
||||
params: {}, <span class="tk-c">// one object per host: no params</span>
|
||||
members: {
|
||||
list: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-f">str</span>()) }), <span class="tk-c">// state anyone can read and watch</span>
|
||||
add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>() })), <span class="tk-c">// a method: arguments checked by Chord</span>
|
||||
},
|
||||
});
|
||||
|
||||
<span class="tk-c">// the document the object keeps</span>
|
||||
<span class="tk-k">const</span> <span class="tk-t">NotesDoc</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes"</span>, { schema: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-f">str</span>()) }), version: <span class="tk-n">1</span>, initial: () => ({ items: [] }) });
|
||||
|
||||
<span class="tk-c">// the facet: which types this host serves, and how to make each object</span>
|
||||
<span class="tk-k">const</span> facet = <span class="tk-f">defineFacet</span>((host) => {
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, (_params, self) => {
|
||||
<span class="tk-k">const</span> doc = self.<span class="tk-f">state</span>(<span class="tk-t">NotesDoc</span>);
|
||||
<span class="tk-k">return</span> {
|
||||
list: doc,
|
||||
add: <span class="tk-k">async</span> ({ text }, ctx) => self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(doc)).items.<span class="tk-f">push</span>(text); }, ctx),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
<span class="tk-k">const</span> server = <span class="tk-f">createHost</span>({ onError: console.error });
|
||||
<span class="tk-k">await</span> server.<span class="tk-f">load</span>([{ name: <span class="tk-s">"@app/notes"</span>, facet }], ctx);
|
||||
<span class="tk-k">const</span> notes = <span class="tk-k">await</span> server.<span class="tk-f">get</span>(<span class="tk-t">Notes</span>, {}, ctx); <span class="tk-c">// a reference: live, with its state</span>
|
||||
<span class="tk-k">await</span> notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"buy milk"</span> }, ctx);
|
||||
console.<span class="tk-f">log</span>(notes.list.value); <span class="tk-c">// { items: ["buy milk"] }</span></code></pre>
|
||||
<p>Four things happened. The <strong>contract</strong> <code>Notes</code> says what the object offers. The <strong>facet</strong> says this host serves
|
||||
<code>Notes</code>, with a <strong>factory</strong> that declares the object's parts: a document, and a method writing it in a transaction.
|
||||
<code>load</code> publishes the facet. <code>get</code> returns a <strong>reference</strong> once the object is live; its members are the contract's.</p>
|
||||
<p>The factory only declares: it is synchronous and opens nothing. Chord activates the object (loads its documents, opens
|
||||
what it asked to open) and then serves calls. That is why the same factory can run again when the code changes.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="hosts"><header class="opener"><div class="numeral">II</div><h2>Several Hosts</h2><p class="lede">A server, a worker per project, browser tabs: how they find each other's objects.</p></header>
|
||||
<p class="dropcap">Now give each project its own process. Declare the kinds of addressable host, and make notes a <code>remote</code> type that
|
||||
lives on its project's worker:</p>
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Server</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"server"</span>);
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Project</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"project"</span>, { id: <span class="tk-f">str</span>() });
|
||||
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||||
version: <span class="tk-n">1</span>,
|
||||
params: { project: <span class="tk-f">str</span>() },
|
||||
hostAddress: ({ project }) => <span class="tk-t">Project</span>({ id: project }), <span class="tk-c">// where an object of these params lives</span>
|
||||
variants: { editor: { state: { list: <span class="tk-t">NotesSchema</span> }, methods: { add: <span class="tk-f">method</span>(<span class="tk-t">AddArgs</span>) } } },
|
||||
});</code></pre>
|
||||
<p><code>hostAddress</code> is the whole routing story. A tab asks for <code>Notes { project: "p1" }</code>. Its host computes the address,
|
||||
<code>Project(p1)</code>, and sends the request to its parent, the server. The server has a <strong>route</strong> for <code>Project</code>: it starts (or
|
||||
finds) the worker for <code>p1</code> and passes the request on. The worker serves <code>Notes</code>. The tab gets a reference whose state
|
||||
follows the worker's document, one frame per change.</p>
|
||||
<figure class="fig wide anim"><svg viewBox="0 0 720 342" width="100%" role="img" aria-label="A tab holds a project's notes: the server routes by address to the project's worker and relays, without reading the data." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f1m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f1a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f1b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#2e6a8a" text-anchor="start" font-weight="600" letter-spacing="2">CLIENTS</text><text x="16" y="144" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">SERVER</text><text x="16" y="266" font-size="10" fill="#7b7468" text-anchor="start" font-weight="600" letter-spacing="2">WORKERS</text><g class="step" style="--i:1"><rect x="137" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="242" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">tab A</text><text x="242" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">holds Notes { project: p1 }</text></g><g class="step" style="--i:4"><rect x="373" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="478" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">tab B</text><text x="478" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">holds Notes { project: p1 }</text></g><g class="step" style="--i:3"><rect x="255" y="158" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">server</text><text x="360" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">route Project → worker</text></g><g class="step" style="--i:7"><rect x="137" y="280" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="242" y="299.3" font-size="11" fill="#2a2723" text-anchor="middle">worker p1</text><text x="242" y="313.3" font-size="9.5" fill="#7b7468" text-anchor="middle">serves Notes { p1 }</text></g><g class="step" style="--i:0"><rect x="373" y="280" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="478" y="299.3" font-size="11" fill="#2a2723" text-anchor="middle">worker p2</text><text x="478" y="313.3" font-size="9.5" fill="#7b7468" text-anchor="middle">serves Notes { p2 }</text></g><g class="step edge" style="--i:2"><path d="M242 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="305" y="112" width="29.1" height="13" fill="#fbf8f2" opacity=".92"/><text x="309" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">hold</text></g><g class="step edge" style="--i:5"><path d="M478 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="423" y="112" width="29.1" height="13" fill="#fbf8f2" opacity=".92"/><text x="427" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">hold</text></g><g class="step edge" style="--i:6"><path d="M360 202 L242 278" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="305" y="234" width="34.3" height="13" fill="#fbf8f2" opacity=".92"/><text x="309" y="244" font-size="9.5" fill="#7b7468" text-anchor="start">relay</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 1.</span> A tab holds a project's notes: the server routes by address to the project's worker and relays, without reading the data.</figcaption></figure>
|
||||
<p>Each host is created the same way, with what it is:</p>
|
||||
<pre><code><span class="tk-c">// server.ts</span>
|
||||
<span class="tk-k">const</span> server = <span class="tk-f">createHost</span><P>({ address: <span class="tk-t">Server</span>(), principal: { kind: <span class="tk-s">"server"</span> }, onError });
|
||||
|
||||
<span class="tk-c">// worker.ts (started by the server for one project)</span>
|
||||
<span class="tk-k">const</span> worker = <span class="tk-f">createHost</span><P>({ address: <span class="tk-t">Project</span>({ id }), principal: { kind: <span class="tk-s">"worker"</span>, project: id }, onError });
|
||||
worker.<span class="tk-f">connect</span>(parentLink, { principal: { kind: <span class="tk-s">"server"</span> }, trusted: <span class="tk-k">true</span> }, ctx);
|
||||
|
||||
<span class="tk-c">// a browser tab: no address, it is a client</span>
|
||||
<span class="tk-k">const</span> tab = <span class="tk-f">createHost</span><P>({ onError });
|
||||
tab.<span class="tk-f">connect</span>(<span class="tk-f">webSocketLink</span>(<span class="tk-k">new</span> <span class="tk-t">WebSocket</span>(<span class="tk-s">"/chord"</span>)), { principal: { kind: <span class="tk-s">"server"</span> } }, ctx);</code></pre>
|
||||
<p><code>P</code> is your app's type of <strong>principal</strong>, who is calling: an owner, a guest, the server, a worker. Links stamp it;
|
||||
a <code>remote</code> object is made per principal, so a guest can get a read-only variant of the same notes.</p>
|
||||
<div class="note"><b class="tag">Coming with the app kit</b><p>Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components today
|
||||
(<code>createWorkers</code>, <code>chord.http</code>), wired by the app. The opinionated app kit (<code>chord/next/app</code>, being designed) will do
|
||||
this wiring for you: <code>startApp(...)</code> and <code>startWorker()</code>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="plugins"><header class="opener"><div class="numeral">III</div><h2>Plugins</h2><p class="lede">Move the code into a directory that can be added, edited and removed while the app runs.</p></header>
|
||||
<p class="dropcap">So far the facets were part of the app's own code. A <strong>plugin</strong> is the same code in its own directory:</p>
|
||||
<pre class="plain"><code>plugins/notes/
|
||||
package.json { "name": "@app/notes" }
|
||||
contract.ts the Notes contract (other plugins import it)
|
||||
worker.ts the facet for workers: serves Notes
|
||||
server.ts the facet for the server: an export route
|
||||
web.tsx the facet for browser tabs: a notes panel</code></pre>
|
||||
<p>The plugin's name is its <code>package.json</code> name. Each file named after a <strong>host kind</strong> is that kind's facet, its
|
||||
default export a <code>defineFacet(...)</code>. A plugin has only the files it needs: a plugin that only adds a route has a
|
||||
<code>server.ts</code> and nothing else.</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/worker.ts</span>
|
||||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;
|
||||
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host) => {
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ project }, principal, self) => {
|
||||
<span class="tk-k">const</span> doc = self.<span class="tk-f">state</span>(<span class="tk-t">NotesDoc</span>);
|
||||
<span class="tk-k">return</span> { variant: <span class="tk-s">"editor"</span>, members: { list: doc, add: <span class="tk-k">async</span> ({ text }, ctx) => <span class="tk-c">/* … */</span> } };
|
||||
});
|
||||
});</code></pre>
|
||||
<p><code>@app/core</code> is your app's <strong>base code</strong>, imported by every plugin as a host package (the plugin's build leaves it
|
||||
out; every host passes its own copy in). It holds what plugins share and never reloads: host kinds, the principal
|
||||
type, a <code>defineFacet</code> bound to it.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="loading"><header class="opener"><div class="numeral">IV</div><h2>Loading Plugins</h2><p class="lede">Discover, build, evaluate, load: what happens between a directory and a running object.</p></header>
|
||||
<p class="dropcap">The <strong>plugins component</strong> does the work. You tell it where plugins are and what each host kind needs:</p>
|
||||
<pre><code><span class="tk-k">const</span> plugins = <span class="tk-f">createPlugins</span>({
|
||||
roots: [<span class="tk-s">"./plugins"</span>],
|
||||
kinds: {
|
||||
server: { platform: <span class="tk-s">"node"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord/next"</span>, <span class="tk-s">"@app/core"</span>] },
|
||||
worker: { platform: <span class="tk-s">"node"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord/next"</span>, <span class="tk-s">"@app/core"</span>] },
|
||||
web: { platform: <span class="tk-s">"browser"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord/next"</span>, <span class="tk-s">"@app/core"</span>, <span class="tk-s">"react"</span>, <span class="tk-s">"react/jsx-runtime"</span>] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
|
||||
<span class="tk-k">const</span> report = <span class="tk-k">await</span> plugins.<span class="tk-f">load</span>(server, {
|
||||
kind: <span class="tk-s">"server"</span>,
|
||||
core: [{ name: <span class="tk-s">"@app/core"</span>, facet: coreServerFacet }],
|
||||
modules: { <span class="tk-s">"@earendil-works/chord/next"</span>: chord, <span class="tk-s">"@app/core"</span>: core },
|
||||
}, ctx);
|
||||
console.<span class="tk-f">log</span>(report.failed); <span class="tk-c">// plugins left out, with why: [] when all loaded</span></code></pre>
|
||||
<div class="steps"><div class="steps-head"><span class="title">What <code>plugins.load</code> does</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>Discover</h4><p>It lists <code>./plugins</code> one level deep and reads each <code>package.json</code>. A directory without one, or with two <code>server</code>
|
||||
files, is reported, not loaded.</p>
|
||||
</div><div class="step-panel"><h4>Build</h4><p>For each plugin with a <code>server</code> facet, esbuild builds one script from <code>server.ts</code> and everything it imports: the
|
||||
plugin's own files, another plugin's contract files, its npm dependencies. Host packages stay out. A syntax error is a
|
||||
build failure, never a crash.</p>
|
||||
</div><div class="step-panel"><h4>Evaluate</h4><p>The script runs and returns the facet. Its <code>import "@app/core"</code> gets the server's own <code>@app/core</code>, so a <code>ChordError</code>
|
||||
from a plugin is the same class as the server's.</p>
|
||||
</div><div class="step-panel"><h4>Load</h4><p><code>host.load</code> runs every facet's setup and publishes all of them at once. A plugin whose build, evaluation or setup
|
||||
fails is left out and listed in <code>report.failed</code>; the others load. Its objects' users wait, and the next edit loads it.</p>
|
||||
</div></div>
|
||||
<p>A worker never builds: the server builds the worker facets too, and each worker evaluates the scripts it is sent.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="together"><header class="opener"><div class="numeral">V</div><h2>Plugins Using Plugins</h2><p class="lede">Two ways one plugin uses another, and the one it may not.</p></header>
|
||||
<p class="dropcap">A search plugin wants to search the notes. It <strong>imports the contract</strong> and asks for the object:</p>
|
||||
<pre><code><span class="tk-c">// plugins/search/worker.ts</span>
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"../notes/contract.ts"</span>; <span class="tk-c">// copied into search's script: contracts only</span>
|
||||
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">Search</span>, ({ project }, principal, self) => {
|
||||
<span class="tk-k">const</span> notes = self.<span class="tk-f">get</span>(<span class="tk-t">Notes</span>, { project }); <span class="tk-c">// a dependency: Search goes live once Notes is</span>
|
||||
<span class="tk-k">const</span> index = self.<span class="tk-f">derive</span>([notes.list], ([list]) => <span class="tk-f">buildIndex</span>(list.items));
|
||||
<span class="tk-k">return</span> { variant: <span class="tk-s">"main"</span>, members: { find: <span class="tk-k">async</span> ({ q }) => <span class="tk-f">lookup</span>(index.value, q) } };
|
||||
});</code></pre>
|
||||
<p><code>self.get</code> in a factory declares a dependency: Chord makes <code>Search</code> live only once <code>Notes</code> is, and if the notes
|
||||
plugin is missing, <code>Search</code> waits with a clear reason ("waiting for notes: not served here") instead of failing.</p>
|
||||
<p>The other way is a <strong>registry</strong>: an extension point one plugin defines and others contribute to. The app's core
|
||||
defines <code>Panels</code>, and every plugin with a UI contributes one:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/web.tsx</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { title: <span class="tk-s">"Notes"</span>, <span class="tk-t">Component</span>: <span class="tk-t">NotesPanel</span> }));</code></pre>
|
||||
<p>What a plugin may not do is reach into another's data. Search never reads notes' documents; it uses the <code>Notes</code>
|
||||
object's state. That rule is what lets the notes plugin change its storage without breaking search.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VI</div><h2>Changing a Plugin</h2><p class="lede">Edit, build, roll out: how new code takes over while every tab stays connected.</p></header>
|
||||
<p class="dropcap">You change the notes plugin: notes now have a timestamp. The watcher sees the saved file and marks the plugin dirty.
|
||||
You (or an agent) build and roll out:</p>
|
||||
<pre><code><span class="tk-k">const</span> built = <span class="tk-k">await</span> plugins.<span class="tk-f">build</span>(plugins.state.value.dirty, ctx);
|
||||
<span class="tk-k">const</span> result = <span class="tk-k">await</span> plugins.<span class="tk-f">rollout</span>([plugins.<span class="tk-f">step</span>(<span class="tk-s">"server"</span>, server), workers.<span class="tk-f">step</span>(<span class="tk-s">"workers"</span>)], built, ctx);</code></pre>
|
||||
<p>The rollout goes host by host, server first, then every running worker. On each host Chord compares the new plugin
|
||||
set with the loaded one, runs the new factories against a recording <code>self</code> to see what each object would declare, and
|
||||
then gives every object of the changed plugin its fate:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>The object</th>
|
||||
<th>What happens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>holds no natives (open files, sockets), the new code neither</td>
|
||||
<td><strong>swapped</strong> in place: new methods, same documents; running calls finish on the old code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>holds natives</td>
|
||||
<td><strong>restarted</strong>: calls wait, running ones finish, natives close and reopen with the new code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>its type was removed</td>
|
||||
<td><strong>ended</strong>; its users wait until something serves it again</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>The tab's reference follows the new object and gets one frame with the difference. Nothing reconnects. If the edit
|
||||
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
|
||||
every other plugin of the rollout applies.</p>
|
||||
<div class="note"><b class="tag">Versions</b><p>A change to a contract (a new field in <code>Notes</code>'s state) gets a new <code>version</code>. A tab built with the old contract keeps
|
||||
working if the provider maps the old version (<code>accepts</code>); otherwise it waits with "version mismatch" until it reloads.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="base-code-and-plugins"><header class="opener"><div class="numeral">∴</div><h2>Base Code and Plugins</h2><p class="lede">What reloads, what restarts, and where the line is.</p></header>
|
||||
<p class="dropcap">Everything in <code>plugins/</code> reloads: edit, build, roll out. Everything in your base code (<code>src/</code>, <code>@app/core</code>: host
|
||||
kinds, principals, the core facet, the app's startup) changes only with a restart, and the watcher tells you so.
|
||||
Keep base code small: host kinds, principals, and what every plugin needs. Put features in plugins.</p>
|
||||
<p>The app kit being designed next (<code>chord/next/app</code>) will make the base code nearly empty: <code>startApp</code> and <code>startWorker</code>
|
||||
with Chord's conventions for hosts, users, authentication and storage, so an app is mostly plugins.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter appendix" id="reference"><header class="opener"><div class="numeral">A</div><h2>Reference</h2></header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Call</th>
|
||||
<th>What it does</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>createHost<P>({ address?, principal?, store?, onError })</code></td>
|
||||
<td>a host: addressable with an address, else a client</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>host.load([{ name, facet }], ctx)</code> / <code>host.reload(...)</code></td>
|
||||
<td>publish facets / apply a new plugin set</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>host.get(Type, params, ctx)</code> / <code>host.hold(...)</code></td>
|
||||
<td>a live reference / a reference at once that follows its object</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>local(name, { version, params, members })</code> / <code>remote(name, { version, params, hostAddress, variants })</code></td>
|
||||
<td>contracts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>defineFacet((host, self) => …)</code>, <code>host.serve(Type, factory)</code>, <code>self.contribute(R, value)</code></td>
|
||||
<td>a facet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>factory <code>self</code>: <code>state</code>, <code>derive</code>, <code>get</code>, <code>registry</code>, <code>open</code>, <code>on</code>, <code>onLive</code>, <code>job</code>, <code>tx</code></td>
|
||||
<td>an object's parts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>createPlugins({ roots, kinds, onError }, ctx)</code></td>
|
||||
<td>the plugins component</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plugins.load(host, { kind, core, modules }, ctx)</code>, <code>build(names)</code>, <code>rollout(steps, built)</code></td>
|
||||
<td>load, build, roll out</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hostKind(name, params?)</code>, <code>Kind(params)</code></td>
|
||||
<td>host kinds and addresses</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<p class="colophon">Hosts and Plugins · first edition · 2026-10-31 · Chord v2 slices 1-10</p>
|
||||
</div></div>
|
||||
<script>(function activate(root) {
|
||||
const replay = (f) => {
|
||||
f.classList.remove("play");
|
||||
void f.getBoundingClientRect();
|
||||
f.classList.add("play");
|
||||
};
|
||||
const io = new IntersectionObserver(
|
||||
(es) => {
|
||||
for (const e of es)
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("play");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
root.querySelectorAll(".anim:not(.armed)").forEach((f) => {
|
||||
f.classList.add("armed");
|
||||
io.observe(f);
|
||||
const b = f.querySelector(".replay");
|
||||
if (b) b.onclick = () => replay(f);
|
||||
});
|
||||
root.querySelectorAll(".steps:not(.js)").forEach((s) => {
|
||||
s.classList.add("js");
|
||||
const ps = [...s.querySelectorAll(":scope>.step-panel")];
|
||||
let i = 0;
|
||||
const c = s.querySelector(".count"),
|
||||
p = s.querySelector(".prev"),
|
||||
n = s.querySelector(".next");
|
||||
const show = () => {
|
||||
ps.forEach((x, j) => x.classList.toggle("on", j === i));
|
||||
c.textContent = `${i + 1} / ${ps.length}`;
|
||||
p.disabled = i === 0;
|
||||
n.disabled = i === ps.length - 1;
|
||||
ps[i].querySelectorAll(".anim").forEach(replay);
|
||||
};
|
||||
p.onclick = () => {
|
||||
i--;
|
||||
show();
|
||||
};
|
||||
n.onclick = () => {
|
||||
i++;
|
||||
show();
|
||||
};
|
||||
show();
|
||||
});
|
||||
})(document)</script>
|
||||
</body></html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue