diff --git a/package-lock.json b/package-lock.json
index 06e7dd2e9..84587bc00 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"workspaces": [
"packages/*",
"apps/*",
+ "packages/chord/test/examples/61-tutorial",
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
@@ -2606,6 +2607,10 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@todo/app": {
+ "resolved": "packages/chord/test/examples/61-tutorial",
+ "link": true
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -5995,6 +6000,10 @@
}
}
},
+ "packages/chord/test/examples/61-tutorial": {
+ "name": "@todo/app",
+ "version": "0.0.1"
+ },
"packages/codemode": {
"name": "@earendil-works/pi-codemode",
"version": "1.5.0",
diff --git a/package.json b/package.json
index eaaddc4c7..badb6e965 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"workspaces": [
"packages/*",
"apps/*",
+ "packages/chord/test/examples/61-tutorial",
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md
index c6a64e36b..5e17d46e6 100644
--- a/packages/chord/CHANGELOG.md
+++ b/packages/chord/CHANGELOG.md
@@ -15,6 +15,8 @@
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`@earendil-works/chord/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("", "", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`@earendil-works/chord/web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`@earendil-works/chord/react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`@earendil-works/chord/tui/launcher`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`@earendil-works/chord/tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook.
- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client).
- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
+- Chord v2's root exports `invalid(reason, message, details?)` and the new `forbidden(message, details?)`, the `ChordError`s a plugin throws for arguments or params it refuses (`invalid/args`, `invalid/params`) and for a principal it refuses (`forbidden`); an app's API re-exports them, and the caller gets the same `{ code, reason }` as from Chord's own checks.
+- Chord v2's React bindings gain `usePageState(key, initial)`, `useState`'s pair with the value kept by the page's host under `key` instead of the component: a component of a reloaded web plugin is a new function and remounts (its `useState` starts over), and page state is what survives (a half-typed message, a scroll position); any value, never on the wire, gone with the page. `TuiScope` gains `pageState(key, initial)` on the same per-host store, its changes calling the scope's `changed`.
- Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting.
### Changed
diff --git a/packages/chord/README.md b/packages/chord/README.md
index c5aa13d1e..d73439b99 100644
--- a/packages/chord/README.md
+++ b/packages/chord/README.md
@@ -23,7 +23,7 @@ The design, the rule book for all of this, is [docs/design.md](docs/design.md).
| `@earendil-works/chord/app` | The app kit: `startServer`, `startWorker`, the start-data objects, sessions and the owner's sign-in, device pairing, files, export and import per address, optional plugins (users, invites, ephemeral login). | Node |
| `@earendil-works/chord/surfaces` | The client surfaces' server side: `webSurface` (base bundle, plugin scripts, the page) and `tuiSurface` (the terminal surface). | Node |
| `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`. | Browsers |
-| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `watchStore`. | Browsers |
+| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `usePageState`, `watchStore`. | Browsers |
| `@earendil-works/chord/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) |
| `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node |
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |
diff --git a/packages/chord/docs/design.md b/packages/chord/docs/design.md
index 437c13651..e327e2ff0 100644
--- a/packages/chord/docs/design.md
+++ b/packages/chord/docs/design.md
@@ -1949,6 +1949,14 @@ type ReloadReport = {
would interleave unchecked. In practice a restart costs little (the Durable harness is base code, terminal PTYs live
in their own process, model runtimes, sandboxes and the search index rebuild in milliseconds); reloading the plugin
that owns SSH connections or the relay socket hurts.
+- **UI on client hosts** (decision "Slice 11, P4", refined by "Tutorial gaps" (1)): a page or terminal applies a new
+ plugin script with `host.reload`, as any host; the UI re-renders from its registries and keeps what lives outside
+ the changed plugin. A component of a reloaded plugin is a new function, so React (or a terminal scope reopened by the
+ app) mounts it anew and its own state (`useState`) starts over. What must survive a plugin update (a half-typed
+ message, a scroll position) is **page state**: values the client host keeps by key (`usePageState(key, initial)` in
+ `chord/react`, `scope.pageState(key, initial)` on a `TuiScope`, one store per host), any same-host value, never on
+ the wire, untouched by reloads and gone with the page (a page reload, a terminal's new base bundle). Rejected: React
+ Fast Refresh (a Babel or SWC transform in every web build, fragile when hooks change).
- **No factory trial, no migrate dry run, no consumer contract check during staging:** staging records declarations;
incompatible consumers wait at bind (`failed { waiting }`, `contract_mismatch`). The one exception is the closure
rule above: when a plugin of the reload failed, a changed plugin's recorded requests are checked as a bind would
@@ -2312,7 +2320,9 @@ Decisions "The app kit, point 1" to "point 5". `startServer(options)` on the ser
entry. Opinionated and small: an app is its API, its built-in plugins and a start script.
- **Shape:** the app's layout is fixed: `src/api/v/` (shared and per-kind modules) and the built-in plugins root
- `plugins/`. The data directory is a `startServer` option (no environment variables), default a dot directory named after
+ `plugins/`. The API is a real package (decision "Tutorial gaps" (3)): a workspace package of the app's repository
+ (`"name": "@acme/app"`, `"exports": { "./v1": ..., "./v1/web": ... }`) that npm links, so Node, TypeScript, tests and
+ plugin builds resolve it alike (a test imports a plugin's `contract.ts`). The data directory is a `startServer` option (no environment variables), default a dot directory named after
the app in the home directory. One server; worker kinds are a list the app defines, each a host kind with its own
params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server
with clients only.
@@ -2334,7 +2344,7 @@ entry. Opinionated and small: an app is its API, its built-in plugins and a star
### Web and TUI (slice 11)
-Decision "Slice 11's points" and "Slice 11, P3" to "P9".
+Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (page state).
- `chord/surfaces` serves the client surfaces (web and terminal) from the server; `chord/web` is a page's client host
with its connection loop (p2's `keepConnected`), loading and applying the plugins' web facets. A base bundle per
@@ -2342,13 +2352,15 @@ Decision "Slice 11's points" and "Slice 11, P3" to "P9".
one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no
eval); new scripts apply in place (`host.reload` in the page); pages learn of them through an object the web
component serves (the current set); a base code change reloads the page.
-- `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`.
+- `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`, and `usePageState(key, initial)`:
+ `useState`'s pair kept by the page's host under a key, so a component remounted by a plugin reload (a new function)
+ keeps it (§21, "UI on client hosts").
- `chord/tui/launcher`: a Node client connecting with a token, always running the downloaded code (the app's terminal
entry on `chord/tui`) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes
with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen
substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its
own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the
- app's redraw), no pi-tui dependency.
+ app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`), no pi-tui dependency.
### Messages and languages (after slice 11)
diff --git a/packages/chord/docs/guide-plugins-hosts/book.md b/packages/chord/docs/guide-plugins-hosts/book.md
index 33ec92d8a..1a6802c0a 100644
--- a/packages/chord/docs/guide-plugins-hosts/book.md
+++ b/packages/chord/docs/guide-plugins-hosts/book.md
@@ -625,6 +625,12 @@ The tab's reference follows the new object and gets one frame with the differenc
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.
+A tab applies a new web script the same way, without reloading the page, and the UI keeps what lives outside the
+changed plugin. The changed plugin's own components are new functions, so React mounts them anew and their `useState`
+starts over. What must survive the update (a half-typed note, a scroll position) goes into **page state**, kept by the
+tab's host under a key: `usePageState(key, initial)` from `chord/react` instead of `useState`, and
+`scope.pageState(key, initial)` on a terminal's `TuiScope` (design §21, "UI on client hosts").
+
:::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.
diff --git a/packages/chord/docs/guide-plugins-hosts/index.html b/packages/chord/docs/guide-plugins-hosts/index.html
index 452e583a9..349975217 100644
--- a/packages/chord/docs/guide-plugins-hosts/index.html
+++ b/packages/chord/docs/guide-plugins-hosts/index.html
@@ -583,6 +583,11 @@ then gives every object of the changed plugin its fate:
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.
+
A tab applies a new web script the same way, without reloading the page, and the UI keeps what lives outside the
+changed plugin. The changed plugin's own components are new functions, so React mounts them anew and their useState
+starts over. What must survive the update (a half-typed note, a scroll position) goes into page state, kept by the
+tab's host under a key: usePageState(key, initial) from chord/react instead of useState, and
+scope.pageState(key, initial) on a terminal's TuiScope (design §21, "UI on client hosts").
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.
diff --git a/packages/chord/docs/plugin-authoring.md b/packages/chord/docs/plugin-authoring.md
index 45a5cc924..1598dcbe1 100644
--- a/packages/chord/docs/plugin-authoring.md
+++ b/packages/chord/docs/plugin-authoring.md
@@ -97,6 +97,37 @@ Refused at build, each with the file, line and rule:
Contract files mention native things (React components, functions) only through `import type`; at runtime they import
only the API and the plugin's own data-only files.
+## An app's API is a package
+
+The app's API is a workspace package of the app's repository: a `package.json` named `@acme/app` with an `exports`
+entry per module, listed in the repository root's `workspaces`, so `npm install` links it into `node_modules`:
+
+```json
+{
+ "name": "@acme/app",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./v3": "./src/api/v3/index.ts",
+ "./v3/web": "./src/api/v3/web.ts",
+ "./v3/worker": "./src/api/v3/worker.ts"
+ }
+}
+```
+
+Plugins, tests and tools resolve it alike: a facet imports `@acme/app/v3`, and so do the app's base code, a test, and
+a plugin's `contract.ts` that a test or a script imports. In a plugin's build it stays a host package (left out of the
+script, the process's own instance passed in when the script is evaluated).
+
+## Errors
+
+Throw Chord's own errors where Chord's built-in checks would, from the API (it re-exports them):
+`invalid("args", "a note needs a title")` for arguments a method refuses (`"params"` for params a factory refuses),
+`forbidden("viewers only")` for a principal a factory or method refuses. The caller gets the same
+`{ code: "invalid", reason: "args" }` or `{ code: "forbidden" }` across every link. For codes of the plugin's own throw
+`new PluginError(code, message, details)`. Only the code, reason, message and JSON details cross a link, never a stack
+or a cause.
+
## Contracts and versions
A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import
@@ -114,7 +145,7 @@ No type check, no build hooks, no `dist` needed; generated files are committed.
process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup)
or keeps its old code (at a reload), reported with the error.
-## Styles and themes (web and terminal facets)
+## Styles, themes and state across reloads (web and terminal facets)
- A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a
file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping
@@ -123,6 +154,11 @@ or keeps its old code (at a reload), reported with the error.
- Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`,
`var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them.
- In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours.
+- A reload of the plugin remounts its components (each is a new function): their `useState` starts over. Keep what
+ must survive an update (a half-typed message, a scroll position) in page state, kept by the page's host under a key
+ prefixed with the plugin's name: `usePageState("stats.draft", "")` instead of `useState("")` (from the app's web
+ API); in a terminal facet `scope.pageState("stats.draft", "")` on the `TuiScope` it was given. Any value, never sent
+ anywhere, gone with the page.
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
@@ -162,8 +198,9 @@ await t.close(ctx);
```
The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary
-facets with fake data. A test declares the contracts it reads again (contracts are data: the same name, version and
-schema bind).
+facets with fake data. A test imports the API by its package name and the plugin's own contracts from its
+`contract.ts` (the build bundles its own copy; contracts are data: the same name, version and schema bind); contracts
+of plugins it has no files of, it declares again, as `Notes` above.
## Checklist
diff --git a/packages/chord/docs/tutorial/book.md b/packages/chord/docs/tutorial/book.md
index 65556ca0c..762ae170d 100644
--- a/packages/chord/docs/tutorial/book.md
+++ b/packages/chord/docs/tutorial/book.md
@@ -115,13 +115,17 @@ export {
type Ctx,
defineTable,
eq,
+ forbidden,
int,
+ invalid,
local,
method,
num,
obj,
PluginError,
query,
+ remote,
+ type Static,
str,
type Theme,
Themes,
@@ -129,6 +133,10 @@ export {
export type { TodoPrincipal } from "../../principals.ts";
```
+`invalid` and `forbidden` make Chord's own errors: a plugin refusing arguments throws `invalid("args", message)`,
+one refusing a caller `forbidden(message)`, and the caller gets the same `{ code, reason }` Chord's built-in checks
+give (chapter VIII). `PluginError` is for codes of the plugin's own.
+
and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`:
@@ -144,6 +152,31 @@ it evaluates it. The plugin sees the very `Themes` registry and `List` host kind
version in the path lets the app ship `v2` beside `v1` later; one facet uses one version (design §21, "The plugin
model").
+The API is also a real package: a **workspace package** of the app's repository, named in its `package.json` with an
+`exports` entry per module:
+
+
+```json
+{
+ "name": "@todo/app",
+ "version": "0.0.1",
+ "private": true,
+ "description": "The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins",
+ "type": "module",
+ "exports": {
+ "./v1": "./src/api/v1/index.ts",
+ "./v1/server": "./src/api/v1/server.ts",
+ "./v1/web": "./src/api/v1/web.ts",
+ "./v1/tui": "./src/api/v1/tui.ts"
+ }
+}
+```
+
+The repository's root `package.json` lists it in `workspaces` (here Chord's repository lists
+`packages/chord/test/examples/61-tutorial`), so `npm install` links `node_modules/@todo/app` to it. Then Node,
+TypeScript, the tests and esbuild resolve `@todo/app/v1` alike, from base code, from a plugin's files and from a test
+importing a plugin's `contract.ts`; plugin builds still leave it out and pass the process's instance.
+
## The start script
`startServer` takes what the app decides and does the rest. `start.ts` names the API with this process's modules, the
@@ -260,14 +293,21 @@ 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 **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. The app's
+host kinds are base code, in the API:
```ts
/** A todo list: a worker process of its own, with its own store. */
export const List = hostKind("list", { id: str() });
+```
+
+A `remote` contract says, with `hostAddress`, at which address its objects live. The contract for a list's todos is
+the lists plugin's, in its `contract.ts`, which its facets of every kind import, and so do the steps and its test:
+
+
+```ts
+import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
/** 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() });
@@ -276,8 +316,8 @@ export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: n
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.
+ * A list's todos as one principal sees them: 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,
@@ -394,6 +434,8 @@ writer, so it knows every change:
```ts
add: async ({ text, by }, ctx) => {
+ // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
+ if (text === "") throw invalid("args", "a todo needs text");
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.
@@ -401,6 +443,10 @@ writer, so it knows every change:
return null;
```
+A todo without text is refused with `invalid("args", …)`, from the API: the error crosses every link as it is, so a
+caller on a page or in a terminal gets `{ code: "invalid", reason: "args" }`, as for arguments that fail the
+contract's schema.
+
## The view per principal
`Todos` is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:
@@ -497,8 +543,9 @@ 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 { defineFacet } from "@todo/app/v1";
import { Http } from "@todo/app/v1/server";
+import { Todos } from "./contract.ts";
export default defineFacet((_host, self) => {
self.contribute(Http, {
@@ -566,7 +613,7 @@ for them:
import type { ComponentType } from "react";
import { defineRegistry } from "../../../../../../src/index.ts";
-export { useHold, useWatch } from "../../../../../../src/react/index.ts";
+export { useHold, usePageState, 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 };
@@ -612,9 +659,11 @@ when its items change:
```ts
function TodoList() {
- const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
+ const list = location.hash.slice(1) || "groceries";
+ const todos = useHold(Todos, { list });
const items = useWatch(todos?.items) ?? [];
- const [text, setText] = useState("");
+ // Kept by the page, not the component: the new code's component finds the half-typed todo.
+ const [text, setText] = usePageState(`lists.draft.${list}`, "");
const add = (event: { preventDefault(): void }) => {
event.preventDefault();
if (todos === undefined || text.trim() === "") return;
@@ -659,7 +708,8 @@ export default defineFacet((_host, self) => self.contribute(Panels, { title: "Gr
```
`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
+render. `useWatch` follows a member. `usePageState` is `useState` whose value the page keeps under a key instead of the
+component: chapter VIII shows why the half-typed todo needs it. 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):
@@ -977,16 +1027,14 @@ 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:
+Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
+plugin's worker facet: the view trims the text before the store sees it. 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 AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
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"));
@@ -999,9 +1047,11 @@ for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
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");
+const refused = await adas.add(" ", context).then(
+ () => "added",
+ (error: ChordError) => ({ code: error.code, reason: error.reason }),
+);
+console.log(" ada adds ' ':", refused);
```
@@ -1009,7 +1059,7 @@ console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignore
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
+ ada adds ' ': { code: 'invalid', reason: 'args' }
```
The watcher marked `@todo/lists` dirty when the file changed; `applyChanges` rebuilt it (one esbuild call per facet)
@@ -1026,7 +1076,9 @@ and rolled it out host by host. On each host Chord stages the new code (it runs
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
+`loading`, and her next add was trimmed. Her blank one is now empty after trimming, so the store refused it with
+`invalid("args", …)` (chapter II): her client got `{ code: "invalid", reason: "args" }`, Chord's own error, which
+crossed the worker's link and the server's as it was. 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
@@ -1049,16 +1101,20 @@ const BEFORE = `h(
wrote docs/tutorial/screens/page.png
wrote docs/tutorial/screens/harbor.png
wrote docs/tutorial/screens/edited.png
-typed "oat mi"; after the edit: ""
+typed "oat mi"; after the edit: "oat mi"
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.
+:::figure | After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.

:::
-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.
+The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel
+itself is new code: its component is a new function, which React mounts anew, so its own `useState` would start over
+(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is **page state**:
+`usePageState(key, initial)` keeps the value in the page's host under the key, not in the component, so the new
+component finds it; a terminal's `TuiScope` has the same store as `scope.pageState(key, initial)`. Page state is any
+value, never sent anywhere, and gone with the page.
# Moving a List {#export lede="Export a worker's data while it runs, import it under another address."}
@@ -1100,9 +1156,9 @@ loads it on a new host with an in-memory store, here at a list's address so it m
```ts
- let t: TestedPlugin;
+ let t: TestedPlugin;
beforeEach(async () => {
- t = await testPlugin(
+ t = await testPlugin(
fileURLToPath(new URL("./", import.meta.url)),
{
kind: "worker",
@@ -1124,7 +1180,7 @@ 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" });
+ const todos = await ctx.get(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);
@@ -1146,10 +1202,27 @@ 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.
+The test imports the API by its package name and `Todos` from the plugin's own `contract.ts`, as the facets do:
+
+
+```ts
+import * as v1 from "@todo/app/v1";
+```
+
+
+```ts
+import { Todos } from "./contract.ts";
+```
+
+Both resolve because the API is a workspace package npm linked (chapter I); the plugin's build bundles its own copy of
+`contract.ts`, and the two bind, since contracts are data (the same name, version and schema). A third test checks
+the refusal of chapter II:
+
+
+```ts
+ const refused = await todos.add("", ctx).catch((error: ChordError) => error);
+ expect(refused).toMatchObject({ code: "invalid", reason: "args" });
+```
# 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."}
@@ -1295,28 +1368,25 @@ a tutorial.
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.
+
+Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now `usePageState`,
+chapter VIII), a plugin's `contract.ts` could not be imported outside builds (the API is now a workspace package,
+chapter I), and plugins could not throw Chord's `invalid` (Chord exports `invalid` and `forbidden`, chapter II).
# 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` |
+| `package.json` | base | the API as the workspace package `@todo/app`, its `exports` |
+| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`), `defineFacet`, `List` |
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` |
-| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useWatch` |
+| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `usePageState`, `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/lists/` | plugin | contract (`Item`, `Todos`), 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 |
@@ -1331,9 +1401,11 @@ a tutorial.
| `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 |
+| `invalid(reason, message, details?)`, `forbidden(message, details?)` | II, VIII | Chord's errors, thrown by a plugin |
| `self.contribute(Http, { routes })` | III | routes, denied by default |
| `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page |
| `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal |
+| `usePageState(key, initial)`, `scope.pageState(key, initial)` | IV, VIII | state the page keeps across plugin reloads |
| `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 |
diff --git a/packages/chord/docs/tutorial/index.html b/packages/chord/docs/tutorial/index.html
index 4a74485f3..9f0f5cae9 100644
--- a/packages/chord/docs/tutorial/index.html
+++ b/packages/chord/docs/tutorial/index.html
@@ -216,18 +216,25 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
typeCtx,
defineTable,
eq,
+ forbidden,
int,
+ invalid,
local,
method,
num,
obj,
PluginError,
query,
+ remote,
+ typeStatic,
str,
typeTheme,
Themes,
} from"../../../../../../src/index.ts";
exporttype { TodoPrincipal } from"../../principals.ts";
+
invalid and forbidden make Chord's own errors: a plugin refusing arguments throws invalid("args", message),
+one refusing a caller forbidden(message), and the caller gets the same { code, reason } Chord's built-in checks
+give (chapter VIII). PluginError is for codes of the plugin's own.
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. */
@@ -238,6 +245,26 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
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 API is also a real package: a workspace package of the app's repository, named in its package.json with an
+exports entry per module:
+
+
{
+ "name": "@todo/app",
+ "version": "0.0.1",
+ "private": true,
+ "description": "The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins",
+ "type": "module",
+ "exports": {
+ "./v1": "./src/api/v1/index.ts",
+ "./v1/server": "./src/api/v1/server.ts",
+ "./v1/web": "./src/api/v1/web.ts",
+ "./v1/tui": "./src/api/v1/tui.ts"
+ }
+}
+
The repository's root package.json lists it in workspaces (here Chord's repository lists
+packages/chord/test/examples/61-tutorial), so npm install links node_modules/@todo/app to it. Then Node,
+TypeScript, the tests and esbuild resolve @todo/app/v1 alike, from base code, from a plugin's files and from a test
+importing a plugin's contract.ts; plugin builds still leave it out and pass the process's instance.
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:
@@ -331,12 +358,15 @@ chapter III). Nothing is the owner because it comes from loopback: any local pro
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 host kind declares addressable hosts with params; List({ id: "groceries" }) is an address. The app's
+host kinds are base code, in the API:
/** A todo list: a worker process of its own, with its own store. */
-exportconstList = hostKind("list", { id: str() });
+exportconstList = hostKind("list", { id: str() });
+
A remote contract says, with hostAddress, at which address its objects live. The contract for a list's todos is
+the lists plugin's, in its contract.ts, which its facets of every kind import, and so do the steps and its test:
+
+
import { arr, bool, int, List, method, num, obj, remote, typeStatic, str } from"@todo/app/v1";
/** A todo: its number in the list, its text, whether it is done, who added it and when. */exportconstItem = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
@@ -345,8 +375,8 @@ in the API so that every host kind, the tests and command-line code can name it:
exporttypeItem = Static<typeofItem>;
/**
- * 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.
+ * A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
+ * owner also clears the done ones.
*/exportconstTodos = remote("todo.todos", {
version: 1,
@@ -440,11 +470,16 @@ when fn returns, or not at all if it throws. The window follows aft
writer, so it knows every change:
add: async ({ text, by }, ctx) => {
+ // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
+ if (text === "") throwinvalid("args", "a todo needs text");
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));
returnnull;
+
A todo without text is refused with invalid("args", …), from the API: the error crosses every link as it is, so a
+caller on a page or in a terminal gets { code: "invalid", reason: "args" }, as for arguments that fail the
+contract's schema.
The view per principal
Todos is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:
@@ -518,8 +553,9 @@ contributes routes to the kit's Http registry, which the API
// 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 { defineFacet } from"@todo/app/v1";
import { Http } from"@todo/app/v1/server";
+import { Todos } from"./contract.ts";
exportdefaultdefineFacet((_host, self) => {
self.contribute(Http, {
@@ -573,7 +609,7 @@ for them:
importtype { ComponentType } from"react";
import { defineRegistry } from"../../../../../../src/index.ts";
-export { useHold, useWatch } from"../../../../../../src/react/index.ts";
+export { useHold, usePageState, useWatch } from"../../../../../../src/react/index.ts";
/** A panel of the page: its title and its React component. */exporttypePanel = { readonly title: string; readonlyComponent: ComponentType };
@@ -609,9 +645,11 @@ scripts with the entry's core facets, and keeps them current. HostProv
when its items change:
functionTodoList() {
- const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
+ const list = location.hash.slice(1) || "groceries";
+ const todos = useHold(Todos, { list });
const items = useWatch(todos?.items) ?? [];
- const [text, setText] = useState("");
+ // Kept by the page, not the component: the new code's component finds the half-typed todo.
+ const [text, setText] = usePageState(`lists.draft.${list}`, "");
const add = (event: { preventDefault(): void }) => {
event.preventDefault();
if (todos === undefined || text.trim() === "") return;
@@ -654,7 +692,8 @@ when its items change:
exportdefaultdefineFacet((_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
+render. useWatch follows a member. usePageState is useState whose value the page keeps under a key instead of the
+component: chapter VIII shows why the half-typed todo needs it. 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):
@@ -895,14 +934,12 @@ 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:
+
Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
+plugin's worker facet: the view trims the text before the store sees it. Step 8 makes the edit in the running app's
+copy of the plugins, as an editor or an agent would, and applies it:
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
@@ -953,7 +992,9 @@ and rolled it out host by host. On each host Chord stages the new code (it runs
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
+loading, and her next add was trimmed. Her blank one is now empty after trimming, so the store refused it with
+invalid("args", …) (chapter II): her client got { code: "invalid", reason: "args" }, Chord's own error, which
+crossed the worker's link and the server's as it was. 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
@@ -968,12 +1009,16 @@ page reload. screenshots.ts adds a count of open todos to the panel
wrote docs/tutorial/screens/page.png
wrote docs/tutorial/screens/harbor.png
wrote docs/tutorial/screens/edited.png
-typed "oat mi"; after the edit: ""
+typed "oat mi"; after the edit: "oat mi"
the page did not reload: true
-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.
+Figure 3. After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.
+
The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel
+itself is new code: its component is a new function, which React mounts anew, so its own useState would start over
+(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is page state:
+usePageState(key, initial) keeps the value in the page's host under the key, not in the component, so the new
+component finds it; a terminal's TuiScope has the same store as scope.pageState(key, initial). Page state is any
+value, never sent anywhere, and gone with the page.
IX
Moving a List
Export a worker's data while it runs, import it under another address.
@@ -1006,9 +1051,9 @@ an index of the app's, if it had one, is the app's.
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>;
+
let t: TestedPlugin<v1.TodoPrincipal>;
beforeEach(async () => {
- t = awaittestPlugin<TodoPrincipal>(
+ t = awaittestPlugin<v1.TodoPrincipal>(
fileURLToPath(newURL("./", import.meta.url)),
{
kind: "worker",
@@ -1026,7 +1071,7 @@ loads it on a new host with an in-memory store, here at a list's address so
it("lets the owner clear the done todos", async () => {
await t.root(
async (ctx) => {
- const todos = await ctx.get(v1.Todos, { list: "groceries" });
+ const todos = await ctx.get(Todos, { list: "groceries" });
if (todos.variant !== "owner") thrownewError(`the owner got ${todos.variant}`);
await todos.add("milk", ctx);
await todos.add("eggs", ctx);
@@ -1042,10 +1087,17 @@ loads it on a new host with an in-memory store, here at a list's address so
knows which stub facets to add. Registries it contributes to that nobody serves are collected (t.contributions(R)).
Run it like any test:
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.
+
The test imports the API by its package name and Todos from the plugin's own contract.ts, as the facets do:
+
+
import * as v1 from"@todo/app/v1";
+
+
import { Todos } from"./contract.ts";
+
Both resolve because the API is a workspace package npm linked (chapter I); the plugin's build bundles its own copy of
+contract.ts, and the two bind, since contracts are data (the same name, version and schema). A third test checks
+the refusal of chapter II:
What is different in p2, and one small plugin: a tool for the agent and a chip for the people.
@@ -1207,15 +1259,10 @@ a tutorial.
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.
+
Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now usePageState,
+chapter VIII), a plugin's contract.ts could not be imported outside builds (the API is now a workspace package,
+chapter I), and plugins could not throw Chord's invalid (Chord exports invalid and forbidden, chapter II).
A
The App's Files
@@ -1233,9 +1280,14 @@ moved into the API.
the principal type
+
package.json
+
base
+
the API as the workspace package @todo/app, its exports
+
+
src/api/v1/index.ts
base, every kind
-
@todo/app/v1: Chord's part, defineFacet, List, Item, Todos
+
@todo/app/v1: Chord's part (with invalid, forbidden), defineFacet, List
src/api/v1/server.ts
@@ -1245,7 +1297,7 @@ moved into the API.
src/api/v1/web.ts
base, web
-
Panels, useHold, useWatch
+
Panels, useHold, usePageState, useWatch
src/api/v1/tui.ts
@@ -1270,7 +1322,7 @@ moved into the API.
plugins/lists/
plugin
-
worker (table, store, views), server (routes), web (panel), tui (screen), its test
+
contract (Item, Todos), worker (table, store, views), server (routes), web (panel), tui (screen), its test
diff --git a/packages/chord/docs/tutorial/screens/edited.png b/packages/chord/docs/tutorial/screens/edited.png
index b04a94b9b..382e4f780 100644
Binary files a/packages/chord/docs/tutorial/screens/edited.png and b/packages/chord/docs/tutorial/screens/edited.png differ
diff --git a/packages/chord/src/data/errors.ts b/packages/chord/src/data/errors.ts
index a47461581..d992fca0e 100644
--- a/packages/chord/src/data/errors.ts
+++ b/packages/chord/src/data/errors.ts
@@ -99,7 +99,7 @@ export class PluginError extends Error {
}
}
-/** An `invalid` error with the given reason. */
+/** An `invalid` error with the given reason: a plugin throws it for arguments or params it refuses. */
export function invalid(
reason: ChordErrorReason<"invalid">,
message: string,
@@ -109,6 +109,15 @@ export function invalid(
return new ChordError({ code: "invalid", reason, message, details }) as Extract;
}
+/**
+ * A `forbidden` error without a reason: what a plugin or an `authorize` throws for a principal it refuses (`revoked` is
+ * Chord's own, for a revoked authority).
+ */
+export function forbidden(message: string, details?: JsonValue): Extract {
+ // The constructor's return type cannot narrow on its argument.
+ return new ChordError({ code: "forbidden", message, details }) as Extract;
+}
+
/** `error` itself when it is a ChordError, otherwise an `unavailable` error naming `label`, with `error` as cause. */
export function asChordError(error: unknown, label: string): ChordError {
if (isChordError(error)) return error;
diff --git a/packages/chord/src/data/index.ts b/packages/chord/src/data/index.ts
index 3eb2015e8..4eadb7810 100644
--- a/packages/chord/src/data/index.ts
+++ b/packages/chord/src/data/index.ts
@@ -1,9 +1,12 @@
-// Data for apps: Chord's errors and plugin errors, principals, and how a plugin's open reports a broken configuration.
+// Data for apps: Chord's errors (with `invalid` and `forbidden`, the two a plugin throws), plugin errors, principals,
+// and how a plugin's open reports a broken configuration.
export {
ChordError,
type ChordErrorCode,
type ChordErrorReason,
+ forbidden,
+ invalid,
isChordError,
PluginError,
} from "./errors.ts";
diff --git a/packages/chord/src/host/route.ts b/packages/chord/src/host/route.ts
index 7a1fb2a8c..6f3fc5f4f 100644
--- a/packages/chord/src/host/route.ts
+++ b/packages/chord/src/host/route.ts
@@ -6,7 +6,7 @@
import type { Context } from "../context/index.ts";
import type { Address, HostKind, KindShape } from "../contracts/index.ts";
import { kindParams, type ParamsData } from "../contracts/internal.ts";
-import { ChordError, type Principal } from "../data/index.ts";
+import { ChordError, forbidden, type Principal } from "../data/index.ts";
import { asChordError, checkAddress } from "../data/internal.ts";
import type { Codec, Link, PartLimits } from "../link/index.ts";
import { type HostLink, Path, type PathEvent, type Upstream } from "../remote/internal.ts";
@@ -87,7 +87,7 @@ export class Route {
path(params: unknown, principal: Principal | undefined, context: Context): Path {
const address = checkAddress(this.kind, this.#params, params, context);
if (principal === undefined || !this.#options.authorize(principal, address)) {
- throw new ChordError({ code: "forbidden", message: `${address.key}: not authorized` });
+ throw forbidden(`${address.key}: not authorized`);
}
let routed = this.#addresses.get(address.key);
if (routed === undefined) {
diff --git a/packages/chord/src/node/app/data.ts b/packages/chord/src/node/app/data.ts
index 0bad28c67..a2b5511e1 100644
--- a/packages/chord/src/node/app/data.ts
+++ b/packages/chord/src/node/app/data.ts
@@ -5,7 +5,7 @@
import { BACKGROUND_CONTEXT } from "../../context/index.ts";
import { type Address, method, obj, remote, str } from "../../contracts/index.ts";
-import { ChordError, type Principal } from "../../data/index.ts";
+import { forbidden, type Principal } from "../../data/index.ts";
import { canonicalJson } from "../../data/principal.ts";
import { defineFacet, type Facet } from "../../host/index.ts";
import type { JsonValue } from "../../json.ts";
@@ -35,7 +35,7 @@ export function hostDataFacet
(store: Store, files: string,
return defineFacet
((host) =>
host.serve(HostData, (_params, principal) => {
if (canonicalJson(principal as JsonValue, BACKGROUND_CONTEXT) !== serverKey) {
- throw new ChordError({ code: "forbidden", message: "chord.app.data: the server's only" });
+ throw forbidden("chord.app.data: the server's only");
}
return {
variant: "main",
diff --git a/packages/chord/src/react/README.md b/packages/chord/src/react/README.md
index a140a3a56..68209cae5 100644
--- a/packages/chord/src/react/README.md
+++ b/packages/chord/src/react/README.md
@@ -9,6 +9,10 @@ peer), non-React apps never load it. No components, no styling.
- `useWatch(watchable)`: its current value (`undefined` while it has none), re-rendering on change
(`useSyncExternalStore` over `watchStore`).
- `useRegistry(R)`: the registry's composed value; `useRefState(ref)`: the reference's state.
-- A component unmounted by a reload has not left (design §21): its releases are plain releases.
+- `usePageState(key, initial)`: `useState`'s pair, the value kept by the page (its host's life) under `key`
+ (`surfaces/page-state.ts`, shared with `TuiScope.pageState`), so a component remounted by a plugin reload keeps it.
+- A component unmounted by a reload has not left (design §21): its releases are plain releases. A component of a
+ reloaded plugin is a new function and remounts, its `useState` starting over: what must survive is page state.
-Tests: `test/node/react/` (`stores.test.ts` in Node, `hooks.test.ts` in a real browser).
+Tests: `test/node/react/` (`stores.test.ts` in Node, `hooks.test.ts` in a real browser); the page state alone in
+`test/surfaces/page-state.test.ts`.
diff --git a/packages/chord/src/react/hooks.ts b/packages/chord/src/react/hooks.ts
index 3cfc08707..8abdf1ac9 100644
--- a/packages/chord/src/react/hooks.ts
+++ b/packages/chord/src/react/hooks.ts
@@ -1,7 +1,8 @@
// React bindings (slice 11 §3, decision "Slice 11, P7"; p2's `useObject`, `useReadable`, `useRegistry`): a host in
-// context, references held for a component's life, and watchables read with `useSyncExternalStore`. A component
-// unmounted by a reload has not left (design §21): its releases are plain releases, presence is the app's. No
-// components, no styling.
+// context, references held for a component's life, watchables read with `useSyncExternalStore`, and page state kept
+// for the host's life. A component unmounted by a reload has not left (design §21): its releases are plain releases,
+// presence is the app's. A component of a reloaded plugin is a new function and remounts, its `useState` starting
+// over; what must survive is page state (decision "Tutorial gaps" (1)). No components, no styling.
import {
createContext,
@@ -19,6 +20,7 @@ import type { Principal } from "../data/index.ts";
import { canonicalJson } from "../data/principal.ts";
import type { Held, Host, RefState } from "../host/index.ts";
import type { Registry } from "../registries/index.ts";
+import { pageState } from "../surfaces/page-state.ts";
import type { NativeWatchable, Watchable } from "../watch.ts";
import { watchStore } from "./stores.ts";
@@ -77,3 +79,13 @@ export function useRegistry(R: Registry): V | undefined {
export function useRefState(ref: { readonly state: NativeWatchable } | undefined): RefState | undefined {
return useWatch(ref?.state);
}
+
+/**
+ * A value the page keeps under `key` for its host's life, not the component's, with its setter (`useState`'s pair): a
+ * component remounted by a plugin reload, a new function, finds what it had; a new key starts at `initial`. Any value
+ * (it never crosses a link); gone with the page.
+ */
+export function usePageState(key: string, initial: T): readonly [T, (value: T) => void] {
+ const state = pageState(useHost(), key, initial);
+ return [useSyncExternalStore(state.subscribe, state.read, state.read), state.set];
+}
diff --git a/packages/chord/src/react/index.ts b/packages/chord/src/react/index.ts
index eac53ffdf..b97aad21a 100644
--- a/packages/chord/src/react/index.ts
+++ b/packages/chord/src/react/index.ts
@@ -1,4 +1,4 @@
// Chord v2's React bindings (`@earendil-works/chord/react`; slice 11 §3): optional, non-React apps never load them.
-export { HostProvider, useHold, useHost, useRefState, useRegistry, useWatch } from "./hooks.ts";
+export { HostProvider, useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "./hooks.ts";
export { type ExternalStore, watchStore } from "./stores.ts";
diff --git a/packages/chord/src/surfaces/page-state.ts b/packages/chord/src/surfaces/page-state.ts
new file mode 100644
index 000000000..6980fed85
--- /dev/null
+++ b/packages/chord/src/surfaces/page-state.ts
@@ -0,0 +1,56 @@
+// Page state (decision "Tutorial gaps" (1), refining "Slice 11, P4"): values a client host keeps for its UI by key,
+// beside its plugins, so a component of a reloaded plugin (a new function, which React or a terminal scope mounts
+// anew) finds what it had: a half-typed message, a scroll position. Same-host values of any kind, never on the wire;
+// gone with the host (a page reload, a terminal's new base bundle). `usePageState` and `TuiScope.pageState` read it.
+
+import type { Principal } from "../data/index.ts";
+import type { Host } from "../host/index.ts";
+
+/** One key's value in a host's page state. Its functions are bound: React's `useSyncExternalStore` takes them as is. */
+export type PageState = {
+ /** The value now. */
+ readonly read: () => T;
+ /** Sets the value; a new value (by identity) calls every subscriber. */
+ readonly set: (value: T) => void;
+ /** Calls `changed` after every set that changed the value, until the returned unsubscribe. */
+ readonly subscribe: (changed: () => void) => () => void;
+};
+
+/** Each client host's page state by key, kept as long as the host. */
+const PAGE_STATES = new WeakMap