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