Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
7.7 KiB
@earendil-works/chord
Chord builds applications out of objects that live in several processes (a server, workers, browser tabs) and can be hot reloaded while they run.
- An object is state plus code at an address. It is created on demand and lives while someone references it.
- Everyone gets a reference, never the object. References work the same locally and across processes, and survive reloads.
- Every object knows its principal: an owner and a guest get different objects, with different methods.
- State is observable JSON. Remote viewers get a snapshot followed by deltas.
- Hot reload swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.
Contracts are runtime values described with TypeBox schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.
Entry points
| Import | Contents | Runs in |
|---|---|---|
@earendil-works/chord |
Contracts (local, remote, method, opaque), declarations (through the factory's self: state, derive, resource, on, task), hosts (createHost, defineFacet, defineRegistry), links (memoryLinkPair, ipcLink, webSocketLink, acceptWebSockets), errors (ChordError, isChordError, forbidden, invalid). Also the shared base: Context, JsonValue, JsonRepresentation, Draft, copyJson, isJsonValue, and replicatedState with its types. |
Node and browsers |
@earendil-works/chord/node |
Child host processes: createHostManager, forkDriver, childControl, processLock; the facet module loader nodeLoader. |
Node |
@earendil-works/chord/context |
Explicit cancellation and invocation values: BACKGROUND_CONTEXT, withCancel, withAbortSignal, withoutAbortSignal, withContextValue, awaitWithContext. |
Node and browsers |
@earendil-works/chord/delta |
The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. | Node and browsers |
@earendil-works/chord/output |
Bounded output streams and paced progress: OutputBuffer (head or tail window of a stream, OutputLimits, OutputSkip), boundOutput, sanitizeOutput, utf8ByteLength, and Progress (adaptive commits of a changing value). |
Node and browsers |
Quick start
import { Type } from "typebox";
import { createHost, hostNamed, method, remote } from "@earendil-works/chord";
// contract.ts: shared by provider and consumers
export const Counter = remote("demo.counter", {
version: 1,
input: Type.Object({ name: Type.String() }),
host: hostNamed("server"), // where its objects live; callers never name a host
variants: {
default: {
state: { count: Type.Number() },
methods: { increment: method(Type.Object({ by: Type.Number() })) },
},
},
});
const host = createHost({ name: "server", self: { kind: "server" } });
host.serve(Counter, ({ name }, _principal, self) => {
const count = self.state("count", Type.Number(), 0);
return {
variant: "default",
count,
increment: async ({ by }, ctx) => {
count.change(ctx, (n) => n + by);
},
};
});
await host.root(async (ctx) => {
const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends
clicks.count.subscribe((n) => console.log("count", n));
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
});
Documentation
- Tutorial: a shared todo list, built up section by section. Every example runs as
test/tutorial.test.ts. - Usage guide: API reference for contracts, objects, state, roots, lifetime, plugins, hot reload, hosts, and links.
- Specification: compatibility, lifetime, and reload semantics, plus known limitations and implementation decisions.
- Delta guide: ownership, lifecycle, operation, and replica contracts of
@earendil-works/chord/delta.
Tracking JSON deltas
Import the standalone transactional tracker from @earendil-works/chord/delta:
import { applyImmutable, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "", count: 0 });
const change = tracker.beginChange();
change.state.output += "done\n";
change.state.count += 1;
const prepared = change.prepare();
tracker.adopt(prepared);
const replica = applyImmutable(prepared.base, prepared.ops);
tracker.value is always the latest adopted immutable revision. Preparation does
not change authority; adoption validates the preparation and swaps the root pointer.
Assigned containers are copied by value and unchanged subtrees may be shared between
revisions.
The tracker uses trusted immutable ownership rather than defensive copying or
freezing. track(initial), prepareReplace(value), replicatedState(initial), and
replace(context, value) take ownership of alias-free strict-JSON roots without
walking them. Callers must not mutate transferred or published data. Values placed
through drafts are already copied, so that walk also rejects non-strict JSON before
the draft changes. Published values are not frozen. In-process consumers may share
their containers with the provider; mutating a consumed value violates the contract
and can corrupt authority. Clone or serialize at any mutable trust boundary.
Replicated state provides the same model through a callback. replicatedState is a
standalone observable value, independent of hosts; Durable uses it for document,
view, and task-graph states:
import { replicatedState } from "@earendil-works/chord";
const initial = { output: "", count: 0 };
const status = replicatedState(initial); // transfers ownership of initial
status.change(context, (draft) => {
draft.output += "done\n";
draft.count += 1;
});
A successful change() publishes exactly one atomic revision. If its callback
throws, the original value and sequence remain unchanged. Draft references become
unusable when the callback returns. The tracker emits string append and
front-truncate operations, array splices and permutations, sets, and deletes; large
edit sets may fold into a complete replacement.
replicatedState(source, { onError }) instead attaches a publication-only state to
an authoritative revision source (ReplicatedStateSource): it captures a snapshot,
then publishes every committed frame in cursor order.
Public state subscriptions
state.subscribe(async (value, context, delivery) => { ... }) serializes callbacks
independently for each subscription. Hydration starts immediately when a value is
available, and its returned promise must settle before updates start. Synchronous
callbacks still run synchronously. Read the captured value inside an asynchronous
callback: state.value may already refer to a later revision.
Each subscription retains at most 100 pending complete-value deliveries, excluding the running callback. On overflow, only the newest pending value, context, and delivery metadata are retained; a not-yet-started initial hydration is preserved. Thus public delivery sequences may skip. This is a frame-count policy, not a byte limit.
The returned unsubscribe function immediately discards pending work and prevents
new callbacks. It neither aborts nor waits for the running callback. Synchronous
throws and promise rejections are observed, reported, and do not stop other
subscriptions or subsequent callbacks. Attached sources use their onError
handler; mutable states (and attached sources without a handler) report failures
by throwing in a microtask. Unsubscribing does not hide a later rejection from the
running callback.
Cells of Chord objects and their remote readables have their own subscription model; see State in the usage guide.