feat(chord): Chord v2 is the package's root

src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
This commit is contained in:
Mario Zechner 2026-11-02 22:36:54 +00:00
parent 0a4fad5489
commit e9c0ff4d84
966 changed files with 2968 additions and 2804 deletions

View file

@ -0,0 +1,68 @@
// Objects on one host: a contract, a facet serving it, references, calls, and how long objects live.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/01-objects.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, method, obj, str } from "../../src/contracts/index.ts";
import { isChordError } from "../../src/data/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { constant } from "../../src/state/index.ts";
// Every call takes a context, used for cancellation; BACKGROUND_CONTEXT is never cancelled.
const context = BACKGROUND_CONTEXT;
// A contract declares what an object type offers: state members (schemas) and methods (arguments, result).
// `params` select the instance: one object per params value.
const Greeter = local("example.greeter", {
version: 1,
params: { language: str() },
members: { language: str(), greet: method(obj({ name: str() }), str()) },
});
// Empty params: one object for the whole host.
const Clock = local("example.clock", { version: 1, params: {}, members: { started: int() } });
// A facet is a plugin's setup for one host. It serves types with factories; a factory only declares and returns the
// implementation: a watchable per state member, an async function per method.
const facet = defineFacet((host) => {
host.serve(Clock, () => {
console.log("clock: created at load");
return { started: constant(Date.now()) };
});
host.serve(Greeter, ({ language }) => {
console.log(`greeter ${language}: created by its first get`);
return {
language: constant(language),
greet: async ({ name }) => (language === "de" ? `Hallo ${name}` : `Hello ${name}`),
};
});
});
// onError receives background errors, such as a watch listener that throws.
const host = createHost({ onError: (error) => console.error("background error:", error) });
// load runs every setup and activates the types with empty params before it resolves.
await host.load([{ name: "@example/greeter", facet }], context);
// A reference is live once its get resolves: its state members have values, its methods can be called.
const german = await host.get(Greeter, { language: "de" }, context);
console.log("language:", german.language.value);
console.log("greet:", await german.greet({ name: "Mario" }, context));
// Arguments are checked against the contract at the object.
try {
// @ts-expect-error: `name` must be a string
await german.greet({ name: 42 }, context);
} catch (error) {
if (isChordError(error, "invalid")) console.log("refused:", error.reason, error.message);
}
// The same params share one object; releasing the last reference ends it.
const again = await host.get(Greeter, { language: "de" }, context);
german.release();
again.release();
try {
german.language.value;
} catch (error) {
if (isChordError(error, "released")) console.log("after release:", error.message);
}
// close ends every object.
await host.close(context);

View file

@ -0,0 +1,79 @@
// Documents and transactions: an object's in-memory JSON state, written atomically.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/02-documents.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, int, local, method, obj, opt, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
// A singleton document: one per object, `initial()` until written.
const Todos = defineState("todos", {
schema: obj({ items: arr(obj({ id: int(), text: str() })), next: int() }),
version: 1,
initial: () => ({ items: [], next: 1 }),
});
// A keyed document: many per object, one per string key, absent until written.
const Notes = defineState("notes", {
schema: obj({ body: str() }),
version: 1,
initial: () => ({ body: "" }),
keyed: true,
});
const Store = local("example.todos", {
version: 1,
params: {},
members: {
todos: obj({ items: arr(obj({ id: int(), text: str() })), next: int() }),
add: method(obj({ text: str(), note: opt(str()) }), int()),
note: method(obj({ id: int() }), str()),
clear: method(),
},
});
const facet = defineFacet((host) => {
host.serve(Store, (_, self) => {
const todos = self.state(Todos);
const notes = self.state(Notes);
return {
// A singleton handle is a watchable, so it can be a state member as it is.
todos,
add: async ({ text, note }, ctx) => {
// One transaction over two documents: both change together, or neither does.
const id = await self.tx(async (tx) => {
const draft = await tx.draft(todos);
const id = draft.next++;
draft.items.push({ id, text });
if (note !== undefined) (await tx.draft(notes, String(id))).body = note;
// Return plain values: drafts are revoked once the transaction settles.
return id;
}, ctx);
return id;
},
// Read keyed documents outside transactions; an absent one is `undefined`.
note: async ({ id }, ctx) => (await notes.get(String(id), ctx))?.body ?? "(none)",
clear: async (ctx) => {
// Shorthands for one document: retire resets a singleton to `initial()`.
await todos.retire(ctx);
for await (const key of notes.keys(ctx)) await notes.retire(key, ctx);
return null;
},
};
});
});
const host = createHost({ onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/todos", facet }], context);
const store = await host.get(Store, {}, context);
const first = await store.add({ text: "buy milk", note: "the oat one" }, context);
await store.add({ text: "call Anna" }, context);
console.log("todos:", store.todos.value.items);
console.log("note of", first, "->", await store.note({ id: first }, context));
await store.clear(context);
console.log("after clear:", store.todos.value, await store.note({ id: first }, context));
await host.close(context);

View file

@ -0,0 +1,66 @@
// Derives: values computed from documents and from other objects' members, recomputed only when needed.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/03-derives.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, int, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { mapShared } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
const NotesDoc = defineState("notes", {
schema: obj({ list: arr(obj({ text: str() })) }),
version: 1,
initial: () => ({ list: [] }),
});
const Notes = local("example.notes", {
version: 1,
params: {},
members: { titles: arr(str()), count: int(), add: method(obj({ text: str() })) },
});
const Badge = local("example.badge", { version: 1, params: { user: str() }, members: { label: str() } });
const facet = defineFacet((host) => {
host.serve(Notes, (_, self) => {
const notes = self.state(NotesDoc);
// mapShared calls its function once per note object: unchanged notes keep the same title, so the derive's
// diff after an append is one insert, not a comparison of every title.
const title = mapShared((note: { text: string }) => {
console.log(" title computed for:", note.text);
return note.text.slice(0, 12);
});
return {
// A derive declares its sources; its compute is synchronous and pure.
titles: self.derive([notes], ([n]) => title(n.list)),
count: self.derive([notes], ([n]) => n.list.length),
add: async ({ text }, ctx) => {
await notes.change((draft) => {
draft.list.push({ text });
}, ctx);
return null;
},
};
});
// A derive over another object's member, through a reference declared with self.get.
host.serve(Badge, ({ user }, self) => {
const notes = self.get(Notes, {});
return { label: self.derive([notes.count], ([count]) => `${user}: ${count} notes`) };
});
});
const host = createHost({ onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/notes", facet }], context);
const notes = await host.get(Notes, {}, context);
const badge = await host.get(Badge, { user: "mario" }, context);
await notes.add({ text: "buy milk and bread" }, context);
await notes.add({ text: "call Anna" }, context);
// Nobody watches `titles`, so the commits only marked it; reading computes it, each title once.
console.log("titles:", notes.titles.value);
await notes.add({ text: "water plants" }, context);
console.log("titles:", notes.titles.value);
console.log("badge:", badge.label.value);
await host.close(context);

View file

@ -0,0 +1,101 @@
// Watches: follow a state member from its current value through the commits that change it; a busy listener gets
// what changed meanwhile folded into one call; a derive that throws gives a `failed` call, the watch stays open, and
// its next value arrives as a root replacement (it heals on a later change).
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/04-watches.ts
import { BACKGROUND_CONTEXT, withCancel } from "../../src/context/index.ts";
import { int, local, method, obj } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
const Count = defineState("count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
const Counter = local("example.counter", {
version: 1,
params: {},
members: { count: obj({ n: int() }), double: int(), half: int(), bump: method(), reset: method() },
});
const facet = defineFacet((host) => {
host.serve(Counter, (_, self) => {
const count = self.state(Count);
return {
count,
double: self.derive([count], ([c]) => c.n * 2),
half: self.derive([count], ([c]) => {
if (c.n % 2 === 1) throw new Error(`${c.n} has no half`);
return c.n / 2;
}),
bump: async (ctx) => {
await count.change((draft) => {
draft.n++;
}, ctx);
return null;
},
reset: async (ctx) => {
await count.retire(ctx);
return null;
},
};
});
});
// A derive whose compute throws is reported once per failure episode (`derive_failed`).
const host = createHost({
onError: (error) => {
if (error.kind === "derive_failed") console.log(`reported: ${error.object}.${error.member}: ${error.error}`);
else console.error("background error:", error);
},
});
await host.load([{ name: "@example/counter", facet }], context);
const counter = await host.get(Counter, {}, context);
// A watch holds the value at acquisition; its listener then gets the changes, asynchronously and in order, never
// overlapping itself. A listener that keeps up gets one call per commit: the new value and the ops that turn the
// previous value into it.
const watch = await counter.count.watch(context);
console.log("acquired:", watch.value);
watch.start(async (call) => {
if (call.kind === "value") console.log("count frame:", call.value, JSON.stringify(call.ops));
});
// A derive's frame carries the diff of its old and new value.
const { context: cancellable, cancel } = withCancel(context);
const doubled = await counter.double.watch(cancellable);
doubled.start(async (call) => {
if (call.kind === "value") console.log("double frame:", call.value, JSON.stringify(call.ops));
});
// A failing derive: one `failed` call per failure, the watch stays open; the next value replaces the root.
const half = await counter.half.watch(context);
half.start(async (call) => {
if (call.kind === "failed") console.log("half failed:", (call.error as Error).message);
else console.log("half frame:", call.value, JSON.stringify(call.ops));
});
// A slow listener: commits made while it is busy fold into its next call, with the newest value and the ops since
// its previous call (an `s` on one path replaces the one before it).
const slow = await counter.count.watch(context);
slow.start(async (call) => {
if (call.kind !== "value") return;
console.log("slow frame:", call.value, JSON.stringify(call.ops));
await new Promise((resolve) => setTimeout(resolve, 20));
});
await counter.bump(context);
await new Promise((resolve) => setTimeout(resolve, 0));
await counter.bump(context);
await counter.bump(context);
await new Promise((resolve) => setTimeout(resolve, 50));
// Retiring the singleton resets it to initial(): watches get a root replacement.
await counter.reset(context);
await new Promise((resolve) => setTimeout(resolve, 50));
// Cancelling the watch's context ends it; stop() ends it too. Releasing the reference ends the watches taken through it.
cancel();
console.log("double watch:", await doubled.closed);
counter.release();
console.log("count watch:", await watch.closed);
await host.close(context);

View file

@ -0,0 +1,94 @@
// Errors: what Chord refuses and how a caller tells the cases apart (`ChordError` with `code` and `reason`).
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/05-errors.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, method, obj, str } from "../../src/contracts/index.ts";
import { isChordError } from "../../src/data/index.ts";
import { createHost, defineFacet, type HostError } from "../../src/host/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
/** Prints how `work` failed: a ChordError's code, reason and message, or a plain error's message. */
async function attempt(label: string, work: () => unknown): Promise<void> {
try {
await work();
console.log(`${label}: ok`);
} catch (error) {
if (isChordError(error))
console.log(`${label}: ${error.code}${error.reason ? `/${error.reason}` : ""} - ${error.message}`);
else console.log(`${label}: ${error instanceof Error ? error.message : String(error)}`);
}
}
const Echo = local("example.echo", {
version: 1,
params: { id: int() },
members: { id: int(), echo: method(obj({ text: str() }), str()) },
});
// Two types that get each other while they activate: a cycle.
const Ping = local("example.ping", { version: 1, params: {}, members: { n: int() } });
const Pong = local("example.pong", { version: 1, params: {}, members: { n: int() } });
const Broken = local("example.broken", { version: 1, params: { id: int() }, members: { n: int() } });
const Sloppy = local("example.sloppy", { version: 1, params: { id: int() }, members: { n: int() } });
const facet = defineFacet((host) => {
host.serve(Echo, ({ id }) => ({ id: constant(id), echo: async ({ text }) => text }));
host.serve(Broken, () => {
throw new Error("broken: missing configuration");
});
// Plugins are bundled without type checks; Chord checks the implementation's members when the object activates.
host.serve(Sloppy, () => ({ n: constant(1), extra: constant(2) }) as never);
});
// Ping and Pong are served by a second plugin: types with empty params activate at load.
const cycle = defineFacet((host) => {
host.serve(Ping, (_, self) => ({ n: self.get(Pong, {}).n }));
host.serve(Pong, (_, self) => ({ n: self.get(Ping, {}).n }));
});
// Failed activations are reported to onError; 07-failures.ts shows the other reports.
const onError = (error: HostError): void => {
if (error.kind === "activation_failed") console.log(`reported: ${error.object} failed ${error.failure.kind}`);
else console.log("reported:", error.kind);
};
// A dependency cycle fails every object on it broken, with invalid/cycle naming the chain. Load still resolves: the
// start barrier waits for each object's first outcome, live or failed.
const host = createHost({ onError });
await host.load(
[
{ name: "@example/errors", facet },
{ name: "@example/cycle", facet: cycle },
],
context,
);
await attempt("get in a cycle", () => host.get(Ping, {}, context));
const echo = await host.get(Echo, { id: 1 }, context);
// Arguments are checked against the contract at the object: wrong types and unknown fields are invalid/args.
// @ts-expect-error: text must be a string
await attempt("wrong argument", () => echo.echo({ text: 1 }, context));
// @ts-expect-error: an unknown field
await attempt("unknown field", () => echo.echo({ text: "hi", extra: true }, context));
// Params select the object and are checked on every get: invalid/params.
// @ts-expect-error: id must be an integer
await attempt("bad params", () => host.get(Echo, { id: "one" }, context));
// An implementation with a member its contract does not declare fails activation.
await attempt("unknown member", () => host.get(Sloppy, { id: 1 }, context));
// A get needs the provider's version and fingerprint; another declaration of the type does not fit. That is not the
// caller's fault (a reload may bring the version): the get waits up to its readyTimeout, then rejects with the cause.
const EchoV2 = local("example.echo", { version: 2, params: { id: int() }, members: { id: int() } });
await attempt("contract mismatch", () => host.get(EchoV2, { id: 1 }, context, { readyTimeout: 100 }));
// A factory that throws fails broken: the get rejects at once with its error, and no object is left behind.
await attempt("throwing factory", () => host.get(Broken, { id: 1 }, context));
// After release, every use of a reference throws released.
echo.release();
await attempt("released reference", () => echo.echo({ text: "hi" }, context));
await host.close(context);
await attempt("get after close", () => host.get(Echo, { id: 1 }, context));

View file

@ -0,0 +1,96 @@
// Natives: values an object opens during activation and closes when it ends (a file watcher, a connection, a timer).
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/06-natives.ts
import { setTimeout as sleep } from "node:timers/promises";
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, method, str } from "../../src/contracts/index.ts";
import { isChordError } from "../../src/data/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
const Clock = local("example.clock", {
version: 1,
params: { zone: str() },
members: { zone: str(), now: method(undefined, str()) },
});
const Leaky = local("example.leaky", { version: 1, params: {}, members: { n: int() } });
const facet = defineFacet((host) => {
host.serve(Clock, ({ zone }, self) => {
// `self.open(open, close)` declares a native; it opens after the references are live, in declaration order.
const config = self.open(
async () => {
console.log(`${zone}: open config`);
return { prefix: `[${zone}]` };
},
(_config, reason) => console.log(`${zone}: close config (${reason.kind})`),
);
// A later opener reads an earlier native's value. Its context is aborted if the object ends while it opens.
const ticker = self.open(
async () => {
console.log(`${zone}: open ticker, config ${config.value.prefix}`);
const ticker = { ticks: 0, timer: setInterval(() => ticker.ticks++, 10) };
return ticker;
},
(ticker, reason) => {
clearInterval(ticker.timer);
console.log(`${zone}: close ticker (${reason.kind})`);
},
);
// The factory only declares: a value is there once its open resolved.
try {
config.value;
} catch (error) {
if (isChordError(error, "not_ready")) console.log(`${zone}: in the factory, ${error.message}`);
}
return {
zone: constant(zone),
now: async () => `${config.value.prefix} ${ticker.value.ticks > 0 ? "ticking" : "starting"}`,
};
});
// Closes that throw or hang: they never stop the other closes.
host.serve(Leaky, (_, self) => {
self.open(
async () => "socket",
() => {
throw new Error("socket close failed");
},
);
self.open(
async () => "worker",
() => new Promise<void>(() => {}),
{ closeTimeout: 50 },
);
return { n: constant(1) };
});
});
// onError gets what fails in the background: here a close past its closeTimeout.
const host = createHost({
onError: (error) => console.log("reported:", error.kind, "object" in error ? error.object : ""),
});
await host.load([{ name: "@example/natives", facet }], context);
const vienna = await host.get(Clock, { zone: "vienna" }, context);
console.log("now:", await vienna.now(context));
await sleep(30);
console.log("now:", await vienna.now(context));
// The last reference gone, the object ends: natives close in reverse order, with reason `end`.
vienna.release();
await sleep(0);
// host.close ends every object at once and closes their natives with reason `close`; it rejects with every close
// that threw.
await host.get(Clock, { zone: "tokyo" }, context);
try {
await host.close(context);
} catch (error) {
if (error instanceof AggregateError)
console.log(
"host.close:",
error.errors.map((e: Error) => e.message),
);
}

View file

@ -0,0 +1,97 @@
// Failures: how an activation fails (transient or broken), how Chord retries it, and what getters see.
// Run from packages/chord (takes about 3 s: the retries wait 1 s, then 2 s):
// node --conditions=source --experimental-strip-types test/examples/07-failures.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, str } from "../../src/contracts/index.ts";
import { broken, isChordError } from "../../src/data/index.ts";
import { createHost, defineFacet, type HostError } from "../../src/host/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
const start = Date.now();
const at = (): string => `${((Date.now() - start) / 1000).toFixed(1)} s`;
// A connection to a remote service, which is down for the first two attempts.
const Remote = local("example.remote", { version: 1, params: {}, members: { status: str() } });
// An inbox per user, which needs the connection.
const Inbox = local("example.inbox", { version: 1, params: { user: str() }, members: { status: str() } });
// A mirror whose configuration is missing: retrying cannot help.
const Mirror = local("example.mirror", { version: 1, params: { id: int() }, members: { status: str() } });
// A service whose open never finishes.
const Stuck = local("example.stuck", { version: 1, params: { id: int() }, members: { status: str() } });
let attempts = 0;
const facet = defineFacet((host) => {
host.serve(Remote, (_, self) => {
const attempt = ++attempts;
self.open(
async () => {
console.log(`${at()}: remote, attempt ${attempt}`);
// A throwing open is transient: Chord retries it after 1 s, then 2 s, 4 s, ... up to 60 s.
if (attempt <= 2) throw new Error("connection refused");
return { connected: true };
},
() => {},
);
return { status: constant("connected") };
});
host.serve(Inbox, ({ user }, self) => {
// A dependency that fails parks this object (`failed { waiting }`, no timer of its own, the factory not run
// again); it goes on the moment the dependency is live (31-parked-dependencies.ts).
const remote = self.get(Remote, {});
console.log(`${at()}: inbox ${user}, attempt`);
return { status: self.derive([remote.status], ([status]) => `${user}: ${status}`) };
});
host.serve(Mirror, (_, self) => {
self.open(
async () => {
// broken(error) says retrying is pointless: no retry, and getters are rejected at once.
throw broken(new Error("no mirror configured"));
},
() => {},
);
return { status: constant("mirroring") };
});
host.serve(Stuck, (_, self) => {
self.open(
() => new Promise(() => {}),
() => {},
);
return { status: constant("never") };
});
});
// Every failed attempt is reported, with its object and cause.
const onError = (error: HostError): void => {
if (error.kind === "activation_failed") {
console.log(`${at()}: reported ${error.object}, ${error.failure.kind}: ${error.failure.error.message}`);
}
};
/** Prints how a get ended. */
async function attempt(label: string, work: () => Promise<{ status: { value: string } }>): Promise<void> {
try {
const reference = await work();
console.log(`${at()}: ${label}: ${reference.status.value}`);
} catch (error) {
if (isChordError(error)) console.log(`${at()}: ${label}: ${error.code} - ${error.message}`);
}
}
// Every attempt must be live within readyTimeout (here 500 ms); getters wait up to it too, unless they say otherwise.
const host = createHost({ onError, readyTimeout: 500 });
// The start barrier: load resolves once the remote (empty params: it lives as long as the host) had its first outcome.
await host.load([{ name: "@example/failures", facet }], context);
console.log(`${at()}: loaded`);
// A broken failure rejects the get at once.
await attempt("mirror", () => host.get(Mirror, { id: 1 }, context));
// An attempt not live within readyTimeout is abandoned and fails transient; this get waits longer (700 ms) and then
// rejects with the last failure's error.
await attempt("stuck", () => host.get(Stuck, { id: 1 }, context, { readyTimeout: 700 }));
// Transient failures keep a get waiting through the retries, up to its deadline: the inbox goes live with the remote.
await attempt("inbox", () => host.get(Inbox, { user: "mario" }, context, { readyTimeout: 10_000 }));
await host.close(context);

View file

@ -0,0 +1,119 @@
// Remote objects over one link: a server and a client host, a remote contract with two variants, a factory choosing
// the variant per principal over a shared local store, and the client's replicas following the server's commits.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/08-remote.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
import { mapShared } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
// The app's principals: who calls. Links stamp them; a caller never sets its own.
type P = { kind: "owner" } | { kind: "guest"; session: string } | { kind: "server" };
const Note = obj({ text: str(), author: str() });
const PublicNote = obj({ text: str() });
// The server's host kind: every remote contract names the address of the host serving its objects.
const Server = hostKind("server");
// A remote contract: one object per (type, params, principal key), in the variant its factory chooses.
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(obj({ text: str() })) } },
reader: { state: { notes: arr(PublicNote), count: int() } },
},
});
// The shared store: the notes of a session once, behind per-principal views (design §6). Local types never cross a link.
const NoteStore = local("example.note-store", {
version: 1,
params: { session: str() },
members: { notes: arr(Note), add: method(Note) },
});
const NotesDoc = defineState("notes", { schema: obj({ list: arr(Note) }), version: 1, initial: () => ({ list: [] }) });
// A redaction computed once per store element, so a reader's derive keeps unchanged notes identical.
const redact = mapShared((note: { text: string; author: string }) => ({ text: note.text }));
const facet = defineFacet<P>((host) => {
host.serve(NoteStore, (_, self) => {
const doc = self.state(NotesDoc);
return {
notes: self.derive([doc], ([d]) => d.list),
add: async (note, ctx) => {
await doc.change((draft) => void draft.list.push(note), ctx);
return null;
},
};
});
// A remote factory gets the principal its object is for and returns one variant with exactly its members.
host.serve(Notes, ({ session }, principal, self) => {
const store = self.get(NoteStore, { session });
const count = self.derive([store.notes], ([n]) => n.length);
if (principal.kind === "guest") {
return { variant: "reader", members: { notes: self.derive([store.notes], ([n]) => redact(n)), count } };
}
return {
variant: "editor",
members: {
notes: store.notes,
count,
add: async ({ text }, ctx) => store.add({ text, author: principal.kind }, ctx),
},
};
});
});
// 1. The server, at its address: it acts as `server` for its own requests.
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error("server:", error),
});
await server.load([{ name: "@example/notes", facet }], context);
// 2. A client (a tab): no principal of its own. The entry point authenticated it as a guest of S1 and stamps that on
// the link; the client connects before it loads (its plugins may reference server types).
const [up, down] = memoryLinkPair();
server.accept(up, { principal: { kind: "guest", session: "S1" } }, context);
const client = createHost<P>({ onError: (error) => console.error("client:", error) });
client.connect(down, { principal: { kind: "server" } }, context);
await client.load([], context);
// 3. The server writes as an editor; the guest reads the redacted view over the link.
const own = await server.get(Notes, { session: "S1" }, context);
if (own.variant === "editor") await own.add({ text: "hello" }, context);
const notes = await client.get(Notes, { session: "S1" }, context);
console.log("guest variant:", notes.variant);
console.log("guest notes:", JSON.stringify(notes.notes.value), "count:", notes.count.value);
// 4. A commit on the server reaches the client as one state message: both members change in the same batch.
const watch = await notes.notes.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log("frame:", JSON.stringify(call.value), "ops:", JSON.stringify(call.ops));
});
if (own.variant === "editor") await own.add({ text: "world" }, context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("count after the commit:", notes.count.value);
// 5. TypeScript narrows on `variant`: a reader has no `add` (a forged call would fail `invalid/member` at the server).
if (notes.variant === "reader") {
// @ts-expect-error: `add` is not a member of the reader variant
void notes.add;
}
// 6. Closing the server closes its links first: the client's reference goes loading and keeps its last values until
// a new link holds it again (10-drops.ts). Closing the client releases it and ends its watches.
await server.close(context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("after the server closed:", notes.state.value, "count:", notes.count.value);
await client.close(context);
console.log("after the client closed:", notes.state.value, await watch.closed);

View file

@ -0,0 +1,115 @@
// Calls over a link: results, bytes as raw segments, ChordErrors and plugin errors, the caller's principal and
// incarnation in `ctx`, cancellation, and release rejecting the calls made through a reference.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/09-calls.ts
import { BACKGROUND_CONTEXT, withCancel } from "../../src/context/index.ts";
import { bytes, hostKind, int, method, obj, remote, str } from "../../src/contracts/index.ts";
import { isChordError, PluginError } from "../../src/data/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" };
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
const Files = remote("example.files", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
owner: {
methods: {
whoami: method(undefined, str()),
// A `bytes()` leaf: the argument and result travel as raw segments, never base64.
upload: method(obj({ name: str(), data: bytes() }), int()),
download: method(obj({ name: str() }), bytes()),
quota: method(),
slow: method(),
},
},
},
});
const stored = new Map<string, Uint8Array>();
const facet = defineFacet<P>((host) => {
host.serve(Files, () => ({
variant: "owner",
members: {
// ctx.principal is the caller as its link stamped it; ctx.incarnation the calling host's run (R20).
whoami: async (ctx) => `${ctx.principal?.kind} from ${ctx.incarnation.slice(0, 8)}…`,
upload: async ({ name, data }) => {
stored.set(name, data);
return data.byteLength;
},
download: async ({ name }) => stored.get(name) ?? new Uint8Array(),
// A PluginError crosses with its code, message and details; any other throw as code `internal`.
quota: async () => {
throw new PluginError("quota", "the store is full", { limit: 1 });
},
// A handler's context is aborted when its caller cancels or releases the reference.
slow: (ctx) =>
new Promise((resolve) =>
ctx.abortSignal?.addEventListener("abort", () => {
console.log("server: slow aborted:", (ctx.abortSignal?.reason as Error).message);
resolve(null);
}),
),
},
}));
});
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error("server:", error),
});
await server.load([{ name: "@example/files", facet }], context);
const [up, down] = memoryLinkPair();
server.accept(up, { principal: { kind: "owner" } }, context);
const client = createHost<P>({ onError: (error) => console.error("client:", error) });
client.connect(down, { principal: { kind: "server" } }, context);
await client.load([], context);
const files = await client.get(Files, {}, context);
// 1. A call resolves with its result; a method without arguments takes only the context.
console.log("whoami:", await files.whoami(context));
// 2. Bytes: 1 MiB goes out as one raw segment next to the JSON part.
const data = new Uint8Array(1024 * 1024).fill(7);
console.log("uploaded bytes:", await files.upload({ name: "chart.png", data }, context));
const back = await files.download({ name: "chart.png" }, context);
console.log("downloaded:", back.byteLength, "bytes, a", back.constructor.name);
// 3. Errors: arguments are checked at the provider (a ChordError); a plugin's own error keeps its code.
try {
// @ts-expect-error: `name` must be a string
await files.upload({ name: 1, data }, context);
} catch (error) {
if (isChordError(error, "invalid")) console.log("refused:", error.reason, error.message);
}
try {
await files.quota(context);
} catch (error) {
if (error instanceof PluginError) console.log("plugin error:", error.code, error.message, error.details);
}
// 4. Cancellation: the caller is rejected at once, the provider aborts the handler.
const { context: cancellable, cancel } = withCancel(context);
const slow = files.slow(cancellable);
setTimeout(() => cancel(new Error("the user gave up")), 5);
await slow.catch((error: unknown) => console.log("client: slow rejected:", (error as Error).message));
// 5. Release rejects the calls made through the reference; the provider aborts their handlers.
const pending = files.slow(context);
await new Promise((resolve) => setTimeout(resolve, 5));
files.release();
await pending.catch((error: unknown) => console.log("client: pending call:", (error as Error).message));
await new Promise((resolve) => setTimeout(resolve, 5));
await client.close(context);
await server.close(context);

View file

@ -0,0 +1,115 @@
// Surviving drops: a reference held before any link exists, `ref.state` as it follows the link, calls parked while
// loading and sent in order once live, a call in flight when the link drops, and the re-hold after `connect` again.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/10-drops.ts
import { BACKGROUND_CONTEXT, type Context } from "../../src/context/index.ts";
import { arr, hostKind, int, method, obj, remote, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../src/host/index.ts";
import { type Link, memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" };
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
editor: {
state: { notes: arr(str()) },
methods: { add: method(obj({ text: str() }), int()), slow: method() },
},
},
});
const NotesDoc = defineState("notes", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }) });
const facet = defineFacet<P>((host) => {
// Lingers for a minute after its last reference: a client that reconnects within it finds its notes again.
host.serve(
Notes,
(_, _principal, self) => {
const doc = self.state(NotesDoc);
return {
variant: "editor",
members: {
notes: self.derive([doc], ([d]) => d.list),
add: async ({ text }, ctx) => {
await doc.change((draft) => void draft.list.push(text), ctx);
return doc.value.list.length;
},
slow: (ctx) => new Promise((resolve) => ctx.abortSignal?.addEventListener("abort", () => resolve(null))),
},
};
},
{ linger: 60_000 },
);
});
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error("server:", error),
});
await server.load([{ name: "@example/notes", facet }], context);
const client = createHost<P>({ onError: (error) => console.error("client:", error) });
await client.load([], context);
/** A new link between the hosts: the server stamps the client as its owner. Returns the server's end. */
function connect(server: Host<P>, client: Host<P>, context: Context): Link {
const [up, down] = memoryLinkPair();
server.accept(up, { principal: { kind: "owner" } }, context);
client.connect(down, { principal: { kind: "server" } }, context);
return up;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
// 1. A hold before any link: a reference at once, `loading`, its variant unknown. Calls on it park.
const notes = client.hold(Notes, { session: "S1" }, context);
console.log("held without a link:", notes.state.value, "variant:", notes.variant);
const watch = await notes.state.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log(" ref.state →", call.value.kind);
});
const parked = notes.add({ text: "written while loading" }, context);
// 2. Connect: the reference is held over the link, goes live, and the parked call is sent.
let link = connect(server, client, context);
console.log("parked call's result once live:", await parked);
console.log("notes:", notes.notes.value, "variant:", notes.variant);
// 3. The link drops (the server's end closes it). A call in flight fails `unavailable` and is never resent; the
// reference goes loading and keeps its last values.
const inFlight = notes.slow(context).catch((error: Error) => `rejected: ${error.message}`);
await settle();
link.close();
console.log("in-flight call:", await inFlight);
await settle();
console.log("after the drop:", notes.state.value, "last notes:", notes.notes.value);
// 4. Calls made while the link is down park; they wait at most the reference's readyTimeout (60 s here).
const first = notes.add({ text: "first while down" }, context);
const second = notes.add({ text: "second while down" }, context);
// 5. The app connects again (its link adapter would): every reference is held again with fresh ids, its members get
// the new snapshot as one frame `[["r", value]]`, then the parked calls are sent in the order they were made.
const frames = await notes.notes.watch(context);
frames.start(async (call) => {
if (call.kind === "value") console.log(" notes frame:", JSON.stringify(call.ops));
});
link = connect(server, client, context);
console.log("parked results, in order:", await first, await second);
await settle();
console.log("notes after the re-hold:", notes.notes.value);
// 6. Release: `ref.state` is `ended` (its error says the holder released it) and its watch ends.
notes.release();
console.log("watch ended:", await watch.closed, "state:", notes.state.value);
await client.close(context);
await server.close(context);

View file

@ -0,0 +1,109 @@
// `linger` and `ref.retry()`: an object that stays warm after its last reference, failed references with `retryAt`,
// a Retry button that skips the backoff, and a broken object renewed by a retry.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/11-linger-and-retry.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, method, obj, str } from "../../src/contracts/index.ts";
import { broken } from "../../src/data/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type RefState } from "../../src/host/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
const Draft = local("example.draft", {
version: 1,
params: { conversation: str() },
members: { text: str(), type: method(obj({ text: str() })) },
});
const Mirror = local("example.mirror", { version: 1, params: { id: int() }, members: { status: str() } });
const DraftDoc = defineState("draft", { schema: obj({ text: str() }), version: 1, initial: () => ({ text: "" }) });
// What the mirror's open finds: nothing configured, then a server that is down, then one that answers.
const remote = { configured: false, up: false };
const facet = defineFacet((host) => {
// 1. A draft lingers 200 ms after its last reference: a tab that closes and reopens quickly finds its text.
host.serve(
Draft,
({ conversation }, self) => {
console.log(` (draft ${conversation} activated)`);
const doc = self.state(DraftDoc);
return {
text: self.derive([doc], ([d]) => d.text),
type: async ({ text }, ctx) => {
await doc.change((draft) => {
draft.text = text;
}, ctx);
return null;
},
};
},
{ linger: 200 },
);
host.serve(Mirror, (_, self) => {
self.open(
async () => {
// `broken`: retrying cannot help until someone changes something (no automatic retry).
if (!remote.configured) throw broken(new Error("no mirror configured"));
// A plain throw is transient: retried with backoff (1 s, doubling).
if (!remote.up) throw new Error("mirror unreachable");
return null;
},
() => {},
);
return { status: constant("mirroring") };
});
});
const host = createHost({ onError: (error) => console.log(` onError: ${error.kind}`) });
await host.load([{ name: "@example/app", facet }], context);
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** A state for the log: a failure with its kind, when it retries by itself, and its error. */
function describe(state: RefState): string {
if (state.kind !== "failed") return state.kind;
const retry = state.retryAt === undefined ? "no automatic retry" : `retries in ${state.retryAt - Date.now()} ms`;
return `failed, ${state.cause.kind}, ${retry}: ${state.cause.error.message}`;
}
// 2. Linger: release, get again within 200 ms, the same object and its in-memory document.
let draft = await host.get(Draft, { conversation: "c1" }, context);
await draft.type({ text: "hello" }, context);
draft.release();
await wait(100);
draft = await host.get(Draft, { conversation: "c1" }, context);
console.log("within linger:", JSON.stringify(draft.text.value));
draft.release();
await wait(300);
draft = await host.get(Draft, { conversation: "c1" }, context);
console.log("after linger:", JSON.stringify(draft.text.value));
draft.release();
// 3. A held reference follows its object: broken first (no retryAt), so nothing happens by itself.
const mirror = host.hold(Mirror, { id: 1 }, context);
const watch = await mirror.state.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log(` ref.state → ${describe(call.value)}`);
});
await wait(10);
// 4. The user fixes the configuration and presses Retry: the broken object is renewed and tries again. The server is
// still down: a transient failure, retried automatically at retryAt.
remote.configured = true;
mirror.retry();
await wait(10);
// 5. Retry again (a Retry button): the backoff window is skipped; this time it goes live.
remote.up = true;
mirror.retry();
await wait(10);
console.log("status:", mirror.status.value);
// 6. A retry while live does nothing.
mirror.retry();
await wait(10);
mirror.release();
await host.close(context);

View file

@ -0,0 +1,161 @@
// Routes: host kinds and addresses, a contract's host address, a server routing a session's objects to an in-process
// worker (one `resolve` per address, `authorize` per principal), a guest's `ctx.get` on the server reaching the worker
// as the guest, and a worker that drops and is resolved again.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/12-routes.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { isChordError } from "../../src/data/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../src/host/index.ts";
import { type Link, memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
type P =
| { kind: "owner" }
| { kind: "guest"; session: string }
| { kind: "server" }
| { kind: "worker"; session: string };
// Kinds of addressable hosts; `Session({ id: "S1" })` is an address, its key `session|S1`.
const Server = hostKind("server");
const Session = hostKind("session", { id: str() });
// Notes live on their session's worker: the caller computes the address and sends it with its hold.
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: ({ session }) => Session({ id: session }),
variants: {
editor: {
state: { notes: arr(str()) },
methods: { add: method(obj({ text: str() }), int()), whoami: method(undefined, str()) },
},
reader: { state: { notes: arr(str()) }, methods: { whoami: method(undefined, str()) } },
},
});
// The lobby lives on the server: its handler gets notes for whoever calls it.
const Lobby = remote("example.lobby", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { visitor: { methods: { peek: method(obj({ session: str() }), str()) } } },
});
// The worker's own store of its session's notes, shared by every principal's `Notes` object.
const NoteStore = local("example.note-store", {
version: 1,
params: {},
members: { list: arr(str()), add: method(obj({ text: str() }), int()) },
});
const NotesDoc = defineState("notes", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }) });
/** The worker's facet: an owner edits, anyone else reads; `whoami` shows whom a call acts for. */
const workerFacet = defineFacet<P>((host) => {
host.serve(NoteStore, (_, self) => {
const doc = self.state(NotesDoc);
return {
list: self.derive([doc], ([d]) => d.list),
add: async ({ text }, ctx) => {
await doc.change((draft) => void draft.list.push(text), ctx);
return doc.value.list.length;
},
};
});
host.serve(Notes, (_, principal, self) => {
const store = self.get(NoteStore, {});
const whoami = async (ctx: { readonly principal: P | undefined }) => JSON.stringify(ctx.principal);
if (principal.kind !== "owner") return { variant: "reader", members: { notes: store.list, whoami } };
return { variant: "editor", members: { notes: store.list, add: (args, ctx) => store.add(args, ctx), whoami } };
});
});
const serverFacet = defineFacet<P>((host) => {
host.serve(Lobby, () => ({
variant: "visitor",
members: {
// `ctx.get` acts as the caller: over the route, the worker sees the guest, not the server.
peek: async ({ session }, ctx) => {
const notes = await ctx.get(Notes, { session });
return `${notes.variant} variant, ${await notes.whoami(ctx)}`;
},
},
}));
});
// 1. The server: an address, and a route for sessions. `resolve` starts an in-process worker over a memory link and
// returns the link at once (the worker connects first, then loads, W6). `authorize` admits owners everywhere and a
// guest only into its own session.
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError: console.error });
let resolves = 0;
let workerLink: Link | undefined;
server.route(
Session,
{
authorize: (principal, address) =>
principal.kind === "owner" || (principal.kind === "guest" && principal.session === address.params.id),
resolve: async (address) => {
console.log(`server: resolving ${address.key} (resolve #${++resolves})`);
const [up, down] = memoryLinkPair();
const worker = createHost<P>({
address,
principal: { kind: "worker", session: address.params.id },
onError: console.error,
});
worker.connect(down, { principal: { kind: "server" }, trusted: true }, context);
void worker.load([{ name: "@example/worker", facet: workerFacet }], context);
workerLink = up;
return { link: up, principal: { kind: "worker", session: address.params.id }, trusted: true };
},
},
context,
);
await server.load([{ name: "@example/server", facet: serverFacet }], context);
/** A tab on the server, stamped as `principal` by the server's entry point. */
async function tabAs(principal: P): Promise<Host<P>> {
const [up, down] = memoryLinkPair();
server.accept(up, { principal }, context);
const tab = createHost<P>({ onError: console.error });
tab.connect(down, { principal: { kind: "server" } }, context);
await tab.load([], context);
return tab;
}
// 2. The owner's tab gets S1's notes: tab → server (relays) → route → worker. A second get shares the worker.
const owner = await tabAs({ kind: "owner" });
const notes = await owner.get(Notes, { session: "S1" }, context);
if (notes.variant === "editor") await notes.add({ text: "milk" }, context);
const again = await owner.get(Notes, { session: "S1" }, context);
console.log("owner:", notes.variant, again.notes.value, "resolves so far:", resolves);
// 3. A guest of S1 reads S1 and is refused S2 by `authorize` (no worker is started for it).
const guest = await tabAs({ kind: "guest", session: "S1" });
const read = await guest.get(Notes, { session: "S1" }, context);
console.log("guest:", read.variant, read.notes.value, "acts as", await read.whoami(context));
await guest.get(Notes, { session: "S2" }, context).catch((error) => {
console.log("guest, S2:", isChordError(error) ? error.code : error, "resolves so far:", resolves);
});
// 4. The guest calls the lobby on the server; its handler's `ctx.get` goes over the route as the guest.
const lobby = await guest.get(Lobby, {}, context);
console.log("guest via the lobby:", await lobby.peek({ session: "S1" }, context));
// 5. The worker drops: references go loading, the route resolves the address again at once (its link had completed
// its handshake), and the new worker (a fresh run, empty notes here) binds them again.
const states: string[] = [];
const watch = await notes.state.watch(context);
watch.start(async (call) => {
if (call.kind === "value") states.push(call.value.kind);
});
await new Promise((resolve) => setTimeout(resolve, 0));
workerLink!.close();
while (states.length < 2 || states.at(-1) !== "live") await new Promise((resolve) => setTimeout(resolve, 50));
console.log("after the drop:", states.join(" → "), "notes:", notes.notes.value, "resolves:", resolves);
await owner.close(context);
await guest.close(context);
await server.close(context);

View file

@ -0,0 +1,146 @@
// A relay over real transports: a tab on a loopback WebSocket, a server that relays, and a worker per session forked
// over IPC. The worker gets its start data from its start message and serves it as `SessionInfo` through its core
// facet (§18); the server routes by address and parses no part.
// Run from packages/chord (the same file is the worker when its first argument is `worker`):
// node --conditions=source --experimental-strip-types test/examples/13-relay.ts
import { fork } from "node:child_process";
import type { AddressInfo } from "node:net";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws";
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { type IpcPort, ipcLink, webSocketLink } from "../../src/link/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" } | { kind: "worker"; session: string };
const Server = hostKind("server");
const Session = hostKind("session", { id: str() });
const Init = obj({ id: str(), title: str(), pid: int() });
/** Start data: a local object with empty params (host lifetime) and constant state, served by the core facet. */
const SessionInfo = local("example.session-info", { version: 1, params: {}, members: { init: Init } });
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: ({ session }) => Session({ id: session }),
variants: {
owner: { state: { info: Init, notes: arr(str()) }, methods: { add: method(obj({ text: str() }), int()) } },
},
});
/** What the server sends a worker it forked: the app's own message, not the link's. */
type Start = { readonly id: string; readonly title: string };
/** The worker's core facet, made from its start message (base code, loaded under a fixed name). */
const coreWorkerFacet = (init: { id: string; title: string; pid: number }) =>
defineFacet<P>((host) => {
host.serve(SessionInfo, () => ({ init: constant(init) }));
});
const NotesDoc = defineState("notes", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }) });
/** A plugin's facet on the worker: its notes, and the start data read through a reference. */
const notesFacet = defineFacet<P>((host) => {
host.serve(Notes, (_, _principal, self) => {
const doc = self.state(NotesDoc);
const info = self.get(SessionInfo, {});
return {
variant: "owner",
members: {
info: info.init,
notes: self.derive([doc], ([d]) => d.list),
add: async ({ text }, ctx) => {
await doc.change((draft) => void draft.list.push(text), ctx);
return doc.value.list.length;
},
},
};
});
});
if (process.argv[2] === "worker") {
// The worker. 1. Its link first: it queues the link's messages until the host listens, and skips the app's own.
const link = ipcLink(process as unknown as IpcPort);
const start = await new Promise<Start>((resolve) => {
const listener = (message: unknown) => {
if (message instanceof Uint8Array) return;
process.off("message", listener);
// The start message the server's `resolve` sent: the app's own shape.
resolve(message as Start);
};
process.on("message", listener);
});
// 2. Its host at its address: connect first (W6), then load the core facet with the start data, then the plugins.
const worker = createHost<P>({
address: Session({ id: start.id }),
principal: { kind: "worker", session: start.id },
onError: console.error,
});
worker.connect(link, { principal: { kind: "server" }, trusted: true }, context);
await worker.load(
[
{ name: "@example/core", facet: coreWorkerFacet({ ...start, pid: process.pid }) },
{ name: "@example/notes", facet: notesFacet },
],
context,
);
// The server closing the link ends the worker.
link.onClose(() => process.exit(0));
} else {
// The server. 3. A route forks a worker per session, sends it its start message and returns the IPC link at once.
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError: console.error });
server.route(
Session,
{
authorize: (principal) => principal.kind === "owner",
// No `signal` for the fork: the context is aborted when the host closes, which would kill the worker (and emit
// `error` on the child); a link Chord closes, at `host.close` or because it arrived after it, ends the worker.
resolve: async (address) => {
const child = fork(fileURLToPath(import.meta.url), ["worker"], {
serialization: "advanced",
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
});
child.send({ id: address.params.id, title: `Session ${address.params.id}` } satisfies Start);
console.log(`server: forked pid ${child.pid} for ${address.key}`);
return { link: ipcLink(child), principal: { kind: "worker", session: address.params.id }, trusted: true };
},
},
context,
);
await server.load([], context);
// 4. Tabs reach the server over a WebSocket; its entry point stamps each as the owner here.
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await new Promise((resolve) => wss.once("listening", resolve));
wss.on("connection", (socket) => server.accept(webSocketLink(socket), { principal: { kind: "owner" } }, context));
const { port } = wss.address() as AddressInfo;
// 5. A tab: Node's own WebSocket, as a browser's. Its holds go tab → server (relay) → worker.
const tab = createHost<P>({ onError: console.error });
tab.connect(webSocketLink(new WebSocket(`ws://127.0.0.1:${port}`)), { principal: { kind: "server" } }, context);
await tab.load([], context);
const s1 = await tab.get(Notes, { session: "S1" }, context);
console.log("tab: S1 info", s1.info.value, "server pid", process.pid);
await s1.add({ text: "milk" }, context);
await s1.add({ text: "bread" }, context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("tab: S1 notes", s1.notes.value);
// 6. Another session is another worker process.
const s2 = await tab.get(Notes, { session: "S2" }, context);
console.log("tab: S2 info", s2.info.value, "notes", s2.notes.value);
// 7. Closing the server closes its route links; each worker exits when its link closes.
await tab.close(context);
await server.close(context);
await new Promise((resolve) => wss.close(resolve));
console.log("closed");
}

View file

@ -0,0 +1,208 @@
// Flow control: a slow tab. At most two of a provider's own `bound`/`state` messages are unacknowledged per origin;
// while the window is full the provider folds the ops of every commit into its pending queues and sends them as one
// message when an `ack` frees a slot. A relay folds and drops nothing: it forwards at once under the tab's origin and
// returns each `ack` to the worker that sent the message, so a slow tab folds at the worker, alone.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/14-flow-control.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { hostKind, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../src/host/index.ts";
import { binaryCodec, describeMessage, type Link, memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "server" } | { kind: "worker"; session: string };
const Server = hostKind("server");
const Session = hostKind("session", { id: str() });
/** The worker's own document of a streamed reply, and its one write. */
const Reply = local("example.reply", {
version: 1,
params: {},
members: { reply: obj({ text: str() }), append: method(str()) },
});
/** What a tab holds: the reply, on its session's worker. */
const View = remote("example.view", {
version: 1,
params: { session: str() },
hostAddress: ({ session }) => Session({ id: session }),
variants: { owner: { state: { reply: obj({ text: str() }) } } },
});
const ReplyDoc = defineState("reply", { schema: obj({ text: str() }), version: 1, initial: () => ({ text: "" }) });
const workerFacet = defineFacet<P>((host) => {
host.serve(Reply, (_, self) => {
const doc = self.state(ReplyDoc);
return {
reply: doc,
append: async (token, ctx) => {
await doc.change((draft) => {
draft.text += token;
}, ctx);
return null;
},
};
});
host.serve(View, (_params, _principal, self) => ({
variant: "owner",
members: { reply: self.get(Reply, {}).reply },
}));
});
/** A worker for `id`, loaded. */
async function worker(id: string): Promise<Host<P>> {
const host = createHost<P>({
address: Session({ id }),
principal: { kind: "worker", session: id },
onError: console.error,
});
await host.load([{ name: "@example/worker", facet: workerFacet }], context);
return host;
}
/** A link end that logs, as `label`, every `bound`, `state` and `ack` its host receives. */
function tap(link: Link, label: string): Link {
return {
send: (message) => link.send(message),
onMessage: (listener) =>
link.onMessage((message) => {
const decoded = binaryCodec.decode(message);
const shown = decoded.kind === "ack" || decoded.kind === "bound" || decoded.kind === "state";
if (shown) console.log(` ${label} ←`, describeMessage(decoded).slice(0, 110));
listener(message);
}),
close: () => link.close(),
onClose: (listener) => link.onClose(listener),
};
}
/** A slow tab's end of its link: while paused, what arrives waits (so the tab acknowledges nothing). */
class Pausable implements Link {
paused = false;
readonly #link: Link;
readonly #waiting: Uint8Array[] = [];
#listener: (message: Uint8Array) => void = () => {};
constructor(link: Link) {
this.#link = link;
link.onMessage((message) => {
if (this.paused) this.#waiting.push(message);
else this.#listener(message);
});
}
resume(): void {
this.paused = false;
for (const message of this.#waiting.splice(0)) this.#listener(message);
}
send(message: Uint8Array): void {
this.#link.send(message);
}
onMessage(listener: (message: Uint8Array) => void): void {
this.#listener = listener;
}
close(): void {
this.#link.close();
}
onClose(listener: () => void): void {
this.#link.onClose(listener);
}
}
/** A tab connected over `link`, loaded. */
async function tab(link: Link): Promise<Host<P>> {
const host = createHost<P>({ onError: console.error });
host.connect(link, { principal: { kind: "server" } }, context);
await host.load([], context);
return host;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
// 1. Folding at the provider: a tab directly on a worker. Paused, it acknowledges nothing; the worker sends two
// `state` messages (the window), then folds 1,000 appends into its pending queue.
console.log("a slow tab on the provider:");
const w1 = await worker("S1");
const [up, down] = memoryLinkPair();
w1.accept(up, { principal: { kind: "owner" } }, context);
const slow = new Pausable(down);
const t1 = await tab(tap(slow, "tab"));
const view1 = await t1.get(View, { session: "S1" }, context);
const reply1 = await w1.get(Reply, {}, context);
slow.paused = true;
for (let i = 0; i < 1_000; i++) await reply1.append(" tok", context);
await settle();
console.log(
" paused: the tab shows",
view1.reply.value.text.length,
"characters, the worker has",
reply1.reply.value.text.length,
);
// 2. Resumed: the tab applies and acknowledges the two, and the freed slot carries one `state` with one folded append.
slow.resume();
await settle();
console.log(" resumed: the tab shows", view1.reply.value.text.length, "characters");
// 3. The same slow tab through a server: its holds reach the worker with the tab's ids under the tab link's origin
// (`["@",1,…]`), the worker's window for that origin fills and it folds there; a second, fast tab is another origin and
// receives every commit.
console.log("a slow tab through a relay:");
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError: console.error });
let w2: Host<P> | undefined;
server.route(
Session,
{
authorize: () => true,
resolve: async (address) => {
const [a, b] = memoryLinkPair();
w2 = await worker(address.params.id);
w2.connect(tap(b, "worker"), { principal: { kind: "server" }, trusted: true }, context);
return { link: a, principal: { kind: "worker", session: address.params.id }, trusted: true };
},
},
context,
);
await server.load([], context);
const [up2, down2] = memoryLinkPair();
server.accept(up2, { principal: { kind: "owner" } }, context);
const slow2 = new Pausable(down2);
const t2 = await tab(tap(slow2, "tab"));
const view2 = await t2.get(View, { session: "S2" }, context);
const [up3, down3] = memoryLinkPair();
server.accept(up3, { principal: { kind: "owner" } }, context);
const t3 = await tab(down3);
const view3 = await t3.get(View, { session: "S2" }, context);
let fast = 0;
const watch = await view3.reply.watch(context);
watch.start(async () => {
fast++;
});
const reply2 = await w2!.get(Reply, {}, context);
slow2.paused = true;
for (let i = 0; i < 5; i++) await reply2.append(` t${i}`, context);
await settle();
console.log(" paused: the slow tab shows", view2.reply.value, "the worker has", reply2.reply.value);
console.log(" the fast tab saw", fast, "changes:", view3.reply.value);
// 4. Resumed: the slow tab acknowledges the two; the relay returns each `ack` to the worker with the origin, and the
// worker's freed slot carries one `state` with the three folded appends.
slow2.resume();
await settle();
console.log(" resumed: the slow tab shows", view2.reply.value);
await t1.close(context);
await w1.close(context);
await watch.stop();
await t2.close(context);
await t3.close(context);
await server.close(context);
await w2!.close(context);

View file

@ -0,0 +1,142 @@
// Revocation: the app says which revocations apply to a principal (`revokedBy`, the same function on every host);
// `host.revoke(r)` ends that principal's objects at once, whatever references remain, closes its child links (tabs) and
// sends `revoke` down every route link, where each worker applies it with its own `revokedBy`. Nothing is replayed.
// What ends gets the error `forbidden` with reason `revoked` (a UI can say "access revoked"): holders get it in `gone`,
// running handlers in their aborted context.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/15-revocation.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, hostKind, method, obj, remote, str } from "../../src/contracts/index.ts";
import type { ChordError } from "../../src/data/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../src/host/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
/** The app's principals: a guest holds a grant to one session; the server and its workers act as themselves. */
type P =
| { kind: "owner" }
| { kind: "guest"; grant: string; session: string }
| { kind: "server" }
| { kind: "worker"; session: string };
/** What the app revokes: a grant, or everything of a session. */
type Revocation = { grant: string } | { session: string };
/** The same function on every host: a guest is revoked by its grant and by its session. */
const revokedBy = (p: P): Revocation[] => (p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }] : []);
const Server = hostKind("server");
const Session = hostKind("session", { id: str() });
const Notes = remote("example.notes", {
version: 1,
params: { session: str() },
hostAddress: ({ session }) => Session({ id: session }),
variants: {
all: { state: { notes: arr(str()) }, methods: { add: method(obj({ text: str() })), follow: method() } },
},
});
const NotesDoc = defineState("notes", { schema: obj({ list: arr(str()) }), version: 1, initial: () => ({ list: [] }) });
const notesFacet = defineFacet<P>((host) => {
host.serve(Notes, (_, principal, self) => {
const doc = self.state(NotesDoc);
console.log(` worker: Notes for ${principal.kind}${principal.kind === "guest" ? ` ${principal.grant}` : ""}`);
return {
variant: "all",
members: {
notes: self.derive([doc], ([d]) => d.list),
add: async ({ text }, ctx) => {
await doc.change((draft) => void draft.list.push(text), ctx);
return null;
},
// A long-running call: it ends when its context is aborted, and says why.
follow: async (ctx) => {
await new Promise((resolve) => ctx.abortSignal?.addEventListener("abort", resolve));
const { code, reason } = ctx.abortSignal!.reason as ChordError;
console.log(` worker: ${principal.kind}'s follow aborted: ${code}/${reason}`);
return null;
},
},
};
});
});
// 1. A server with `revokedBy`, routing sessions to in-process workers (trusted links) with the same `revokedBy`.
const server = createHost<P, Revocation>({
address: Server(),
principal: { kind: "server" },
revokedBy,
onError: console.error,
});
const workers: Host<P, Revocation>[] = [];
server.route(
Session,
{
authorize: (p, address) => p.kind === "owner" || (p.kind === "guest" && p.session === address.params.id),
resolve: async (address) => {
const [up, down] = memoryLinkPair();
const worker = createHost<P, Revocation>({
address,
principal: { kind: "worker", session: address.params.id },
revokedBy,
onError: console.error,
});
worker.connect(up, { principal: { kind: "server" }, trusted: true }, context);
void worker.load([{ name: "@example/notes", facet: notesFacet }], context);
workers.push(worker);
return { link: down, principal: { kind: "worker", session: address.params.id }, trusted: true };
},
},
context,
);
await server.load([], context);
/** A tab whose link the server's entry point stamped with `principal`. */
async function tab(principal: P) {
const host = createHost<P>({ onError: console.error });
await host.load([], context);
const [a, b] = memoryLinkPair();
server.accept(a, { principal }, context);
host.connect(b, { principal: { kind: "server" } }, context);
return host;
}
// 2. Two tabs of guest grant g1 (one person, two windows) and the owner's tab, all on session S1.
const [one, two, owner] = await Promise.all([
tab({ kind: "guest", grant: "g1", session: "S1" }),
tab({ kind: "guest", grant: "g1", session: "S1" }),
tab({ kind: "owner" }),
]);
const guestNotes = await one.get(Notes, { session: "S1" }, context);
await two.get(Notes, { session: "S1" }, context);
const ownerNotes = await owner.get(Notes, { session: "S1" }, context);
await guestNotes.add({ text: "from the guest" }, context);
const following = guestNotes.follow(context).catch((error: ChordError) => error.code);
// The call reaches the worker and runs there.
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("worker S1 references:", workers[0]!.references(Notes).value.length);
// 3. Revoke the grant: the worker gets `revoke { grant: "g1" }` and ends the guest's objects there (their running
// calls aborted with forbidden/revoked), and both guest tabs' links close.
server.revoke({ grant: "g1" }, context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("guest tab:", guestNotes.state.value.kind, "(its link closed; a new one needs a new authentication)");
console.log("guest's follow call:", await following);
// The owner's Notes is its own object (identities include the principal), untouched.
console.log("owner tab:", ownerNotes.state.value.kind, ownerNotes.notes.value);
console.log(
"worker S1 references:",
workers[0]!.references(Notes).value.map(({ principal }) => principal?.kind),
);
// 4. A worker resolved later gets no past revocation: its objects are new, and the revoked links are closed already.
const s2 = await owner.get(Notes, { session: "S2" }, context);
console.log("owner on S2:", s2.state.value.kind);
await Promise.all([one, two, owner].map((host) => host.close(context)));
await server.close(context);
await Promise.all(workers.map((worker) => worker.close(context)));

View file

@ -0,0 +1,93 @@
// Presence and keep-alive counts. `host.references(Type)` is a JSON watchable of the references to objects of a type
// on this host, one entry per reference with the object's params, its principal and its incarnation (two tabs are two
// origins); a derive over it is a viewer count, and a link drop is a departure in the same batch. `host.activity` counts
// what keeps a host busy (holds over links, running calls, jobs, lingering objects), published at most once per
// microtask turn: the app's supervisor decides when an idle worker stops.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/16-presence-and-activity.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { hostKind, int, method, remote, str } from "../../src/contracts/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "user"; name: string } | { kind: "server" };
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
/** A shared document view: who else is looking (`viewers`), and a slow method. */
const Doc = remote("example.doc", {
version: 1,
params: { id: str() },
hostAddress: () => Server(),
variants: { v: { state: { viewers: int() }, methods: { think: method(undefined, int()) } } },
});
// 1. The server serves `Doc`; its factory derives the viewer count from `host.references(Doc)` (on the facet's host).
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError: console.error });
const facet = defineFacet<P>((host) => {
host.serve(
Doc,
({ id }, _principal, self) => ({
variant: "v",
members: {
viewers: self.derive([host.references(Doc)], ([refs]) => refs.filter((ref) => ref.params.id === id).length),
think: async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 20));
return ctx.principal?.kind === "user" ? 42 : 0;
},
},
}),
{ linger: 50 },
);
});
await server.load([{ name: "@example/doc", facet }], context);
// 2. The supervisor's view: every publication of the counts.
const activity = await server.activity.watch(context);
activity.start(async (call) => {
if (call.kind === "value") console.log(" activity:", JSON.stringify(call.value));
});
/** A tab of `name` on a new link; returns the tab and the server's end of its link. */
async function tab(name: string) {
const host = createHost<P>({ onError: console.error });
await host.load([], context);
const [a, b] = memoryLinkPair();
server.accept(a, { principal: { kind: "user", name } }, context);
host.connect(b, { principal: { kind: "server" } }, context);
return { host, link: a };
}
// 3. Ada opens the document in two tabs, Bob in one: three references, three incarnations, two objects (one per principal);
// the viewer count is over every object of the document.
const [ada1, ada2, bob] = await Promise.all([tab("ada"), tab("ada"), tab("bob")]);
const doc = await ada1.host.get(Doc, { id: "d1" }, context);
await ada2.host.get(Doc, { id: "d1" }, context);
await bob.host.get(Doc, { id: "d1" }, context);
await new Promise((resolve) => setTimeout(resolve, 5));
console.log("viewers seen by ada:", doc.viewers.value);
const present = server
.references(Doc)
.value.map(
({ principal, incarnation }) => `${principal?.kind === "user" ? principal.name : "?"} @ ${incarnation.slice(-4)}`,
);
console.log("references:", present);
// 4. A call running counts as activity until it settles.
await doc.think(context);
// 5. Bob's link drops: a departure, at once. Bob's own `Doc` object (identities include the principal) lingers.
bob.link.close();
await new Promise((resolve) => setTimeout(resolve, 5));
console.log("viewers after bob's link dropped:", doc.viewers.value);
// 6. Ada closes both tabs: the object lingers (50 ms) with no references, then ends; then everything is zero.
await ada1.host.close(context);
await ada2.host.close(context);
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("references at the end:", server.references(Doc).value.length);
await activity.stop();
await server.close(context);

View file

@ -0,0 +1,199 @@
// Size limits per part and compression at the origin. A host with a synchronous compressor (`nodeCompression` on
// Node) deflates each JSON part above 16 KiB once, when it encodes it; relays forward it as it is; the end that owns it
// inflates it, within its link's decoded limit. A browser has no synchronous deflate: it sends uncompressed and
// inflates with `DecompressionStream`, later messages of its link waiting in order. Only a part that shrinks is sent
// deflated, and never hold params. Clients never send compressed parts: a host sends its call arguments up its parent
// link uncompressed, and a link the server accepted (a child) refuses compressed parts as a protocol error (a small
// part could inflate to 64 MiB there). A part past a limit fails only its own hold or call (`too_large/part`); the
// link stays. Limits and the threshold count UTF-8 bytes.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/17-limits-and-compression.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, hostKind, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { isChordError } from "../../src/data/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../src/host/index.ts";
import { type Compression, type Link, memoryLinkPair, type PartLimits } from "../../src/link/index.ts";
import { nodeCompression } from "../../src/node/compression.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "user" } | { kind: "server" };
const Entry = obj({ id: str(), text: str() });
/** The shared store of a conversation: its entries, once, and the method that appends to them. */
const Store = local("example.store", {
version: 1,
params: { id: str() },
members: { entries: arr(Entry), append: method(Entry) },
});
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
/** What a tab holds: the conversation's entries, and appending. */
const Conversation = remote("example.conversation", {
version: 1,
params: { id: str() },
hostAddress: () => Server(),
variants: { v: { state: { entries: arr(Entry) }, methods: { append: method(Entry) } } },
});
const Entries = defineState("entries", {
schema: obj({ list: arr(Entry) }),
version: 1,
initial: () => ({ list: [] }),
});
const facet = defineFacet<P>((host) => {
host.serve(Store, (_, self) => {
const doc = self.state(Entries);
return {
entries: self.derive([doc], ([d]) => d.list),
append: async (entry, ctx) => {
await doc.change((draft) => void draft.list.push(entry), ctx);
return null;
},
};
});
host.serve(Conversation, ({ id }, _principal, self) => {
const store = self.get(Store, { id });
return { variant: "v", members: { entries: store.entries, append: (entry, ctx) => store.append(entry, ctx) } };
});
});
/** A link end counting the bytes its host receives. */
function counted(link: Link, bytes: { received: number }): Link {
return {
send: (message) => link.send(message),
onMessage: (listener) =>
link.onMessage((message) => {
bytes.received += message.byteLength;
listener(message);
}),
close: () => link.close(),
onClose: (listener) => link.onClose(listener),
};
}
/**
* A server compressing with `compression` and a tab without a compressor (a browser) on a link the server accepts with
* `limits`; the tab's own link to the server has `tabLimits`.
*/
async function setUp(compression: Compression | undefined, limits?: PartLimits, tabLimits?: PartLimits) {
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
compression,
onError: console.error,
});
await server.load([{ name: "@example/conversation", facet }], context);
const tab = createHost<P>({ onError: console.error });
await tab.load([], context);
const [a, b] = memoryLinkPair();
const bytes = { received: 0 };
server.accept(a, { principal: { kind: "user" }, limits }, context);
tab.connect(counted(b, bytes), { principal: { kind: "server" }, limits: tabLimits }, context);
return { server, tab, bytes };
}
/** Words for entries that compress like prose (about 3x), not like a repeated string. */
const WORDS = "the a model tool call file read write test error result agent session note code run build".split(" ");
/** Entry `i`'s text: about 1 KB of words picked by a fixed pseudo-random sequence. */
function text(i: number): string {
let seed = i * 7919 + 1;
const words: string[] = [];
for (let length = 0; length < 1000; length += words.at(-1)!.length + 1) {
seed = (seed * 1103515245 + 12345) % 2147483648;
const word = WORDS[seed % WORDS.length]!;
words.push(seed % 5 === 0 ? `${word}${seed % 1000}` : word);
}
return words.join(" ");
}
/** Fills the server's conversation `id` with 500 entries of about 1 KB (its store kept by this reference). */
async function fill(server: Host<P>, id: string): Promise<void> {
const conversation = await server.get(Store, { id }, context);
for (let i = 0; i < 500; i++) await conversation.append({ id: `e${i}`, text: text(i) }, context);
}
// 1. A 500 KB snapshot to a browser tab, without and with compression at the server.
for (const compression of [undefined, nodeCompression]) {
const { server, tab, bytes } = await setUp(compression);
await fill(server, "c1");
const started = performance.now();
const conversation = await tab.get(Conversation, { id: "c1" }, context);
const ms = (performance.now() - started).toFixed(1);
const label = compression === undefined ? "uncompressed" : "deflated at the server, inflated asynchronously";
console.log(
`snapshot ${label}: ${conversation.entries.value.length} entries, ${(bytes.received / 1024).toFixed(1)} KiB received, ${ms} ms`,
);
await tab.close(context);
await server.close(context);
}
// 2. Limits per part: the server admits at most 64 KiB per part from this tab. A larger argument fails its call only.
{
const { server, tab } = await setUp(nodeCompression, { part: 64 * 1024 });
const conversation = await tab.get(Conversation, { id: "c2" }, context);
try {
await conversation.append({ id: "big", text: "x".repeat(100_000) }, context);
} catch (error) {
console.log(
"a 100 KB argument:",
isChordError(error) ? `${error.code}/${error.reason}: ${error.message}` : error,
);
}
await conversation.append({ id: "small", text: "fits" }, context);
console.log(
"the link stays: entries",
conversation.entries.value.map(({ id }) => id),
);
await tab.close(context);
await server.close(context);
}
// 3. A compression bomb: 50 KB deflated that would inflate to 6 MB, past the tab's decoded limit of 1 MiB. The tab
// stops inflating at the limit; only that hold fails.
{
const { server, tab } = await setUp(nodeCompression, undefined, { decoded: 1024 * 1024 });
const bomb = await server.get(Store, { id: "bomb" }, context);
for (let i = 0; i < 6; i++) await bomb.append({ id: `z${i}`, text: "0".repeat(1_000_000) }, context);
await fill(server, "fine");
try {
await tab.get(Conversation, { id: "bomb" }, context);
} catch (error) {
console.log("the bomb's hold:", isChordError(error) ? `${error.code}/${error.reason}` : error);
}
const fine = await tab.get(Conversation, { id: "fine" }, context);
console.log("another hold on the same link:", fine.entries.value.length, "entries");
await tab.close(context);
await server.close(context);
}
// 4. A client with a compressor still sends its arguments up uncompressed: the server, which accepted its link as a
// child, would refuse a compressed part. Its 100 KB argument arrives as JSON, within the server's default limits.
{
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
compression: nodeCompression,
onError: console.error,
});
await server.load([{ name: "@example/conversation", facet }], context);
const client = createHost<P>({ compression: nodeCompression, onError: console.error });
await client.load([], context);
const [a, b] = memoryLinkPair();
const bytes = { received: 0 };
server.accept(counted(a, bytes), { principal: { kind: "user" } }, context);
client.connect(b, { principal: { kind: "server" } }, context);
const conversation = await client.get(Conversation, { id: "c4" }, context);
await conversation.append({ id: "big", text: text(0).repeat(100) }, context);
console.log(
`a compressing client's 100 KB argument: ${(bytes.received / 1024).toFixed(1)} KiB received by the server`,
);
await client.close(context);
await server.close(context);
}

View file

@ -0,0 +1,115 @@
// Routines: an object's standing behaviour, declared in its factory. `self.on(source, fn)` reconciles from a value: the
// first call gets the current value (`[["r", value]]`), then one call per change, never overlapping; a call that
// throws is reported (`routine_failed`) and called again with the whole current value after a backoff (1 s doubling to
// 60 s), or at once when the source changes. `self.onLive(fn)` runs once the object is live: a loop with `sleep` is a
// periodic task; its ctx is aborted when the object ends, which `sleep` turns into a rejection, so nothing is left
// running.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/18-routines.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, bool, local, map, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, sleep } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
/** The catalog of sessions: another owner's state, which the forwards reconcile from. */
const Catalog = local("example.catalog", {
version: 1,
params: {},
members: { sessions: map(obj({ archived: bool() })), archive: method(obj({ session: str() })) },
});
/** Port forwards per session; a forward of an archived session must be closed (an invariant across owners, §9). */
const ForwardStore = local("example.forwards", {
version: 1,
params: {},
members: { open: arr(str()), cleanups: arr(str()) },
});
const Sessions = defineState("example.sessions", {
schema: obj({ sessions: map(obj({ archived: bool() })) }),
version: 1,
initial: () => ({ sessions: { s1: { archived: false }, s2: { archived: false } } }),
});
const Forwards = defineState("example.forward-list", {
schema: obj({ open: arr(str()), cleanups: arr(str()) }),
version: 1,
initial: () => ({ open: ["s1", "s2"], cleanups: [] }),
});
const started = Date.now();
const at = () => `${Math.round((Date.now() - started) / 100) / 10}s`;
// 1. The host reports background failures: a routine call that threw arrives as `routine_failed`.
const host = createHost({
onError: (error) => {
if (error.kind === "routine_failed") {
const { kind, position } = error.routine;
console.log(` [${at()}] ${error.object} ${kind} #${position} failed: ${(error.error as Error).message}`);
} else console.error(error);
},
});
let flaky = true;
const facet = defineFacet((h) => {
h.serve(Catalog, (_, self) => {
const doc = self.state(Sessions);
return {
sessions: self.derive([doc], ([d]) => d.sessions),
archive: async ({ session }, ctx) => {
await doc.change((d) => {
d.sessions[session] = { archived: true };
}, ctx);
return null;
},
};
});
h.serve(ForwardStore, (_, self) => {
const forwards = self.state(Forwards);
const catalog = self.get(Catalog, {});
// 2. Reconcile: close every forward of an archived session. Idempotent, from the current value; the first close
// fails once (a flaky remote), and the retry comes with the whole value.
self.on(catalog.sessions, async ({ value: sessions, ops }, ctx) => {
console.log(` [${at()}] on: ${JSON.stringify(ops)}`);
for (const session of forwards.value.open) {
if (!sessions[session]?.archived) continue;
if (flaky) {
flaky = false;
throw new Error(`closing the forward of ${session} timed out`);
}
await forwards.change((d) => {
d.open = d.open.filter((open) => open !== session);
}, ctx);
console.log(` [${at()}] closed the forward of ${session}`);
}
});
// 3. A periodic task: a loop with `sleep`, ended by the object's end (the abort rejects `sleep`).
self.onLive(async (ctx) => {
for (;;) {
await sleep(400, ctx);
await forwards.change((d) => void d.cleanups.push(at()), ctx);
}
});
return {
open: self.derive([forwards], ([d]) => d.open),
cleanups: self.derive([forwards], ([d]) => d.cleanups),
};
});
});
await host.load([{ name: "@example/forwards", facet }], context);
const store = await host.get(ForwardStore, {}, context);
const catalog = await host.get(Catalog, {}, context);
await sleep(50, context);
// 4. Archive s2: the routine's call throws once, is retried after 1 s with the whole value, and closes the forward.
console.log("archiving s2");
await catalog.archive({ session: "s2" }, context);
await sleep(1_300, context);
console.log("open forwards:", store.open.value);
console.log("periodic cleanups so far:", store.cleanups.value);
// 5. Closing the host aborts the routines: the periodic loop ends in its `sleep`, nothing is reported.
await host.close(context);
console.log("closed; nothing left running");

View file

@ -0,0 +1,219 @@
// Jobs: owned work a call starts that outlives the call, in Durable's sandwich: intent (`start`, one transaction), run
// (any effect, outside transactions) and finish (one transaction on the current code, with the cleanup). Here a shell
// job of a shared chat store: its state document carries the output, throttled by the author and flushed in a
// `finally`, so the last output reaches `outcome.state` after an abort too; `running` is ordinary state, which
// per-principal views filter (a guest sees no output); `finish` records an entry and chains a second job.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/19-jobs.ts
import { BACKGROUND_CONTEXT, type Context } from "../../src/context/index.ts";
import { arr, hostKind, int, local, method, obj, remote, type Static, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, sleep } from "../../src/host/index.ts";
import { defineJob } from "../../src/jobs/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
import { OutputBuffer, Progress } from "../../src/output.ts";
import { mapShared } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
type P = { kind: "owner" } | { kind: "guest" } | { kind: "server" };
// 1. The job: closed args (checked at every start), a result, a state document per run, one run at a time.
const ShellState = defineState("example.shell.state", {
schema: obj({ output: str() }),
version: 1,
initial: () => ({ output: "" }),
});
const ShellJob = defineJob("example.shell", {
version: 1,
args: obj({ command: str(), lines: int() }),
result: obj({ exit: int(), output: str() }),
state: ShellState,
concurrency: 1,
});
/** A second job `finish` chains: it announces a finished command. */
const NotifyJob = defineJob("example.notify", { version: 1, args: obj({ text: str() }), result: str() });
const Entries = defineState("example.entries", {
schema: obj({ list: arr(str()) }),
version: 1,
initial: () => ({ list: [] }),
});
/** A shell as its owner sees it; a guest's view has no output. */
const Shell = obj({ id: str(), phase: str(), command: str(), output: str() });
const GuestShell = obj({ id: str(), phase: str(), command: str() });
/** The shared store: the entries and the shells once, whoever views them (§6). */
const ChatStore = local("example.chat-store", {
version: 1,
params: {},
members: {
entries: arr(str()),
shells: arr(Shell),
runShell: method(obj({ command: str(), lines: int(), requestId: str() }), str()),
abortShell: method(obj({ id: str() })),
},
});
// The server's host kind: a remote contract's `hostAddress` names the host serving its objects.
const Server = hostKind("server");
/** Per-principal views: the owner runs shells and sees their output, a guest sees which shells run. */
const Chat = remote("example.chat", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
owner: {
state: { entries: arr(str()), shells: arr(Shell) },
methods: {
runShell: method(obj({ command: str(), lines: int(), requestId: str() }), str()),
abortShell: method(obj({ id: str() })),
},
},
guest: { state: { entries: arr(str()), shells: arr(GuestShell) } },
},
});
/** A fake process: one line every 30 ms; `sleep` rejects when the run is aborted. */
async function runProcess(lines: number, ctx: Context, onLine: (line: string) => void): Promise<number> {
for (let n = 1; n <= lines; n++) {
await sleep(30, ctx);
onLine(`line ${n}\n`);
}
return 0;
}
const facet = defineFacet<P>((host) => {
host.serve(ChatStore, (_, self) => {
const entries = self.state(Entries);
const notify = self.job(NotifyJob, {
run: async ({ text }) => `notified: ${text}`,
finish: async (outcome, tx) => {
if (outcome.kind === "done") (await tx.draft(entries)).list.push(outcome.result);
},
});
const shell = self.job(ShellJob, {
run: async ({ lines }, ctx) => {
// The output's bounds are required: a tail of at most 2,000 lines and 64 KiB.
const out = new OutputBuffer({ maxBytes: 64 * 1024, maxLines: 2_000, retain: "tail" });
// Every state change is a commit and, while watched, a message: the author throttles them (50 ms here).
const write = async () => {
const text = out.snapshot().text;
await ctx.state.change((draft) => {
draft.output = text;
}, ctx);
return text.length;
};
const progress = new Progress(write, (error) => console.error("progress failed:", error), 50);
try {
const exit = await runProcess(lines, ctx, (line) => {
out.push(line);
progress.mark();
});
return { exit, output: out.snapshot().text };
} finally {
// The last output reaches `outcome.state`, also after an abort (changes are taken until run settles).
await progress.stop();
await write();
}
},
finish: async (outcome, tx) => {
const { command } = outcome.args;
const lines = outcome.state.output.split("\n").length - 1;
const text =
outcome.kind === "done"
? `${command}: exit ${outcome.result.exit}, ${lines} lines`
: `${command}: interrupted (${outcome.error.kind}), ${lines} lines kept`;
(await tx.draft(entries)).list.push(text);
// Chaining: the next job starts atomically with this outcome (both commit or neither).
tx.start(notify, { text: command });
},
});
// What the views read: one object per run, kept while the run's entry is the same object (`mapShared`).
const toShell = mapShared(
(entry: (typeof shell.running.value)[number]): Static<typeof Shell> =>
entry.phase === "blocked"
? { id: entry.id, phase: entry.phase, command: "?", output: "" }
: { id: entry.id, phase: entry.phase, command: entry.args.command, output: entry.state.output },
);
return {
entries: self.derive([entries], ([d]) => d.list),
shells: self.derive([shell.running], ([running]) => toShell(running)),
runShell: async ({ command, lines, requestId }, ctx) =>
(await shell.start({ command, lines }, ctx, { key: requestId })).id,
abortShell: async ({ id }) => {
void shell.abort(id, context);
return null;
},
};
});
host.serve(Chat, (_, principal, self) => {
const store = self.get(ChatStore, {});
if (principal.kind !== "owner") {
// A guest sees no output: the view drops it (computed once per variant in a real store, §12).
const strip = mapShared(
(s: Static<typeof Shell>): Static<typeof GuestShell> => ({ id: s.id, phase: s.phase, command: s.command }),
);
return {
variant: "guest",
members: { entries: store.entries, shells: self.derive([store.shells], ([list]) => strip(list)) },
};
}
return {
variant: "owner",
members: {
entries: store.entries,
shells: store.shells,
runShell: (args, ctx) => store.runShell(args, ctx),
abortShell: (args, ctx) => store.abortShell(args, ctx),
},
};
});
});
// 2. A server and two tabs, the owner's and a guest's.
const server = createHost<P>({
address: Server(),
principal: { kind: "server" },
onError: (error) => console.error(error),
});
await server.load([{ name: "@example/chat", facet }], context);
async function tab(principal: P) {
const host = createHost<P>({ onError: (error) => console.error(error) });
await host.load([], context);
const [a, b] = memoryLinkPair();
server.accept(a, { principal }, context);
host.connect(b, { principal: { kind: "server" } }, context);
return host.get(Chat, {}, context);
}
const owner = await tab({ kind: "owner" });
const guest = await tab({ kind: "guest" });
if (owner.variant !== "owner") throw new Error("expected the owner's variant");
// 3. The owner runs a command; its request id dedupes a resent request (the same run comes back).
const id = await owner.runShell({ command: "npm test", lines: 5, requestId: "r1" }, context);
const again = await owner.runShell({ command: "npm test", lines: 5, requestId: "r1" }, context);
console.log("a resent request returns the same run:", again === id);
await sleep(100, context);
console.log("owner sees:", owner.shells.value);
console.log("guest sees:", guest.shells.value);
// 4. One run at a time: a second command is refused while the first runs.
try {
await owner.runShell({ command: "npm run build", lines: 1, requestId: "r2" }, context);
} catch (error) {
console.log("second run refused:", (error as Error).message);
}
await sleep(300, context);
console.log("entries after the first run:", owner.entries.value);
// 5. Abort is a request: the run stops at its next `sleep`, its last output is flushed, `finish` records it.
const long = await owner.runShell({ command: "tail -f log", lines: 100, requestId: "r3" }, context);
await sleep(100, context);
await owner.abortShell({ id: long }, context);
await sleep(100, context);
console.log("entries after the abort:", owner.entries.value);
console.log("running now:", owner.shells.value);
await server.close(context);

View file

@ -0,0 +1,101 @@
// Tables: an object's rows in the host's store, declared with `defineTable`, written in transactions (with the
// object's documents, if it has any), read by key, by page with cursors, and counted.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/20-tables.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, bool, int, local, method, obj, opt, str } from "../../src/contracts/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
import { add, defineTable, eq, openStore, prefix, query, sqliteBackend } from "../../src/store/index.ts";
const context = BACKGROUND_CONTEXT;
// Flat columns (str, int, num, bool, opt(...), json(...)), defaults, a key of non-opt str or int columns, and
// indexes, which end with the key.
const Edits = defineTable("edits", {
version: 1,
columns: {
id: str(),
path: str(),
at: int(),
hits: int({ default: 0 }),
done: bool({ default: false }),
note: opt(str()),
},
key: ["id"],
indexes: { by_path: ["path", "at"] },
});
const History = local("example.history", {
version: 1,
params: {},
members: {
record: method(obj({ id: str(), path: str(), at: int() })),
hit: method(str()),
finish: method(str()),
hits: method(str(), int()),
open: method(obj({ after: opt(str()) }), obj({ ids: arr(str()), next: opt(str()) })),
finished: method(obj({}), int()),
},
});
const facet = defineFacet((host) => {
// Tables need a stable identity: a local type with empty params (one object per host).
host.serve(History, (_, self) => {
const edits = self.table(Edits);
// A query of the table: a condition and an order (the key is the tie-break); immutable, built once.
const open = query(Edits)
.where((e) => eq(e.done, false))
.orderBy((e) => e.at, "desc");
return {
record: async (row, ctx) => {
// Writes are staged synchronously (a bad row throws here) and commit together in one step.
await self.tx(async (tx) => tx.put(edits, row), ctx);
return null;
},
hit: async (id, ctx) => {
// A set is values or an expression over the row's columns, computed by the store.
await self.tx(async (tx) => tx.update(edits, { id }, (e) => ({ hits: add(e.hits, 1) })), ctx);
return null;
},
finish: async (path, ctx) => {
// One statement for every matching row; its count is recorded with the write.
await self.tx(async (tx) => tx.updateWhere(edits, (e) => prefix(e.path, path), { done: true }), ctx);
return null;
},
hits: async (id, ctx) => (await edits.get({ id }, ctx))?.hits ?? 0,
open: async ({ after }, ctx) => {
const page = await edits.page(open, 1, ctx, after === undefined ? {} : { after });
return { ids: page.items.map((row) => row.id), ...(page.next === undefined ? {} : { next: page.next }) };
},
finished: async (_, ctx) =>
edits.count(
query(Edits).where((e) => eq(e.done, true)),
ctx,
),
};
});
});
// A store over SQLite (`:memory:` here, a file in an app): the app opens it, gives it to one host, and closes it.
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(":memory:")), context);
const host = createHost({ store, onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/history", facet }], context);
const history = await host.get(History, {}, context);
for (let i = 0; i < 5; i++) {
await history.record({ id: `e${i}`, path: `src/${i % 2 === 0 ? "a" : "b"}.ts`, at: i }, context);
}
await history.hit("e1", context);
await history.hit("e1", context);
await history.finish("src/a", context);
console.log("hits of e1:", await history.hits("e1", context));
// Pages continue from an opaque cursor.
const first = await history.open({}, context);
console.log("open, first page:", first.ids);
console.log("open, next page:", (await history.open({ after: first.next }, context)).ids);
console.log("finished:", await history.finished({}, context));
await host.close(context);
await store.close(context);

View file

@ -0,0 +1,91 @@
// Persisted documents: `persist: true` keeps an object's documents in the host's store, so a new host on the same
// store finds them again; `checkpointWhen` decides when a change is stored as a new base instead of a delta.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/21-persisted-documents.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Reference } from "../../src/host/index.ts";
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
import { openStore, sqliteBackend } from "../../src/store/index.ts";
const context = BACKGROUND_CONTEXT;
// A persisted singleton: loaded once when its object activates; every 20th stored change is a new base.
const Tags = defineState("tags", {
schema: obj({ tags: arr(str()) }),
version: 1,
initial: () => ({ tags: [] }),
persist: true,
checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 20,
});
// A persisted keyed document: each key loaded on its first read, then cached until idle for ten minutes.
const Notes = defineState("notes", {
schema: obj({ body: str() }),
version: 1,
initial: () => ({ body: "" }),
keyed: true,
persist: true,
checkpointWhen: () => false,
});
const Notebook = local("example.notebook", {
version: 1,
params: {},
members: {
tags: obj({ tags: arr(str()) }),
write: method(obj({ id: str(), body: str(), tag: str() })),
read: method(str(), str()),
},
});
const facet = defineFacet((host) => {
// Persisted documents need a stable identity, as tables do: a local type with empty params.
host.serve(Notebook, (_, self) => {
const tags = self.state(Tags);
const notes = self.state(Notes);
return {
tags,
write: async ({ id, body, tag }, ctx) => {
// Both documents commit in one store step, or neither does.
await self.tx(async (tx) => {
(await tx.draft(notes, id)).body = body;
const draft = await tx.draft(tags);
if (!draft.tags.includes(tag)) draft.tags.push(tag);
}, ctx);
return null;
},
read: async (id, ctx) => (await notes.get(id, ctx))?.body ?? "(none)",
};
});
});
/** A host on the store at `path`, run until `work` is done with the notebook. */
async function session(path: string, work: (notebook: Reference<typeof Notebook>) => Promise<void>) {
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(path)), context);
const host = createHost({ store, onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/notebook", facet }], context);
await work(await host.get(Notebook, {}, context));
await host.close(context);
await store.close(context);
}
const directory = await mkdtemp(join(tmpdir(), "chord-example-"));
const path = join(directory, "notebook.db");
try {
await session(path, async (notebook) => {
await notebook.write({ id: "groceries", body: "oat milk", tag: "home" }, context);
await notebook.write({ id: "standup", body: "demo the store", tag: "work" }, context);
console.log("first host, tags:", notebook.tags.value.tags);
});
// A new host on the same file: the documents come back from the store.
await session(path, async (notebook) => {
console.log("second host, tags:", notebook.tags.value.tags);
console.log("second host, standup:", await notebook.read("standup", context));
});
} finally {
await rm(directory, { recursive: true, force: true });
}

View file

@ -0,0 +1,100 @@
// Table migrations: `evolveTable` appends a version with its edits (rename, add, json before an optional data step;
// drop, index, dropIndex after it). An object whose store holds an older version migrates it when it activates, one
// version at a time, in chunks of 100 rows; a fresh store gets the newest version directly.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/22-migrations.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, int, local, method, obj, str } from "../../src/contracts/index.ts";
import { createHost, defineFacet, type Reference } from "../../src/host/index.ts";
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
import {
defineTable,
evolveTable,
openStore,
query,
sqliteBackend,
type TableDefinition,
} from "../../src/store/index.ts";
const context = BACKGROUND_CONTEXT;
// Version 1, the base, written in full.
const EditsV1 = defineTable("edits", { version: 1, columns: { id: str(), file: str(), at: int() }, key: ["id"] });
// Version 2: `file` becomes `path`, a `dir` column is added and filled from each row (the data step).
const EditsV2 = evolveTable(EditsV1, (t) =>
t
.rename("file", "path")
.add("dir", str({ default: "" }))
.rows((row) => ({ dir: row.path.slice(0, row.path.lastIndexOf("/")) })),
);
// Version 3: `dir` is dropped again, an index is added. Data functions stay as deployed; history only grows.
const EditsV3 = evolveTable(EditsV2, (t) => t.drop("dir").index("by_path", ["path", "at"]));
const History = local("example.history", {
version: 1,
params: {},
members: { record: method(obj({ id: str(), at: int() })), dump: method(obj({}), arr(str())) },
});
/** The facet of one deployment: its newest table version, and how it writes a row. */
function facetOf(definition: TableDefinition, row: (id: string, at: number) => Record<string, unknown>) {
return defineFacet((host) => {
host.serve(History, (_, self) => {
const edits = self.table(definition);
return {
record: async ({ id, at }, ctx) => {
// A deployment's rows match its own version (an example shortcut for two versions in one file).
await self.tx(async (tx) => tx.put(edits, row(id, at) as never), ctx);
return null;
},
dump: async (_, ctx) => {
const lines: string[] = [];
for await (const row of edits.rows(query(definition), ctx)) lines.push(JSON.stringify(row));
return lines;
},
};
});
});
}
/** A host on the store at `path` serving `facet`, run until `work` is done. */
async function deployment(
path: string,
facet: ReturnType<typeof facetOf>,
work: (history: Reference<typeof History>) => Promise<void>,
) {
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(path)), context);
const host = createHost({ store, onError: (error) => console.error("background error:", error) });
// Objects with empty params activate at load: the migration runs before `load` resolves.
await host.load([{ name: "@example/history", facet }], context);
await work(await host.get(History, {}, context));
await host.close(context);
await store.close(context);
}
const directory = await mkdtemp(join(tmpdir(), "chord-example-"));
const path = join(directory, "history.db");
try {
await deployment(
path,
facetOf(EditsV1, (id, at) => ({ id, file: `src/${id}.ts`, at })),
async (history) => {
for (let i = 0; i < 3; i++) await history.record({ id: `e${i}`, at: i }, context);
console.log("version 1:", await history.dump({}, context));
},
);
// The next deployment declares version 3: the store goes through 2 (rename, add, rows) and 3 (drop, index).
await deployment(
path,
facetOf(EditsV3, (id, at) => ({ id, path: `lib/${id}.ts`, at })),
async (history) => {
await history.record({ id: "e9", at: 9 }, context);
console.log("version 3:", await history.dump({}, context));
},
);
} finally {
await rm(directory, { recursive: true, force: true });
}

View file

@ -0,0 +1,84 @@
// Keyed watches and `at(key)`: a keyed document is watched per key; `handle.at(key)` in the factory makes one key a
// watchable (a state member here) that reads `initial()` while absent. A retirement arrives as `initial()` with
// `retired: true`, so a watcher can tell "deleted" from "changed back".
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/23-keyed-watches.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState, type KeyedHandle } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
type Note = { body: string };
const Notes = defineState("notes", {
schema: obj({ body: str() }),
version: 1,
initial: (): Note => ({ body: "" }),
keyed: true,
});
const Notebook = local("example.notebook", {
version: 1,
params: {},
members: {
// The note "pinned" as a state member: `initial()` while absent.
pinned: obj({ body: str() }),
write: method(obj({ id: str(), body: str() })),
remove: method(str()),
},
});
// The example's own window into the object: its keyed handle, to watch other keys from outside.
let notes: KeyedHandle<Note> | undefined;
const facet = defineFacet((host) => {
host.serve(Notebook, (_, self) => {
notes = self.state(Notes);
const handle = notes;
return {
// Pinned while this attempt lives, and loaded before the object goes live.
pinned: handle.at("pinned"),
write: async ({ id, body }, ctx) => {
await handle.change(
id,
(draft) => {
draft.body = body;
},
ctx,
);
return null;
},
remove: async (id, ctx) => {
await handle.retire(id, ctx);
return null;
},
};
});
});
const host = createHost({ onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/notebook", facet }], context);
const notebook = await host.get(Notebook, {}, context);
// A watch of the state member: every value call carries the value, its ops and the retired flag.
const pinned = await notebook.pinned.watch(context);
pinned.start(async (call) => {
if (call.kind === "value") console.log("pinned:", call.value, call.retired ? "(retired)" : "");
});
await notebook.write({ id: "pinned", body: "buy milk" }, context);
// A watch of one key; an absent key gives `undefined` (watch it once it exists, or pin it with `at`).
console.log("watch of an absent key:", await notes!.watch("later", context));
await notebook.write({ id: "later", body: "call Anna" }, context);
const later = (await notes!.watch("later", context))!;
later.start(async (call) => {
if (call.kind === "value") console.log("later:", call.value, call.retired ? "(retired)" : "");
});
await notebook.remove("later", context);
await notebook.remove("pinned", context);
// Frames are delivered after the commit, asynchronously: let them arrive before closing.
await new Promise((resolve) => setTimeout(resolve, 10));
await host.close(context);

View file

@ -0,0 +1,64 @@
// Keyed state members: a `local` contract exposes a keyed document set as one member, `keyed(schema)`. A consumer on
// the same host pins one key with `ref.member.at(key)` in its factory (a watchable, part of its own hydration), or
// reads and watches any key with `ref.member.get/watch(key, ctx)`. One source per key: consumers share it.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/24-keyed-members.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { keyed, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
const Profile = obj({ name: str(), status: str() });
const Profiles = defineState("profiles", {
schema: Profile,
version: 1,
initial: () => ({ name: "", status: "offline" }),
keyed: true,
});
// The directory: every user's profile, one keyed member.
const Directory = local("example.directory", {
version: 1,
params: {},
members: { profiles: keyed(Profile), set: method(obj({ user: str(), name: str(), status: str() })) },
});
// A badge per user: its own object, showing one profile.
const Badge = local("example.badge", { version: 1, params: { user: str() }, members: { label: str() } });
const facet = defineFacet((host) => {
host.serve(Directory, (_, self) => {
const profiles = self.state(Profiles);
return {
// The keyed handle is the member as it is.
profiles,
set: async ({ user, name, status }, ctx) => {
await profiles.change(user, (draft) => Object.assign(draft, { name, status }), ctx);
return null;
},
};
});
host.serve(Badge, ({ user }, self) => {
// Pinned for this attempt; the badge goes live once the profile is loaded.
const profile = self.get(Directory, {}).profiles.at(user);
return { label: self.derive([profile], ([p]) => `${p.name || user} (${p.status})`) };
});
});
const host = createHost({ onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/directory", facet }], context);
const directory = await host.get(Directory, {}, context);
await directory.set({ user: "ana", name: "Ana", status: "online" }, context);
const badge = await host.get(Badge, { user: "ana" }, context);
console.log("badge:", badge.label.value);
await directory.set({ user: "ana", name: "Ana", status: "away" }, context);
console.log("badge after a change:", badge.label.value);
// Any key, read where it is needed.
console.log("ref.profiles.get(bo):", await directory.profiles.get("bo", context));
badge.release();
await host.close(context);

View file

@ -0,0 +1,102 @@
// Export and import: `store.export(ctx)` yields the store's state as archive parts (lossless: tables with their rows
// and versions, the live documents), holding the commit queue while it runs; `encodeJsonl` turns them into JSONL.
// `importStore` checks an archive part by part and makes a new store from it in one backend transaction.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/25-export-import.ts
import { createReadStream, createWriteStream } from "node:fs";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Reference } from "../../src/host/index.ts";
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
import {
decodeJsonl,
defineTable,
encodeJsonl,
importStore,
openStore,
type Store,
sqliteBackend,
} from "../../src/store/index.ts";
const context = BACKGROUND_CONTEXT;
const Visits = defineTable("visits", { version: 1, columns: { page: str(), count: int() }, key: ["page"] });
const Title = defineState("title", {
schema: obj({ text: str() }),
version: 1,
initial: () => ({ text: "untitled" }),
persist: true,
checkpointWhen: () => false,
});
const Site = local("example.site", {
version: 1,
params: {},
members: {
title: obj({ text: str() }),
visit: method(str(), int()),
rename: method(str()),
},
});
const facet = defineFacet((host) => {
host.serve(Site, (_, self) => {
const visits = self.table(Visits);
const title = self.state(Title);
return {
title,
visit: async (page, ctx) =>
self.tx(async (tx) => {
const count = ((await tx.get(visits, { page }))?.count ?? 0) + 1;
tx.put(visits, { page, count });
return count;
}, ctx),
rename: async (text, ctx) => {
await title.change((draft) => {
draft.text = text;
}, ctx);
return null;
},
};
});
});
/** A host on `store` serving the site, run until `work` is done. */
async function serve(store: Store, work: (site: Reference<typeof Site>) => Promise<void>) {
const host = createHost({ store, onError: (error) => console.error("background error:", error) });
await host.load([{ name: "@example/site", facet }], context);
await work(await host.get(Site, {}, context));
await host.close(context);
}
const directory = await mkdtemp(join(tmpdir(), "chord-example-"));
try {
const original = await openStore(sqliteBackend(await openNodeSqliteDatabase(join(directory, "a.db"))), context);
await serve(original, async (site) => {
await site.rename("Chord", context);
await site.visit("/", context);
await site.visit("/", context);
await site.visit("/docs", context);
});
// Export to a local file (then upload it, if needed): the queue is held only while the parts are read.
const archive = join(directory, "archive.jsonl");
await pipeline(encodeJsonl(original.export(context)), createWriteStream(archive));
console.log("archive:", (await stat(archive)).size, "bytes at seq", original.lastSeq);
await original.close(context);
// Import into an empty backend: every part is checked; a refused archive leaves the backend empty.
const parts = decodeJsonl(createReadStream(archive));
const copy = await importStore(sqliteBackend(await openNodeSqliteDatabase(join(directory, "b.db"))), parts, context);
console.log("imported at seq", copy.lastSeq);
await serve(copy, async (site) => {
console.log("title:", site.title.value.text, "- next visit of /:", await site.visit("/", context));
});
await copy.close(context);
} finally {
await rm(directory, { recursive: true, force: true });
}

View file

@ -0,0 +1,125 @@
// Persisted jobs: `persist: { resume }` keeps each unsettled run's record and state document in the object's store.
// A run survives a crash (counted: five in a row end it `restart_limit`), a clean close (written back `stopped`, resumed
// without counting) and its object's retry; `abort` is stored and joins `run`; a state change may be `deferred`.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/28-persisted-jobs.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { int, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type Host } from "../../src/host/index.ts";
import { defineJob, type JobHandle } from "../../src/jobs/index.ts";
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
import { openStore, type Store, sqliteBackend } from "../../src/store/index.ts";
const context = BACKGROUND_CONTEXT;
// The run's state: persisted, as a persisted job's must be.
const ImportState = defineState("notes.import.state", {
schema: obj({ done: int() }),
version: 1,
initial: () => ({ done: 0 }),
persist: true,
checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 16,
});
const ImportJob = defineJob("notes.import", {
version: 1,
args: obj({ path: str() }),
result: obj({ count: int() }),
state: ImportState,
// After a crash or a stop, `run` runs again: it must be idempotent (only work since the last state change repeats).
persist: { resume: "rerun" },
});
const Notes = local("notes.store", {
version: 1,
params: {},
members: { start: method(str(), str()), abort: method(str(), str()) },
});
/** How the current process's `run` behaves: hang (to be cut off) or finish. */
let behaviour: "hang" | "finish" = "hang";
let jobs: JobHandle<{ path: string }, { count: number }, { done: number }> | undefined;
const facet = defineFacet((host) => {
host.serve(Notes, (_, self) => {
const imports = self.job(ImportJob, {
run: async ({ path }, ctx) => {
console.log(` run ${path}: from done = ${ctx.state.value.done}`);
// Progress, deferred: published at once, stored within 100 ms (or with the next immediate change).
await ctx.state.change(
(draft) => {
draft.done += 1;
},
ctx,
{ persistence: { kind: "deferred", withinMs: 100 } },
);
if (behaviour === "hang")
await new Promise((resolve) => ctx.abortSignal?.addEventListener("abort", resolve));
return { count: ctx.state.value.done };
},
finish: async (outcome) => {
console.log(` finish: ${outcome.kind}`, outcome.kind === "done" ? outcome.result : outcome.error.kind);
},
});
jobs = imports;
return {
start: async (path, ctx) => (await imports.start({ path }, ctx)).id,
abort: (id, ctx) => imports.abort(id, ctx),
};
});
});
/** A process: a store over the file and a host serving `Notes`. */
async function process(path: string): Promise<{ store: Store; host: Host }> {
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(path)), context);
const host = createHost({
store,
onError: (error) => console.log(" onError:", error.kind),
closeTimeout: 100,
});
await host.load([{ name: "@example/notes", facet }], context);
return { store, host };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 150));
const directory = await mkdtemp(join(tmpdir(), "chord-example-28-"));
const path = join(directory, "session.sqlite");
// 1. Start a run, then crash: the store closes under the live host.
console.log("1. start, then crash");
let { store, host } = await process(path);
const id = await (await host.get(Notes, {}, context)).start("inbox", context);
await settle();
await store.close(context);
// 2. The next process counts the crash and reruns it (its state as stored: the deferred change was stored in time).
console.log("2. reopen: the run is counted and rerun");
({ store, host } = await process(path));
await settle();
// 3. A clean close writes the run back `stopped`: the next process resumes it without counting.
console.log("3. clean close, reopen: resumed, uncounted");
await host.close(context);
await store.close(context);
behaviour = "finish";
({ store, host } = await process(path));
await settle();
console.log(" unsettled runs now:", jobs?.running.value.length);
// 4. A stored abort: marked, joined.
console.log("4. abort");
behaviour = "hang";
const notes = await host.get(Notes, {}, context);
const second = await notes.start("drafts", context);
await settle();
console.log(" abort:", await notes.abort(second, context));
await settle();
console.log(" once settled:", await notes.abort(second, context), "/ the first run:", await notes.abort(id, context));
await host.close(context);
await store.close(context);
await rm(directory, { recursive: true, force: true });

View file

@ -0,0 +1,115 @@
// Registries: an extension point between plugins. The defining plugin shares a token (`defineRegistry`: a name, a
// version, the contribution and value types) and serves it with `compose`, a pure function from the table of
// contributions (sorted by plugin name, never load order) to one value, conflicts resolved by a fallback and reported
// (`registry_conflict`). Facets and host-lifetime objects contribute one value each: a plain value, or a watchable
// (`self.writable`) whose changes recompose. Readers follow the composed value (`self.registry`, a dependency like
// `self.get`; `host.registry` outside objects) and guard each contribution where they call it.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/29-registries.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { local, method, obj, str } from "../../src/contracts/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { defineRegistry, keyedEntries, type TableItem } from "../../src/registries/index.ts";
const context = BACKGROUND_CONTEXT;
// 1. The token, shared by every plugin (bundled like a contract): routes in, a router out.
type Handler = (path: string) => string;
type Routes = { readonly routes: Readonly<Record<string, Handler>> };
type Router = ReadonlyMap<string, TableItem<Handler>>;
const Http = defineRegistry<Routes, Router>("example.http", { version: 1 });
/** The HTTP server: it reads the router at each request and guards the contribution it calls. */
const Server = local("example.server", {
version: 1,
params: {},
members: { request: method(obj({ path: str() }), str()) },
});
/** A plugin whose routes are known only at runtime: it contributes a writable it sets. */
const Mounts = local("example.mounts", { version: 1, params: {}, members: { mount: method(obj({ path: str() })) } });
// 2. The defining plugin serves the registry: the first route by plugin name wins, a duplicate is reported.
const httpPlugin = defineFacet((host) => {
host.serve(Http, { compose: (table, { report }) => keyedEntries(table, (entry) => entry.routes, report) });
host.serve(Server, (_, self) => {
const router = self.registry(Http);
return {
request: async ({ path }) => {
const route = router.value.get(path);
if (route === undefined) return "404";
// A contribution is another plugin's code: catch, report its plugin, go on.
try {
return route.value(path);
} catch (error) {
console.log(`route ${path} of ${route.plugin} threw:`, (error as Error).message);
return "500";
}
},
};
});
});
// 3. Two plugins contribute from their facets: plain values, in the table while the plugin is loaded.
const notesPlugin = defineFacet((_, self) => {
self.contribute(Http, { routes: { "/notes": () => "notes", "/broken": () => JSON.parse("{") } });
});
const searchPlugin = defineFacet((_, self) => {
self.contribute(Http, { routes: { "/search": () => "results", "/notes": () => "search's notes" } });
});
// 4. A host-lifetime object contributes a writable: its changes recompose the router.
const mountsPlugin = defineFacet((host) => {
host.serve(Mounts, (_, self) => {
const routes = self.writable<Routes>({ routes: {} });
self.contribute(Http, routes);
return {
mount: async ({ path }) => {
routes.set({ routes: { ...routes.value.routes, [path]: () => `mounted ${path}` } });
return null;
},
};
});
});
const host = createHost({
onError: (error) => {
if (error.kind === "registry_conflict") console.log("conflict:", error.message, error.plugins);
else console.log("onError:", error.kind);
},
});
// 5. A reader before `load` waits: the provider comes with `load`, which composes every registry before any object.
const early = host.registry(Http, context);
await host.load(
[
{ name: "@example/http", facet: httpPlugin },
{ name: "@example/notes", facet: notesPlugin },
{ name: "@example/search", facet: searchPlugin },
{ name: "@example/mounts", facet: mountsPlugin },
],
context,
);
const router = await early;
console.log(
"routes:",
[...router.value].map(([path, { plugin }]) => `${path} (${plugin})`),
);
// 6. Requests: a duplicate went to the first plugin by name; a throwing contribution is guarded at the use site.
const server = await host.get(Server, {}, context);
console.log("/notes ->", await server.request({ path: "/notes" }, context));
console.log("/broken ->", await server.request({ path: "/broken" }, context));
// 7. A watch of the composed value gets each new router; mounting recomposes it.
const watch = await router.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log("router now has:", [...call.value.keys()]);
});
const mounts = await host.get(Mounts, {}, context);
await mounts.mount({ path: "/files" }, context);
await new Promise((resolve) => setTimeout(resolve, 0));
console.log("/files ->", await server.request({ path: "/files" }, context));
await watch.stop();
await host.close(context);

View file

@ -0,0 +1,85 @@
// `host.root(fn, context)`: a call scope for code outside objects (a Durable tool, a CLI command). `fn` gets a ctx like a
// method handler's: the host's principal (or one a trusted caller names), this host's incarnation, the context's
// telemetry and signal, and `ctx.get` for references. When `fn` settles, or the context aborts, or the host closes, the
// scope is sealed: its signal aborts, its references are released, and a late `ctx.get` is refused `released`; so code
// that ignores its signal fails at its next use of Chord. (p3 wraps it as `chord(fn, context)` for Durable's tools.)
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/30-host-root.ts
import { BACKGROUND_CONTEXT, withCancel } from "../../src/context/index.ts";
import { arr, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { type Ctx, createHost, defineFacet, sleep } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
type Principal = { readonly kind: "server" } | { readonly kind: "owner"; readonly name: string };
const Notes = local("example.notes", {
version: 1,
params: { session: str() },
members: { list: arr(str()), add: method(obj({ text: str() })) },
});
const List = defineState("example.list", {
schema: obj({ list: arr(str()) }),
version: 1,
initial: () => ({ list: [] }),
});
const facet = defineFacet<Principal>((host) => {
host.serve(Notes, (_, self) => {
const notes = self.state(List);
return {
list: self.derive([notes], ([n]) => n.list),
add: async ({ text }, ctx) => {
await notes.change((draft) => {
draft.list.push(`${text} (by ${ctx.principal?.kind})`);
}, ctx);
return null;
},
};
});
});
const host = createHost<Principal>({ principal: { kind: "server" }, onError: (error) => console.log(error.kind) });
// 1. A scope made before `load` resolved: its `ctx.get` parks until the host is loaded.
const early = host.root(async (ctx) => (await ctx.get(Notes, { session: "s1" })).list.value, context);
await host.load([{ name: "@example/notes", facet }], context);
console.log("before load, read after it:", await early);
// 2. A tool's call: get, call, return plain data. The reference is released when fn settles.
const addNote = (text: string) =>
host.root(async (ctx) => {
const notes = await ctx.get(Notes, { session: "s1" });
await notes.add({ text }, ctx);
return notes.list.value;
}, context);
console.log("after a tool call:", await addNote("first"));
// 3. Base code acting for an authenticated user names the principal.
const owner: Principal = { kind: "owner", name: "ada" };
console.log("as the owner:", await host.root(async (ctx) => ctx.principal, context, { principal: owner }));
// 4. A kept ctx is sealed once fn settled.
let kept: Ctx<Principal> | undefined;
await host.root(async (ctx) => {
kept = ctx;
}, context);
console.log("kept ctx aborted:", kept!.abortSignal?.aborted);
await kept!.get(Notes, { session: "s1" }).catch((error: Error) => console.log("kept ctx.get:", error.message));
// 5. Cancelling the context rejects at once and seals the scope; an fn that ignores its signal fails at its next
// use of Chord.
const { context: cancellable, cancel } = withCancel(context);
const stubborn = host.root(async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 20));
await ctx.get(Notes, { session: "s1" });
}, cancellable);
cancel(new Error("user pressed stop"));
await stubborn.catch((error: Error) => console.log("cancelled:", error.message));
// 6. Closing the host seals the scopes still open and rejects their callers at once: an fn sleeping on its ctx ends.
const sleeping = host.root((ctx) => sleep(60_000, ctx), context).catch((error: Error) => `closed: ${error.message}`);
await host.close(context);
console.log(await sleeping);

View file

@ -0,0 +1,148 @@
// Parked dependencies (dependency model, slice 7a): a reference is a request that outlives every provider it meets. An
// object whose dependency is not there yet is parked, not failed: it shows `failed { waiting }` naming what it waits
// for (the nearest blocker and the root cause), no timer runs for it, its factory is not run again, and it goes on the
// moment its dependency is live. Here a tab's panel needs `Notes` from a server that does not serve it yet; the server
// restarts with the notes plugin, the tab reconnects, and the panel goes live without anyone asking.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/31-parked-dependencies.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { arr, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
import { isChordError } from "../../src/data/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type RefState } from "../../src/host/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
import type { NativeWatchable } from "../../src/watch.ts";
const context = BACKGROUND_CONTEXT;
const started = Date.now();
const at = (): string => `${((Date.now() - started) / 1000).toFixed(1)} s`;
type Principal = { readonly kind: "server" } | { readonly kind: "owner" };
const Server = hostKind("server");
/** The server's notes (the notes plugin), placed on the server. */
const Notes = remote("example.notes", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { editor: { state: { list: arr(str()) }, methods: { add: method(obj({ text: str() })) } } },
});
/** A panel in the tab: a local object over the server's notes. */
const Panel = local("example.panel", { version: 1, params: { id: int() }, members: { count: int() } });
/** A local object the panel's sibling needs, which no plugin of the tab serves. */
const Theme = local("example.theme", { version: 1, params: {}, members: { name: str() } });
const Header = local("example.header", { version: 1, params: { id: int() }, members: { title: str() } });
const List = defineState("example.list", {
schema: obj({ list: arr(str()) }),
version: 1,
initial: () => ({ list: [] }),
});
/** The notes plugin's server facet. */
const notesPlugin = defineFacet<Principal>((host) => {
host.serve(Notes, (_params, _principal, self) => {
const list = self.state(List);
return {
variant: "editor",
members: {
list: self.derive([list], ([l]) => l.list),
add: async ({ text }, ctx) => {
await list.change((draft) => void draft.list.push(text), ctx);
return null;
},
},
};
});
});
/** A server with the given plugins, its link to the tab accepted. */
async function startServer(plugins: { readonly name: string; readonly facet: typeof notesPlugin }[]) {
const server = createHost<Principal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load(plugins, context);
const [up, down] = memoryLinkPair();
server.accept(up, { principal: { kind: "owner" } }, context);
return { server, tabLink: down, close: () => up.close() };
}
// The tab: its panel declares the notes (a dependency); its header a theme nobody serves.
let panels = 0;
const tab = createHost<Principal>({ onError: (error) => console.log(`${at()}: reported ${error.kind}`) });
await tab.load(
[
{
name: "@example/panel",
facet: defineFacet<Principal>((host) => {
host.serve(Panel, (_, self) => {
panels++;
const notes = self.get(Notes, {});
return { count: self.derive([notes.list], ([list]) => list.length) };
});
host.serve(Header, (_, self) => ({ title: self.get(Theme, {}).name }));
}),
},
],
context,
);
/** Prints every state of `state` as it changes. */
async function follow(label: string, state: NativeWatchable<RefState>): Promise<void> {
const watch = await state.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log(`${at()}: ${label} ${describe(call.value)}`);
});
}
function describe(state: RefState): string {
if (state.kind !== "failed") return state.kind;
return `failed { ${state.cause.kind} }: ${state.cause.error.message}`;
}
// 1. The first server has no notes plugin. Its answer is `failed { waiting }`: not the panel's fault.
const first = await startServer([]);
tab.connect(first.tabLink, { principal: { kind: "server" } }, context);
const panel = tab.hold(Panel, { id: 1 }, context);
await follow("panel:", panel.state);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`${at()}: panel now ${describe(panel.state.value)}`);
// 2. A local type nobody serves here is the same answer: the request waits unbound, indexed under the type's name.
const header = tab.hold(Header, { id: 1 }, context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`${at()}: header ${describe(header.state.value)}`);
// 3. A getter waits through `waiting` up to its readyTimeout, then rejects with the cause (a hold just shows it).
try {
await tab.get(Panel, { id: 1 }, context, { readyTimeout: 200 });
} catch (error) {
if (isChordError(error)) console.log(`${at()}: get gave up after 200 ms: ${error.message}`);
}
// 4. Time passes: no timer runs for the parked panel, its factory is not run again.
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log(`${at()}: still ${panel.state.value.kind}, factory runs: ${panels}`);
// 5. The server restarts with the notes plugin (the link drop shows as loading); the tab reconnects: its request is
// held again and binds, and the parked panel goes on.
first.close();
await new Promise((resolve) => setTimeout(resolve, 10));
const second = await startServer([{ name: "@example/notes", facet: notesPlugin }]);
tab.connect(second.tabLink, { principal: { kind: "server" } }, context);
const live = await tab.get(Panel, { id: 1 }, context);
console.log(`${at()}: panel live, count ${live.count.value}, factory runs: ${panels}`);
// The panel's notes are the owner's object on the server (a remote object per principal): the tab adds through its own.
const notes = await tab.get(Notes, {}, context);
await notes.add({ text: "first" }, context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`${at()}: panel count ${live.count.value}`);
header.release();
panel.release();
live.release();
notes.release();
await tab.close(context);
await second.server.close(context);
await first.server.close(context);

View file

@ -0,0 +1,104 @@
// Startup (dependency model §6, slice 7a): `load` publishes every facet's declarations in one batch first, then starts
// the host-lifetime objects; requests made here and holds arriving on links wait only for that publication, never for
// the start barrier. Two hosts whose objects need each other's types (an acyclic graph) used to deadlock: the server's
// eager `Bootstrap` needs the worker's `Agent`, the `Agent` needs the server's `Catalog`, and the worker's hold for
// `Catalog` waited for the server's whole `load`, which waited for `Bootstrap`. Now it starts at once, no timeout.
// `load` resolves when each host-lifetime object had its first outcome: live, failed by its own cause, or parked with
// no blocker here still without one (a wait on another host ends the first pass).
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/32-startup.ts
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
import { hostKind, int, local, remote, str } from "../../src/contracts/index.ts";
import { createHost, defineFacet } from "../../src/host/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
const started = Date.now();
const at = (): string => `${Date.now() - started} ms`;
type Principal = { readonly kind: "server" } | { readonly kind: "worker"; readonly session: string };
const Server = hostKind("server");
const Session = hostKind("session", { id: str() });
/** On the server, a host-lifetime object (local, empty params): it starts at `load`. */
const Bootstrap = local("example.bootstrap", { version: 1, params: {}, members: { agents: int() } });
/** On the session's worker. */
const Agent = remote("example.agent", {
version: 1,
params: { session: str() },
hostAddress: ({ session }) => Session({ id: session }),
variants: { v: { state: { models: int() } } },
});
/** On the server. */
const Catalog = remote("example.catalog", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { v: { state: { models: int() } } },
});
/** The worker of a session, started by the server's route; it connects first, then loads (W6). */
function startWorker(id: string) {
const [up, down] = memoryLinkPair();
const worker = createHost<Principal>({
address: Session({ id }),
principal: { kind: "worker", session: id },
onError: (error) => console.log(`${at()}: worker reported ${error.kind}`),
});
worker.connect(down, { principal: { kind: "server" }, trusted: true }, context);
const agents = defineFacet<Principal>((host) =>
host.serve(Agent, (_params, _principal, self) => {
console.log(`${at()}: worker: Agent declares the server's Catalog`);
return { variant: "v", members: { models: self.get(Catalog, {}).models } };
}),
);
void worker
.load([{ name: "@example/agents", facet: agents }], context)
.then(() => console.log(`${at()}: worker loaded`));
return { worker, link: up };
}
const workers: ReturnType<typeof startWorker>[] = [];
const server = createHost<Principal>({
address: Server(),
principal: { kind: "server" },
// No readyTimeout is needed to break anything: a long one shows nothing waits for it.
readyTimeout: 600_000,
onError: (error) => console.log(`${at()}: server reported ${error.kind}`),
});
server.route(
Session,
{
authorize: () => true,
resolve: async (address) => {
const started = startWorker(address.params.id);
workers.push(started);
return { link: started.link, principal: { kind: "worker", session: address.params.id }, trusted: true };
},
},
context,
);
const core = defineFacet<Principal>((host) => {
host.serve(Catalog, () => ({ variant: "v", members: { models: constant(3) } }));
host.serve(Bootstrap, (_, self) => {
console.log(`${at()}: server: Bootstrap declares the worker's Agent`);
return { agents: self.get(Agent, { session: "S1" }).models };
});
});
// 1. The server's load: publication (Catalog served), then Bootstrap starts and parks on the worker's Agent. A wait on
// another host is a first outcome, so `load` resolves without waiting for the worker.
await server.load([{ name: "@example/core", facet: core }], context);
console.log(`${at()}: server loaded; Bootstrap is ${server.hold(Bootstrap, {}, context).state.value.kind}`);
// 2. The worker's hold for Catalog bound on the published server; Agent went live, then Bootstrap.
const bootstrap = await server.get(Bootstrap, {}, context);
console.log(`${at()}: Bootstrap live, it sees ${bootstrap.agents.value} models through the worker`);
bootstrap.release();
await server.close(context);
for (const { worker } of workers) await worker.close(context);

View file

@ -0,0 +1,185 @@
// Reload in the kernel (slice 7b): `host.reload(facets, ctx)` switches a running host to a new plugin set. Staging
// runs every setup and every affected object's new factory against a recording `self` (nothing activates; a throw
// rejects the reload and nothing changes), the commit swaps the served types, registries and contributions at once,
// then every object of a changed or removed plugin takes its fate. Here: a pure notes store is swapped in place while
// a call runs on its old code, a search index with a native restarts after its running call drained, and a removed
// plugin's object ends while its holder waits for it to come back.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/33-reload.ts
import { BACKGROUND_CONTEXT, type Context } from "../../src/context/index.ts";
import { arr, int, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type RefState } from "../../src/host/index.ts";
import { constant } from "../../src/state/index.ts";
const context = BACKGROUND_CONTEXT;
/** The notes store: a list, its count, and `add` (which can be told to wait, to show a call across a swap). */
const Notes = local("example.notes", {
version: 1,
params: {},
members: { list: arr(str()), banner: str(), add: method(obj({ text: str() }), int()) },
});
/** A search index over the notes, holding a native (an index file, say). */
const Search = local("example.search", { version: 1, params: {}, members: { engine: str(), reindex: method() } });
/** A clock plugin, removed by the third reload. */
const Clock = local("example.clock", { version: 1, params: {}, members: { zone: str() } });
const List = defineState("example.list", {
schema: obj({ list: arr(str()) }),
version: 1,
initial: () => ({ list: [] }),
});
let slowAdd: PromiseWithResolvers<void> | undefined;
/** The notes plugin, its banner and the text `add` prefixes differing per version: a pure object (no `self.open`). */
function notesPlugin(banner: string) {
return defineFacet((host) =>
host.serve(Notes, (_, self) => {
const list = self.state(List);
return {
list: self.derive([list], ([value]) => value.list),
banner: constant(banner),
add: async ({ text }: { text: string }, ctx: Context) => {
await slowAdd?.promise;
await list.change((draft) => void draft.list.push(`${text} (${banner})`), ctx);
return list.value.list.length;
},
};
}),
);
}
let slowReindex: PromiseWithResolvers<void> | undefined;
/** The search plugin: its engine is a native, so every reload of it restarts the object. */
function searchPlugin(engine: string) {
return defineFacet((host) =>
host.serve(Search, (_, self) => {
const index = self.open(
async () => {
console.log(` search: ${engine} opened`);
return engine;
},
(value, reason) => console.log(` search: ${value} closed (${reason.kind})`),
);
return {
engine: constant(engine),
reindex: async () => {
console.log(` search: reindex on ${index.value} started`);
await slowReindex?.promise;
console.log(` search: reindex on ${index.value} done`);
return null;
},
};
}),
);
}
const clockPlugin = defineFacet((host) => host.serve(Clock, () => ({ zone: constant("UTC") })));
/** Shows a reference's state as text. */
function shown(state: RefState): string {
return state.kind === "failed" ? `failed { ${state.cause.kind} }: ${state.cause.error.message}` : state.kind;
}
// 1. A host with three plugins, loaded once. A reload passes the complete set; a facet passed as the very same object
// is an unchanged plugin (untouched), any other value for a loaded name a changed one.
const host = createHost({ onError: (error) => console.log(` reported: ${error.kind}`) });
const search = searchPlugin("lucene");
const notesV2 = notesPlugin("v2");
const searchV2 = searchPlugin("tantivy");
await host.load(
[
{ name: "@example/notes", facet: notesPlugin("v1") },
{ name: "@example/search", facet: search },
{ name: "@example/clock", facet: clockPlugin },
],
context,
);
const notes = await host.get(Notes, {}, context);
await notes.add({ text: "first" }, context);
console.log("1. loaded:", notes.list.value, "banner", notes.banner.value);
// 2. A pure swap while a call runs: the running `add` finishes on the old code, the next call runs on the new one.
slowAdd = Promise.withResolvers();
const running = notes.add({ text: "second" }, context);
const swap = await host.reload(
[
{ name: "@example/notes", facet: notesV2 },
{ name: "@example/search", facet: search },
{ name: "@example/clock", facet: clockPlugin },
],
context,
);
console.log("2. reload:", JSON.stringify(swap));
slowAdd.resolve();
slowAdd = undefined;
console.log(
" the old call returned",
await running,
"; the new code adds",
await notes.add({ text: "third" }, context),
);
console.log(" list:", notes.list.value, "banner", notes.banner.value, "(same documents, new code)");
// 3. A restart: the search object holds a native. Its running call drains first, then the native closes (reload).
const index = await host.get(Search, {}, context);
slowReindex = Promise.withResolvers();
const reindexing = index.reindex(context);
await new Promise((resolve) => setTimeout(resolve, 0));
const restart = await host.reload(
[
{ name: "@example/notes", facet: notesV2 },
{ name: "@example/search", facet: searchV2 },
{ name: "@example/clock", facet: clockPlugin },
],
context,
);
console.log("3. reload:", JSON.stringify(restart.objects), "- the reference shows", shown(index.state.value));
slowReindex.resolve();
await reindexing;
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(" search now", shown(index.state.value), "on", index.engine.value);
// 4. A removal: the clock plugin is gone. Its object ends; its holder's request is not ended, it waits `not_served`.
const clock = host.hold(Clock, {}, context);
await new Promise((resolve) => setTimeout(resolve, 0));
const removal = await host.reload(
[
{ name: "@example/notes", facet: notesV2 },
{ name: "@example/search", facet: searchV2 },
],
context,
);
console.log("4. reload:", JSON.stringify(removal.plugins), "- clock holder:", shown(clock.state.value));
console.log(" its last value stays readable:", clock.zone.value);
// 5. A later reload serves the clock again: the same holder binds again by itself.
await host.reload(
[
{ name: "@example/notes", facet: notesV2 },
{ name: "@example/search", facet: searchV2 },
{ name: "@example/clock", facet: clockPlugin },
],
context,
);
await new Promise((resolve) => setTimeout(resolve, 0));
console.log("5. served again: clock holder", shown(clock.state.value), clock.zone.value);
// 6. A staging error rejects the reload and changes nothing.
const broken = defineFacet((h) =>
h.serve(Notes, () => {
throw new Error("a typo in the new factory");
}),
);
try {
await host.reload([{ name: "@example/notes", facet: broken }], context);
} catch (error) {
console.log("6. rejected:", (error as Error).message, "- banner still", notes.banner.value);
}
await host.close(context);

View file

@ -0,0 +1,175 @@
// Versions in use (slice 7b §7, §8): a provider keeps serving older consumers through `accepts`, written once with the
// provider, never in the shared contract (Q10). Here the server's notes go from v1 to v2 while an old tab, still on v1,
// stays connected: it reads the new state mapped back (as diffs, never the whole list per commit), and its calls are
// mapped forwards and their results back. A job goes from v1 to v2 too: a run started on v1 finishes on its old code,
// and the new `finish` gets its args and result mapped forwards.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/34-versions.ts
import { BACKGROUND_CONTEXT, type Context } from "../../src/context/index.ts";
import { arr, evolve, hostKind, int, local, method, obj, remote, str, unknown } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { createHost, defineFacet, type FacetHost } from "../../src/host/index.ts";
import { defineJob } from "../../src/jobs/index.ts";
import { memoryLinkPair } from "../../src/link/index.ts";
const context = BACKGROUND_CONTEXT;
type Principal = { readonly kind: "server" } | { readonly kind: "owner" };
const Server = hostKind("server");
// The shared contract, as every plugin bundles it: v1, then v2 where a note gained a tag.
const NotesV1 = remote("example.notes", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { editor: { state: { notes: arr(str()) }, methods: { add: method(obj({ text: str() }), int()) } } },
});
const NotesV2 = evolve(NotesV1, (e) =>
e
.state("editor", "notes", arr(obj({ text: str(), tag: str() })))
.method("add", obj({ text: str(), tag: str() }), obj({ count: int() })),
);
const List = defineState("example.list", {
schema: obj({ notes: arr(obj({ text: str(), tag: str() })) }),
version: 1,
initial: () => ({ notes: [] }),
});
/** The server's notes plugin at v2: what a v1 consumer sees is the provider's `accepts[1]`. */
function serveV2(host: FacetHost<Principal>): void {
host.serve(
NotesV2,
(_, _principal, self) => {
const list = self.state(List);
return {
variant: "editor",
members: {
notes: self.derive([list], ([value]) => value.notes),
add: async ({ text, tag }, ctx) => {
await list.change((draft) => void draft.notes.push({ text, tag }), ctx);
return { count: list.value.notes.length };
},
},
};
},
{
accepts: {
1: {
state: { editor: { notes: (current) => current.map(({ text }) => text) } },
methods: { add: { args: (old) => ({ ...old, tag: "untagged" }), result: (current) => current.count } },
},
},
},
);
}
/** The server's notes plugin at v1. */
function serveV1(host: FacetHost<Principal>): void {
host.serve(NotesV1, (_, _principal, self) => {
const list = self.state(List);
return {
variant: "editor",
members: {
notes: self.derive([list], ([value]) => value.notes.map(({ text }) => text)),
add: async ({ text }, ctx) => {
await list.change((draft) => void draft.notes.push({ text, tag: "" }), ctx);
return list.value.notes.length;
},
},
};
});
}
// 1. A server on v1 and a tab built against v1, connected.
const server = createHost<Principal>({ address: Server(), principal: { kind: "server" }, onError: () => {} });
await server.load([{ name: "@example/notes", facet: defineFacet<Principal>(serveV1) }], context);
const tab = createHost<Principal>({ onError: () => {} });
await tab.load([], context);
const [up, down] = memoryLinkPair();
server.accept(up, { principal: { kind: "owner" } }, context);
tab.connect(down, { principal: { kind: "server" } }, context);
const old = await tab.get(NotesV1, {}, context);
await old.add({ text: "milk" }, context);
const watch = await old.notes.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log(" tab frame:", JSON.stringify(call.ops));
});
console.log("1. v1 everywhere:", old.notes.value);
// 2. The server reloads to v2 with `accepts[1]`: the old tab's hold is kept; its version is an older one now, so it is
// bound again through the mapping (one snapshot), and from then on gets the mapped diffs.
await server.reload([{ name: "@example/notes", facet: defineFacet<Principal>(serveV2) }], context);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("2. server on v2; the old tab still reads", old.notes.value, "and its state:", old.state.value.kind);
// 3. The old tab calls v1's `add`: its args are mapped forwards, the v2 result mapped back to v1's count.
console.log("3. old add returned", await old.add({ text: "eggs" }, context));
await new Promise((resolve) => setTimeout(resolve, 10));
// A new tab (v2 code) on the same object sees the tags.
const fresh = await tab.get(NotesV2, {}, context);
console.log(" a v2 consumer reads", JSON.stringify(fresh.notes.value));
// 4. Jobs: `Export` v2 maps a v1 run's args and result forwards; a v1 run still running finishes on its old code.
const ExportV1 = defineJob("example.export", { version: 1, args: obj({ path: str() }), result: str() });
const ExportV2 = defineJob("example.export", {
version: 2,
args: obj({ path: str(), format: str() }),
result: obj({ file: str() }),
accepts: {
1: { args: (old: { path: string }) => ({ ...old, format: "md" }), result: (old: string) => ({ file: old }) },
},
});
const Exports = local("example.exports", {
version: 1,
params: {},
members: { start: method(obj({ path: str() }), str()), running: arr(unknown()) },
});
let release: (() => void) | undefined;
/** The exports plugin with `job` (v1 or v2): its `run` waits for `release` once, its `finish` prints the outcome. */
function exportsPlugin(code: string, job: typeof ExportV1 | typeof ExportV2) {
return defineFacet<Principal>((host) =>
host.serve(Exports, (_, self) => {
// One handle for either version of the job (the two definitions' types differ).
const handle = (
self.job as unknown as (
definition: unknown,
implementation: unknown,
) => {
start(args: unknown, ctx: Context): Promise<{ readonly id: string }>;
readonly running: never;
}
)(job, {
run: async (args: { path: string }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return job.version === 1 ? `${args.path}.md` : { file: `${args.path}.out` };
},
finish: (outcome: unknown) => console.log(` finish on ${code}:`, JSON.stringify(outcome)),
});
return {
start: async ({ path }: { path: string }, ctx: Context) =>
(await handle.start(job.version === 1 ? { path } : { path, format: "html" }, ctx)).id,
running: handle.running,
};
}),
);
}
const worker = createHost<Principal>({ onError: () => {} });
await worker.load([{ name: "@example/exports", facet: exportsPlugin("v1 code", ExportV1) }], context);
const exportsRef = await worker.get(Exports, {}, context);
await exportsRef.start({ path: "notes" }, context);
await new Promise((resolve) => setTimeout(resolve, 0));
await worker.reload([{ name: "@example/exports", facet: exportsPlugin("v2 code", ExportV2) }], context);
console.log("4. after the reload, running shows the v1 run mapped:", JSON.stringify(exportsRef.running.value));
release?.();
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(" running is empty again:", JSON.stringify(exportsRef.running.value));
await worker.close(context);
await tab.close(context);
await server.close(context);

View file

@ -0,0 +1,110 @@
// Plugins (slice 7c): plugin directories turned into facets, loaded on their hosts, watched, rebuilt and rolled out.
// A plugin is a directory with a `package.json` (its name) and one file per facet kind (`server.ts`, `worker.ts`);
// `createPlugins` discovers them, builds each facet into a script with esbuild (host packages external) and evaluates
// it on Node. Here (`plugins/`): a contracts-only plugin, `@example/notes` with a server and a worker facet showing one
// label, and `@example/clock`, which the example never edits. A server and a worker load them; the notes' label is
// edited on disk, the watcher marks the plugin, the app builds it and rolls it out (server, then worker), a holder sees
// one frame, and the clock plugin is untouched (not in the reports, its object the same).
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/35-plugins/main.ts
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { createHost, type Host } from "../../../src/host/index.ts";
import * as chord from "../../../src/index.ts";
import { memoryLinkPair } from "../../../src/link/index.ts";
import { createPlugins } from "../../../src/node/plugins/index.ts";
import { Clock, Index, Notes, Worker } from "./plugins/contracts/contract.ts";
const context = BACKGROUND_CONTEXT;
// The example edits files: it works on a copy of `plugins/`, removed at the end.
const root = await mkdtemp(join(tmpdir(), "chord-example-35-"));
await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true });
/** Waits until `ready()` holds (the watcher's events arrive on their own time). */
async function until(ready: () => boolean): Promise<void> {
while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10));
}
// 1. The component: the roots, and the facet kinds, each built for Node with Chord as a host package (kept external in
// the builds). The hosts give the modules themselves when they load (this process's Chord: a plugin's
// `import { defineFacet } from "@earendil-works/chord"` is the app's own instance).
const modules = { "@earendil-works/chord": chord };
const node = { platform: "node", hostPackages: Object.keys(modules) } as const;
const plugins = createPlugins(
{
roots: [root],
kinds: { server: node, worker: node },
onError: (error) => console.log("plugins:", error.kind),
},
context,
);
// 2. A worker and a server joined by a memory link; each loads the plugins' facets of its kind (no core facets here).
type P = { readonly kind: "server" } | { readonly kind: "worker" };
const [up, down] = memoryLinkPair();
const worker = createHost<P>({ address: Worker(), principal: { kind: "worker" }, onError: () => {} });
worker.connect(up, { principal: { kind: "server" } }, context);
const server = createHost<P>({ principal: { kind: "server" }, onError: () => {} });
server.route(
Worker,
{ authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) },
context,
);
const workerLoad = await plugins.load(worker, { kind: "worker", core: [], modules }, context);
const serverLoad = await plugins.load(server, { kind: "server", core: [], modules }, context);
console.log(
"1. discovered:",
plugins.discovery.plugins.map(({ name, facets }) => `${name} [${Object.keys(facets)}]`).join(", "),
);
console.log(" load failures: worker", workerLoad.failed.length, "| server", serverLoad.failed.length);
const notes = await server.get(Notes, {}, context);
const index = await server.get(Index, {}, context);
const clock = await server.get(Clock, {}, context);
await notes.add(context);
const evaluatedAt = clock.evaluatedAt.value;
console.log(" notes:", notes.label.value, "count", notes.count.value, "| index:", index.label.value);
const watch = await notes.label.watch(context);
watch.start(async (call) => {
if (call.kind === "value") console.log(" a frame on the server's reference:", JSON.stringify(call.value));
});
// 3. An edit on disk (the app's editor, a git pull): the watcher marks the plugin dirty; nothing is applied yet.
await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n');
await until(() => plugins.state.value.dirty.includes("@example/notes"));
console.log("2. marks after the edit:", JSON.stringify(plugins.state.value));
// 4. The app builds the dirty plugins and rolls the build out: the server first, then the worker. Each step reloads its
// host with the facets of its kind; the clock plugin was not built, so it keeps its facet and its object.
const steps = [plugins.step("server", server as Host<P>), plugins.step("worker", worker as Host<P>)];
const built = await plugins.build(plugins.state.value.dirty, context);
console.log(
"3. built:",
built.facets.map(({ plugin, facet, kind }) => `${plugin}/${facet}: ${kind}`),
);
const rolled = await plugins.rollout(steps, built, context);
for (const { name, hosts } of rolled.steps) {
for (const host of hosts) {
if ("report" in host) {
console.log(
` ${name}:`,
JSON.stringify(host.report.plugins),
"objects",
JSON.stringify(host.report.objects),
);
}
}
}
await until(() => notes.label.value === "v2");
console.log("4. notes:", notes.label.value, "count", notes.count.value, "| index:", index.label.value);
console.log(" clock untouched:", clock.evaluatedAt.value === evaluatedAt);
console.log(" marks after the rollout:", JSON.stringify(plugins.state.value));
await watch.stop();
await server.close(context);
await worker.close(context);
plugins.close();
await rm(root, { recursive: true, force: true });

View file

@ -0,0 +1,5 @@
{
"name": "@example/clock",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,9 @@
// The clock plugin's server facet: serves `Clock` with the time its script was evaluated. The example never edits
// this plugin, so a reload leaves it as it is: same script, same facet, same object.
import { constant, defineFacet } from "@earendil-works/chord";
import { Clock } from "../contracts/contract.ts";
const evaluatedAt = new Date().toISOString();
export default defineFacet((host) => host.serve(Clock, () => ({ evaluatedAt: constant(evaluatedAt) })));

View file

@ -0,0 +1,21 @@
// The contracts the plugins share: a contracts-only plugin (no facet file). A plugin that imports this file gets its
// own copy built into its script; the app's code declares the same contracts (same names, versions and shapes).
import { hostKind, int, local, method, remote, str } from "@earendil-works/chord";
/** The worker's host kind: `Notes` lives there. */
export const Worker = hostKind("example.worker");
/** Notes on the worker: a label, a count and `add`. */
export const Notes = remote("example.notes", {
version: 1,
params: {},
hostAddress: () => Worker(),
variants: { main: { state: { label: str(), count: int() }, methods: { add: method(undefined, int()) } } },
});
/** The notes' label as the server shows it. */
export const Index = local("example.index", { version: 1, params: {}, members: { label: str() } });
/** When the clock plugin's script was evaluated: it changes only when the plugin is reloaded. */
export const Clock = local("example.clock", { version: 1, params: {}, members: { evaluatedAt: str() } });

View file

@ -0,0 +1,5 @@
{
"name": "@example/contracts",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,3 @@
// The label both of the plugin's facets show; the example edits it.
export const label = "v1";

View file

@ -0,0 +1,5 @@
{
"name": "@example/notes",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,7 @@
// The notes plugin's server facet: serves `Index` with the same label.
import { constant, defineFacet } from "@earendil-works/chord";
import { Index } from "../contracts/contract.ts";
import { label } from "./label.ts";
export default defineFacet((host) => host.serve(Index, () => ({ label: constant(label) })));

View file

@ -0,0 +1,26 @@
// The notes plugin's worker facet: serves `Notes`, its count kept in a document.
import { constant, defineFacet, defineState, int, obj } from "@earendil-works/chord";
import { Notes } from "../contracts/contract.ts";
import { label } from "./label.ts";
const Count = defineState("count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
export default defineFacet((host) =>
host.serve(Notes, (_params, _principal, self) => {
const count = self.state(Count);
return {
variant: "main",
members: {
label: constant(label),
count: self.derive([count], ([value]) => value.n),
add: async (ctx) => {
await count.change((draft) => {
draft.n += 1;
}, ctx);
return count.value.n;
},
},
};
}),
);

View file

@ -0,0 +1,157 @@
// Plugin failures (slice 7c): every failure is per plugin. At startup a plugin whose setup throws is left out and the
// others run (a consumer of what it would serve waits). At a reload a plugin whose new code fails keeps its old code and
// is listed in the report's `failed`: a syntax error (its build failed), a module that throws while it is evaluated
// (`mapStack` names the plugin's file and line), a factory that throws while the host stages it. The closure rule: a
// provider and its consumer edited together, the provider broken, the consumer kept on its old code too (its new code
// needs the provider's new version) and still working; the next good edit applies both. Last, a reload whose context
// aborts after its commit: the commit stands, and the component keeps what was committed.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/36-plugin-failures/main.ts
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT, withAbortSignal } from "../../../src/context/index.ts";
import { int, local, str } from "../../../src/contracts/index.ts";
import { isChordError } from "../../../src/data/index.ts";
import { createHost, type ReloadReport } from "../../../src/host/index.ts";
import * as chord from "../../../src/index.ts";
import { createPlugins, mapStack } from "../../../src/node/plugins/index.ts";
import { Weather } from "./plugins/weather/contract.ts";
const context = BACKGROUND_CONTEXT;
// The example edits files: it works on a copy of `plugins/`, removed at the end.
const root = await mkdtemp(join(tmpdir(), "chord-example-36-"));
await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true });
/** Waits until `ready()` holds (the watcher's events arrive on their own time). */
async function until(ready: () => boolean): Promise<void> {
while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10));
}
/** Replaces `from` with `to` in the plugin file `file` (the copy). */
async function edit(file: string, from: string, to: string): Promise<void> {
const text = await readFile(join(root, file), "utf8");
await writeFile(join(root, file), text.replace(from, to));
}
/** Waits until the watcher marks every plugin in `names` dirty, then builds the dirty plugins. */
async function buildDirty(names: readonly string[]) {
await until(() => names.every((name) => plugins.state.value.dirty.includes(name)));
return plugins.build(plugins.state.value.dirty, context);
}
/** A reload's report: what changed, its objects' fates, and each failed plugin with its error's mapped stack. */
function print(report: ReloadReport): void {
console.log(" plugins:", JSON.stringify(report.plugins));
const objects = report.objects.map((each) => `${each.object} ${each.kind}`);
if (objects.length > 0) console.log(" objects:", objects.join(", "));
for (const { plugin, error } of report.failed) {
// The first line, and the second where it names the plugin's code: a build error, or a mapped stack frame.
const [message, next = ""] = mapStack(error).split("\n");
const detail = !next.trimStart().startsWith("at ") || next.includes("/server.ts:") ? ` ${next.trim()}` : "";
console.log(` failed ${plugin}: ${message}${detail}`);
}
}
// The contracts the app reads (the plugins declare the same ones).
const Forecast = local("example.forecast", { version: 1, params: {}, members: { text: str() } });
const Greeting = local("example.greeting", { version: 1, params: {}, members: { text: str() } });
const Boot = local("example.boot", { version: 1, params: {}, members: { mode: str() } });
const Counter = local("example.counter", { version: 1, params: {}, members: { start: int() } });
const Summary = local("example.summary", { version: 1, params: {}, members: { text: str() } });
const Status = local("example.status", { version: 1, params: {}, members: { text: str() } });
// The host packages: this process's Chord.
const modules = { "@earendil-works/chord": chord };
const plugins = createPlugins(
{
roots: [root],
kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } },
onError: (error) => console.log("plugins:", error.kind),
},
context,
);
const host = createHost({ onError: () => {} });
// 1. Startup: the weather plugin's setup throws; it is left out and reported, the other plugins run. A reference to
// what it would serve waits (`failed`, cause `waiting`: not served here), and so does its consumer's object.
const loaded = await plugins.load(host, { kind: "server", core: [], modules }, context);
for (const { plugin, error } of loaded.failed) console.log(`1. left out ${plugin}: ${(error as Error).message}`);
const weather = host.hold(Weather, {}, context);
const forecast = host.hold(Forecast, {}, context);
await until(() => forecast.state.value.kind === "failed");
console.log(" weather:", JSON.stringify(weather.state.value));
console.log(" forecast:", JSON.stringify(forecast.state.value));
const greeting = await host.get(Greeting, {}, context);
const boot = await host.get(Boot, {}, context);
const counter = await host.get(Counter, {}, context);
const summary = await host.get(Summary, {}, context);
const status = await host.get(Status, {}, context);
const values = () =>
[greeting.text.value, boot.mode.value, counter.start.value, summary.text.value]
.map((v) => JSON.stringify(v))
.join(" ");
console.log(" running:", values());
// 2. Four bad edits at once. The greeter's label gets a syntax error; the boot module throws while it is evaluated;
// the counter's factory throws; the prices plugin moves its contract to version 2 and breaks its facet in the same
// edit, while the summary plugin (its copy of the contract now at version 2) builds fine.
await edit("greeter/label.ts", '"hello"', '"hello" +');
await edit("boot/server.ts", "const Boot", 'throw new Error("boot: config missing");\n\nconst Boot');
await edit("counter/server.ts", "() => ({ start: constant(1) })", '() => {\n\tthrow new Error("counter: no seed");\n}');
await edit("prices/contract.ts", "version: 1", "version: 2");
await edit("prices/server.ts", "constant(250)", "constant(250");
const bad = await buildDirty(["@example/greeter", "@example/boot", "@example/counter", "@example/summary"]);
console.log("2. built:", bad.facets.map(({ plugin, kind }) => `${plugin}: ${kind}`).join(", "));
// Each failed plugin keeps its old code; the summary's new code would ask for `Price` v2, which only the prices
// plugin's new (failed) code serves: the closure rule keeps the summary on its old code too.
print(await plugins.reload(host, bad, context));
console.log(" running:", values());
console.log(" still dirty:", JSON.stringify(plugins.state.value.dirty));
// 3. The fixes: the next build and reload apply every plugin, the prices provider and its consumer together.
await edit("greeter/label.ts", '"hello" +', '"hi"');
await edit("boot/server.ts", 'throw new Error("boot: config missing");\n\n', "");
await edit("boot/server.ts", 'constant("v1")', 'constant("v2")');
await edit("counter/server.ts", '() => {\n\tthrow new Error("counter: no seed");\n}', "() => ({ start: constant(2) })");
await edit("prices/server.ts", "constant(250", "constant(300)");
const good = await buildDirty(["@example/greeter", "@example/boot", "@example/counter", "@example/prices"]);
console.log("3. fixed:");
print(await plugins.reload(host, good, context));
console.log(" running:", values());
// The summary's new code waited for the new `Price` object (parked at the report): it runs once that one is live.
await until(() => summary.text.value.startsWith("price v2"));
console.log(" then:", values());
// 4. A reload whose context aborts after its commit, while it waits for its report: here the app shuts down the moment
// it sees the status object leave `live` (its new code waits for a new `Cache`, which takes 200 ms to open). The
// reload rejects `aborted`, but its commit stands: `details.committed` says what it applied, and the component's
// loaded set is what was committed (a later reload does not roll the cache plugin back).
console.log("4. status:", status.text.value);
await edit("cache/server.ts", 'const generation = "v1"', 'const generation = "v2"');
const cache = await buildDirty(["@example/cache"]);
const abort = new AbortController();
const watchStatus = await status.state.watch(context);
watchStatus.start(async (call) => {
if (call.kind !== "value") return;
console.log(" the status reference:", call.value.kind);
if (call.value.kind !== "live") abort.abort(new Error("the app shuts down"));
});
try {
await plugins.reload(host, cache, withAbortSignal(abort.signal, context));
} catch (error) {
if (!isChordError(error, "aborted")) throw error;
console.log(" rejected:", error.code, error.message, JSON.stringify(error.details));
}
await until(() => status.text.value === "cache v2");
console.log(" status:", status.text.value);
await edit("greeter/label.ts", '"hi"', '"hey"');
print(await plugins.reload(host, await buildDirty(["@example/greeter"]), context));
console.log(" after another reload:", status.text.value, "| greeting:", greeting.text.value);
await watchStatus.stop();
await host.close(context);
plugins.close();
await rm(root, { recursive: true, force: true });

View file

@ -0,0 +1,5 @@
{
"name": "@example/boot",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,7 @@
// The boot plugin's server facet: serves `Boot`. The example makes this module throw while it is evaluated.
import { constant, defineFacet, local, str } from "@earendil-works/chord";
const Boot = local("example.boot", { version: 1, params: {}, members: { mode: str() } });
export default defineFacet((host) => host.serve(Boot, () => ({ mode: constant("v1") })));

View file

@ -0,0 +1,5 @@
{
"name": "@example/cache",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,27 @@
// The cache plugin's server facet: `Cache` opens a store per generation, which takes a while (a native); `Status` reads
// the cache of the plugin's generation. A reload to a new generation swaps `Status` in place; its new code needs a new
// `Cache` object, and its first dependency check waits until that one is open: the reload's report waits for it too.
import { constant, defineFacet, local, str } from "@earendil-works/chord";
const generation = "v1";
const Cache = local("example.cache", { version: 1, params: { generation: str() }, members: { generation: str() } });
const Status = local("example.status", { version: 1, params: {}, members: { text: str() } });
export default defineFacet((host) => {
host.serve(Cache, (params, self) => {
self.open(
async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
return {};
},
() => {},
);
return { generation: constant(params.generation) };
});
host.serve(Status, (_params, self) => {
const cache = self.get(Cache, { generation });
return { text: self.derive([cache.generation], ([value]) => `cache ${value}`) };
});
});

View file

@ -0,0 +1,5 @@
{
"name": "@example/counter",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,7 @@
// The counter plugin's server facet: serves `Counter`. The example makes its factory throw.
import { constant, defineFacet, int, local } from "@earendil-works/chord";
const Counter = local("example.counter", { version: 1, params: {}, members: { start: int() } });
export default defineFacet((host) => host.serve(Counter, () => ({ start: constant(1) })));

View file

@ -0,0 +1,5 @@
{
"name": "@example/forecast",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,13 @@
// The forecast plugin's server facet: a consumer of the weather plugin's `Weather`.
import { defineFacet, local, str } from "@earendil-works/chord";
import { Weather } from "../weather/contract.ts";
const Forecast = local("example.forecast", { version: 1, params: {}, members: { text: str() } });
export default defineFacet((host) =>
host.serve(Forecast, (_params, self) => {
const weather = self.get(Weather, {});
return { text: self.derive([weather.celsius], ([celsius]) => `${celsius} degrees`) };
}),
);

View file

@ -0,0 +1,3 @@
// The greeting's text; the example breaks it with a syntax error, then fixes it.
export const greeting = "hello";

View file

@ -0,0 +1,5 @@
{
"name": "@example/greeter",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,8 @@
// The greeter plugin's server facet: serves `Greeting` with the text of `label.ts`.
import { constant, defineFacet, local, str } from "@earendil-works/chord";
import { greeting } from "./label.ts";
const Greeting = local("example.greeting", { version: 1, params: {}, members: { text: str() } });
export default defineFacet((host) => host.serve(Greeting, () => ({ text: constant(greeting) })));

View file

@ -0,0 +1,6 @@
// The prices plugin's contract; the summary plugin imports (copies) it. The example moves it to version 2.
import { int, local } from "@earendil-works/chord";
/** A price in cents. */
export const Price = local("example.price", { version: 1, params: {}, members: { cents: int() } });

View file

@ -0,0 +1,5 @@
{
"name": "@example/prices",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,6 @@
// The prices plugin's server facet: serves `Price`.
import { constant, defineFacet } from "@earendil-works/chord";
import { Price } from "./contract.ts";
export default defineFacet((host) => host.serve(Price, () => ({ cents: constant(250) })));

View file

@ -0,0 +1,5 @@
{
"name": "@example/summary",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,14 @@
// The summary plugin's server facet: a consumer of the prices plugin's `Price`, at the version its copy of the
// contract declares.
import { defineFacet, local, str } from "@earendil-works/chord";
import { Price } from "../prices/contract.ts";
const Summary = local("example.summary", { version: 1, params: {}, members: { text: str() } });
export default defineFacet((host) =>
host.serve(Summary, (_params, self) => {
const price = self.get(Price, {});
return { text: self.derive([price.cents], ([cents]) => `price v${Price.version}: ${cents} cents`) };
}),
);

View file

@ -0,0 +1,6 @@
// The weather plugin's contract; the forecast plugin imports (copies) it.
import { int, local } from "@earendil-works/chord";
/** Today's temperature. */
export const Weather = local("example.weather", { version: 1, params: {}, members: { celsius: int() } });

View file

@ -0,0 +1,5 @@
{
"name": "@example/weather",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,12 @@
// The weather plugin's server facet: its setup throws (no API key configured), so the host leaves the plugin out at
// startup and runs the others.
import { constant, defineFacet } from "@earendil-works/chord";
import { Weather } from "./contract.ts";
const apiKey: string | undefined = undefined;
export default defineFacet((host) => {
if (apiKey === undefined) throw new Error("weather: no API key configured");
host.serve(Weather, () => ({ celsius: constant(21) }));
});

View file

@ -0,0 +1,10 @@
// The app's own code (base code): given to plugins as the host package `@example/app` (an ES module: a default export
// and named ones). A plugin imports it by that name, never by its path.
/** The app's name and version. */
export default { name: "example app", version: 3 };
/** Cents as a price. */
export function currency(cents: number): string {
return `EUR ${(cents / 100).toFixed(2)}`;
}

View file

@ -0,0 +1,7 @@
// A CommonJS module of the app: given to plugins as the host package `@example/legacy`.
module.exports = {
pad(text, width) {
return String(text).padStart(width, ".");
},
};

View file

@ -0,0 +1,6 @@
// A small npm package (local, so the example needs no network): a plugin lists it in its `dependencies`, and the build
// inlines it into the plugin's script.
export function greet(name) {
return `hello, ${name}`;
}

View file

@ -0,0 +1,7 @@
{
"name": "example-greeting",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "index.js"
}

View file

@ -0,0 +1,4 @@
// A package a plugin keeps external (`chord.external`: a native addon, a package that reads its own files): the build
// leaves its import as it is, and the facet's `require` loads it from the plugin's `node_modules` at run time.
exports.where = () => __filename;

View file

@ -0,0 +1,7 @@
{
"name": "example-native",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "index.js"
}

View file

@ -0,0 +1,11 @@
// A stand-in for `npm install` (the example needs no network): run in a plugin's directory, it copies every
// `file:` dependency of the plugin's package.json into its node_modules, as npm would install it.
import { cpSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
const { dependencies = {} } = JSON.parse(readFileSync("package.json", "utf8"));
for (const [name, spec] of Object.entries(dependencies)) {
if (!spec.startsWith("file:")) throw new Error(`fake npm: ${name}@${spec} is not a local package`);
cpSync(resolve(spec.slice("file:".length)), join("node_modules", name), { recursive: true });
}

View file

@ -0,0 +1,79 @@
// What a facet may import (slice 7c §2, slice 12 §2). The build inlines a plugin's own files, files of other plugins of
// its root by relative path (a copied contract) and its npm `dependencies`; it leaves host packages (the app's
// instances: an ES module's default and named exports, a CommonJS module), `chord.external` packages and Node builtins
// external, answered when the script runs; a plugin's own files are read through `pluginFile`. Here (`plugins/`): the report plugin uses each of these; its
// dependencies are installed first (`installDependencies`; `fake-npm.mjs` stands in for npm: no network). The ruler
// plugin copies a file that imports a package, the refused plugin imports what a script may not, and the paths plugin
// uses `__dirname` and `__filename`: they are left out with their build errors.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/37-plugin-imports/main.ts
import { cp, mkdtemp, readdir, rm } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { createHost } from "../../../src/host/index.ts";
import * as chord from "../../../src/index.ts";
import { createPlugins } from "../../../src/node/plugins/index.ts";
import * as app from "./app/index.ts";
import { Report } from "./plugins/shapes/contract.ts";
const context = BACKGROUND_CONTEXT;
// The example installs packages into a plugin: it works on a copy of `plugins/`, `app/` and `deps/` (the plugins'
// `file:` dependencies point there), removed at the end.
const copy = await mkdtemp(join(tmpdir(), "chord-example-37-"));
for (const dir of ["plugins", "app", "deps"]) {
await cp(fileURLToPath(new URL(`./${dir}/`, import.meta.url)), join(copy, dir), { recursive: true });
}
const root = join(copy, "plugins");
// 1. The component. Its host packages: Chord, the app's ES module and a CommonJS module of the app (their names kept
// external in the server kind's builds, the modules given at load). Its npm command (here a stand-in): what
// `installDependencies` runs in a plugin's directory.
const modules = {
"@earendil-works/chord": chord,
"@example/app": app,
"@example/legacy": createRequire(import.meta.url)("./app/legacy.cjs"),
};
const plugins = createPlugins(
{
roots: [root],
kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } },
install: { npmCommand: [process.execPath, fileURLToPath(new URL("./fake-npm.mjs", import.meta.url))] },
onError: (error) => console.log("plugins:", error.kind),
},
context,
);
// 2. Startup: every plugin whose build fails is left out with its errors (one per line: file, line, column, why). The
// report plugin's dependency is not installed yet.
const host = createHost({ onError: () => {} });
const loaded = await plugins.load(host, { kind: "server", core: [], modules }, context);
console.log("1. left out at startup:");
for (const { plugin, error } of loaded.failed) {
const [first, ...errors] = (error as Error).message.split("\n");
console.log(` ${plugin}: ${first}`);
for (const line of errors) console.log(` ${line}`);
}
// 3. The report plugin's dependencies, installed into its own node_modules; then it builds, and a reload adds it.
await plugins.installDependencies("@example/report", context);
console.log("2. installed:", (await readdir(join(root, "report", "node_modules"))).join(", "));
const built = await plugins.build(["@example/report"], context);
for (const facet of built.facets) {
if (facet.kind !== "script") continue;
const files = facet.inputs.map(({ file }) => file.slice(root.length + 1));
console.log(` built ${facet.plugin}/${facet.facet}: read ${files.join(", ")}; copied ${facet.copied.length} file`);
}
const report = await plugins.reload(host, built, context);
// The reload passes the complete set: the refused plugins fail again and stay out.
const stillRefused = report.failed.map(({ plugin }) => plugin).join(", ");
console.log(" reload:", JSON.stringify(report.plugins), "| still refused:", stillRefused);
const text = (await host.get(Report, {}, context)).text.value;
console.log("3. the report:", text);
await host.close(context);
plugins.close();
await rm(copy, { recursive: true, force: true });

View file

@ -0,0 +1,5 @@
{
"name": "@example/paths",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,10 @@
// The paths plugin's server facet: a script has no `__dirname` or `__filename` (it is evaluated with no file of its
// own); a file of the plugin is read through `pluginFile`. Each use is a build error.
import { join } from "node:path";
import { defineFacet } from "@earendil-works/chord";
export default defineFacet(() => {
console.log(join(__dirname, "data.json"));
console.log(__filename);
});

View file

@ -0,0 +1,3 @@
// A file of the refused plugin, imported dynamically by its facet (refused).
export const other = 1;

View file

@ -0,0 +1,5 @@
{
"name": "@example/refused",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,11 @@
// The refused plugin's server facet: imports a facet script may not have, each a build error with its file and line.
// A script is evaluated with no module identity: no `import.meta` (a file of the plugin is read through `pluginFile`),
// no literal dynamic `import()`; and base code is reached through a host package, not by its path.
import { defineFacet } from "@earendil-works/chord";
import app from "../../app/index.ts";
export default defineFacet(() => {
console.log(import.meta.url, app.name);
void import("./other.ts");
});

View file

@ -0,0 +1 @@
{ "vat": 20 }

View file

@ -0,0 +1,21 @@
// Types for the report plugin's imports that its directory does not hold: the app's host packages (an app ships their
// types with its plugin template) and its dependencies before `npm install` (their own types once installed).
declare module "@example/app" {
const app: { readonly name: string; readonly version: number };
export default app;
export function currency(cents: number): string;
}
declare module "@example/legacy" {
const legacy: { pad(text: string, width: number): string };
export default legacy;
}
declare module "example-greeting" {
export function greet(name: string): string;
}
declare module "example-native" {
export function where(): string;
}

View file

@ -0,0 +1,12 @@
{
"name": "@example/report",
"private": true,
"type": "module",
"dependencies": {
"example-greeting": "file:../../deps/example-greeting",
"example-native": "file:../../deps/example-native"
},
"chord": {
"external": ["example-native"]
}
}

View file

@ -0,0 +1,32 @@
// The report plugin's server facet: one of each import a facet may have.
import { readFileSync } from "node:fs";
import { constant, defineFacet } from "@earendil-works/chord";
import { pluginFile } from "@earendil-works/chord/plugins";
// Host packages: an ES module (default and named imports) and a CommonJS one, the app's own instances.
import app, { currency } from "@example/app";
import legacy from "@example/legacy";
// An npm dependency: inlined into this script by the build (installed first).
import { greet } from "example-greeting";
// A `chord.external` package: left external, loaded from this plugin's node_modules when the script runs.
import { where } from "example-native";
// A contract of another plugin of this root, by relative path: a copy built into this script.
import { Report } from "../shapes/contract.ts";
// A file of this plugin, read at run time by its absolute path.
const rates = JSON.parse(readFileSync(pluginFile("data/rates.json"), "utf8")) as { readonly vat: number };
export default defineFacet((host) =>
host.serve(Report, () => ({
text: constant(
[
`${app.name} v${app.version}`,
currency(1250),
legacy.pad("vat", 6),
`${rates.vat}%`,
greet("plugins"),
where().split("/").slice(-3).join("/"),
].join(" | "),
),
})),
);

View file

@ -0,0 +1,5 @@
{
"name": "@example/ruler",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,9 @@
// The ruler plugin's server facet: imports a file of the shapes plugin that imports a package. Refused at build: a
// copied file may import only host packages and files of its own plugin.
import { defineFacet } from "@earendil-works/chord";
import { Millimetres } from "../shapes/units.ts";
export default defineFacet(() => {
console.log(Millimetres);
});

View file

@ -0,0 +1,7 @@
// The shapes plugin's contract (a contracts-only plugin): other plugins import this file and get their own copy of it.
// A copied file may import host packages and files of its own plugin only.
import { local, str } from "@earendil-works/chord";
/** A report: its lines. */
export const Report = local("example.report", { version: 1, params: {}, members: { text: str() } });

View file

@ -0,0 +1,5 @@
{
"name": "@example/shapes",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,7 @@
// A file of the shapes plugin that imports a package (not a host package): fine for the shapes plugin itself, but a
// plugin importing this file would copy it, and a copied file may not import packages (the ruler plugin shows it).
import { num } from "@earendil-works/pi-schema";
/** A length in millimetres. */
export const Millimetres = num({ minimum: 0 });

View file

@ -0,0 +1,4 @@
// The app's own code (base code): the plugins component watches it and marks `base` when it changes; Chord restarts
// nothing for it, the app restarts its hosts.
export const appName = "example";

View file

@ -0,0 +1,5 @@
{
"name": "@example/extra",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,8 @@
// The extra plugin's server facet: not in plugins/ at first; the example copies the plugin in while the app runs. It
// declares its type itself (the same shape as `labelOf("extra")`).
import { constant, defineFacet, local, str } from "@earendil-works/chord";
const Extra = local("example.label.extra", { version: 1, params: {}, members: { text: str() } });
export default defineFacet((host) => host.serve(Extra, () => ({ text: constant("extra on the server") })));

View file

@ -0,0 +1,154 @@
// The watcher's marks and rollouts (slice 7c §5). The watcher marks what changed and never applies it: `dirty` for a
// plugin whose file changed (a contract file: every plugin that copies it), `install` for a changed `package.json`,
// `set` when a plugin directory or facet file is added or removed, `base` when the app's own code changes. The app
// builds the dirty plugins and rolls the build out over its hosts in order; a rollout clears the marks of the plugins
// it applied on every step. Here: a plugin added, one removed and one dropping its worker facet in one rollout; a
// rollout stopping at a step that rejects; a failed plugin staying dirty while the others clear; an edit during a build
// keeping its mark.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/38-watch-and-rollout/main.ts
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { createHost, type Host, type ReloadReport } from "../../../src/host/index.ts";
import * as chord from "../../../src/index.ts";
import { createPlugins, type RolloutResult, type RolloutStep } from "../../../src/node/plugins/index.ts";
const context = BACKGROUND_CONTEXT;
// The example edits files: it works on a copy of `plugins/` and `app/`, removed at the end.
const copy = await mkdtemp(join(tmpdir(), "chord-example-38-"));
for (const dir of ["plugins", "app"]) {
await cp(fileURLToPath(new URL(`./${dir}/`, import.meta.url)), join(copy, dir), { recursive: true });
}
const root = join(copy, "plugins");
/** Waits until `ready()` holds (the watcher's events arrive on their own time). */
async function until(ready: () => boolean): Promise<void> {
while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10));
}
/** Replaces `from` with `to` in `file` (under the copy). */
async function edit(file: string, from: string, to: string): Promise<void> {
const text = await readFile(join(copy, file), "utf8");
await writeFile(join(copy, file), text.replace(from, to));
}
/** Waits until every plugin of `names` is marked dirty. */
const dirty = (...names: string[]) => until(() => names.every((name) => plugins.state.value.dirty.includes(name)));
/** The marks, in one line. */
const marks = () => JSON.stringify(plugins.state.value);
/** What a step's report says, in one line. */
const line = (report: ReloadReport) =>
JSON.stringify({ ...report.plugins, failed: report.failed.map(({ plugin }) => plugin) });
/** A rollout, step by step. */
function print(result: RolloutResult): void {
for (const { name, hosts } of result.steps) {
for (const host of hosts) if ("report" in host) console.log(` ${name}:`, line(host.report));
}
if (result.stopped !== undefined) {
console.log(` stopped at ${result.stopped.step}: ${(result.stopped.error as Error).message}`);
}
}
// The host packages: this process's Chord, kept external in Node builds.
const modules = { "@earendil-works/chord": chord };
const node = { platform: "node", hostPackages: Object.keys(modules) } as const;
const plugins = createPlugins(
{
roots: [root],
kinds: { server: node, worker: node },
// The app's own code: a change marks `base`.
base: [join(copy, "app")],
// A stand-in for npm (no network; these plugins have no dependencies).
install: { npmCommand: [process.execPath, "-e", ""] },
onError: (error) => console.log("plugins:", error.kind),
},
context,
);
const server = createHost({ onError: () => {} });
const worker = createHost({ onError: () => {} });
await plugins.load(server, { kind: "server", core: [], modules }, context);
await plugins.load(worker, { kind: "worker", core: [], modules }, context);
const steps = [plugins.step("server", server as Host), plugins.step("worker", worker as Host)];
console.log("1. loaded:", plugins.discovery.plugins.map(({ name }) => name).join(", "), "| marks:", marks());
// 2. Each kind of change and its mark.
await edit("plugins/notes/label.ts", "notes v1", "notes v2");
await dirty("@example/notes");
console.log("2. a file of notes edited:", marks());
await edit("plugins/contracts/contract.ts", "one type per plugin", "one type per plugin (edited)");
await dirty("@example/shared", "@example/tags");
console.log(" the shared contract edited:", marks());
await edit("plugins/tags/package.json", '"private": true', '"private": true,\n\t"description": "tags"');
await until(() => plugins.state.value.install.includes("@example/tags"));
console.log(" tags' package.json edited:", marks());
await plugins.installDependencies("@example/tags", context);
console.log(" tags installed:", marks());
await cp(fileURLToPath(new URL("./incoming/extra/", import.meta.url)), join(root, "extra"), { recursive: true });
await rm(join(root, "old"), { recursive: true });
await rm(join(root, "tags", "worker.ts"));
await until(() => plugins.state.value.set);
// Deleting old's directory deleted its package.json too: an install mark, which goes with the next discovery.
console.log(" extra added, old removed, tags' worker facet deleted:", marks());
await edit("app/config.ts", '"example"', '"example 2"');
await until(() => plugins.state.value.base);
console.log(" the app's own code edited:", marks());
// 3. One build of the dirty plugins (discovery runs again first: `set`), rolled out server then worker. The server adds
// extra and removes old; the worker leaves tags out (it has no worker facet any more). The marks clear, but `base`:
// that one is the app's to act on (it restarts its hosts).
const built = await plugins.build(plugins.state.value.dirty, context);
console.log("3. built:", built.facets.map(({ plugin, facet }) => `${plugin}/${facet}`).join(", "));
console.log(" removed:", JSON.stringify(built.removed));
print(await plugins.rollout(steps, built, context));
console.log(" marks:", marks());
// 4. A rollout stops at a step that rejects (a host the app cannot reach): the steps after it do not run, and the marks
// stay (the plugin was not applied everywhere). The next rollout clears them.
const unreachable: RolloutStep = {
name: "edge",
kinds: ["server"],
reload: async () => {
throw new Error("the edge host is unreachable");
},
};
await edit("plugins/notes/label.ts", "notes v2", "notes v3");
await dirty("@example/notes");
const notes = await plugins.build(plugins.state.value.dirty, context);
console.log("4. rollout over server, edge, worker:");
print(await plugins.rollout([steps[0]!, unreachable, steps[1]!], notes, context));
console.log(" marks:", marks());
print(await plugins.rollout(steps, notes, context));
console.log(" marks after the next rollout:", marks());
// 5. A failed plugin stays dirty, the others clear: notes gets a syntax error, tags a good edit.
await edit("plugins/notes/label.ts", '"notes v3"', '"notes v4" +');
await edit("plugins/tags/server.ts", "tags on the server", "tags v2 on the server");
await dirty("@example/notes", "@example/tags");
console.log("5. notes broken, tags edited:");
print(await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context));
console.log(" marks:", marks());
// 6. An edit after the build started keeps its mark: the rollout applies the build, but the newer edit is not in it.
await edit("plugins/notes/label.ts", '"notes v4" +', '"notes v4"');
await dirty("@example/notes");
const early = await plugins.build(plugins.state.value.dirty, context);
await edit("plugins/notes/label.ts", '"notes v4"', '"notes v5"');
// The edit's event arrives on its own time (the mark is set already, so no change shows it).
await new Promise((resolve) => setTimeout(resolve, 200));
console.log("6. built before the second edit:");
print(await plugins.rollout(steps, early, context));
console.log(" marks:", marks());
print(await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context));
console.log(" marks after building again:", marks());
await server.close(context);
await worker.close(context);
plugins.close();
await rm(copy, { recursive: true, force: true });

View file

@ -0,0 +1,9 @@
// The contracts the plugins share (a contracts-only plugin): every plugin importing this file has a copy of it, so an
// edit here marks every one of them dirty.
import { local, str } from "@earendil-works/chord";
/** The label a plugin shows, one type per plugin. */
export function labelOf(plugin: string) {
return local(`example.label.${plugin}`, { version: 1, params: {}, members: { text: str() } });
}

View file

@ -0,0 +1,5 @@
{
"name": "@example/shared",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,3 @@
// The notes' label; the example edits it.
export const label = "notes v1";

View file

@ -0,0 +1,5 @@
{
"name": "@example/notes",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,9 @@
// The notes plugin's server facet: its label.
import { constant, defineFacet } from "@earendil-works/chord";
import { labelOf } from "../contracts/contract.ts";
import { label } from "./label.ts";
export default defineFacet((host) =>
host.serve(labelOf("notes"), () => ({ text: constant(`${label} on the server`) })),
);

View file

@ -0,0 +1,9 @@
// The notes plugin's worker facet: its label.
import { constant, defineFacet } from "@earendil-works/chord";
import { labelOf } from "../contracts/contract.ts";
import { label } from "./label.ts";
export default defineFacet((host) =>
host.serve(labelOf("notes"), () => ({ text: constant(`${label} on the worker`) })),
);

View file

@ -0,0 +1,5 @@
{
"name": "@example/old",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,6 @@
// The old plugin's server facet; the example deletes the plugin's directory.
import { constant, defineFacet } from "@earendil-works/chord";
import { labelOf } from "../contracts/contract.ts";
export default defineFacet((host) => host.serve(labelOf("old"), () => ({ text: constant("old on the server") })));

View file

@ -0,0 +1,5 @@
{
"name": "@example/tags",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,6 @@
// The tags plugin's server facet.
import { constant, defineFacet } from "@earendil-works/chord";
import { labelOf } from "../contracts/contract.ts";
export default defineFacet((host) => host.serve(labelOf("tags"), () => ({ text: constant("tags on the server") })));

View file

@ -0,0 +1,6 @@
// The tags plugin's worker facet.
import { constant, defineFacet } from "@earendil-works/chord";
import { labelOf } from "../contracts/contract.ts";
export default defineFacet((host) => host.serve(labelOf("tags"), () => ({ text: constant("tags on the worker") })));

View file

@ -0,0 +1,62 @@
// A browser build (slice 7c §2): a facet of a browser kind (`web: { platform: "browser", … }`) is built for the browser. Its
// stylesheets become one CSS file (`css`), an asset imported by URL (`./logo.svg?url`) a content-hashed file whose
// import evaluates to its URL under the app's `publicPath`. The build is plain data: script, stylesheet and assets,
// what the app serves to its pages (slice 11 ships it); a page evaluates the script with its host packages, as Node
// does here. A Node builtin is refused in a browser build. The example only reads `plugins/`: no copy.
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/39-browser-build/main.ts
import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts";
import { local, str } from "../../../src/contracts/index.ts";
import { createHost } from "../../../src/host/index.ts";
import * as chord from "../../../src/index.ts";
import { createPlugins, evaluateFacet } from "../../../src/node/plugins/index.ts";
const context = BACKGROUND_CONTEXT;
const root = fileURLToPath(new URL("./plugins/", import.meta.url));
// 1. The component: `web` is a browser kind; its assets are served under `/plugin-assets/`.
const hostPackages = { "@earendil-works/chord": chord };
const plugins = createPlugins(
{
roots: [root],
kinds: { web: { platform: "browser", hostPackages: Object.keys(hostPackages) } },
publicPath: "/plugin-assets/",
onError: (error) => console.log("plugins:", error.kind),
},
context,
);
// 2. The first build: every discovered plugin.
const built = await plugins.build([], context);
const gallery = built.facets.find(({ plugin }) => plugin === "@example/gallery")!;
const bad = built.facets.find(({ plugin }) => plugin === "@example/bad-web")!;
if (gallery.kind !== "script" || bad.kind !== "failed") throw new Error("unexpected build results");
console.log("1. @example/gallery/web: script of", gallery.source.length, "bytes, digest", gallery.digest.slice(0, 12));
console.log(" stylesheet:", gallery.css?.name, JSON.stringify(gallery.css?.text.split("\n")[0]));
for (const asset of gallery.assets) console.log(" asset:", asset.name, `(${asset.bytes.length} bytes)`);
console.log(" the asset's URL in the script:", /\/plugin-assets\/[^"]+/.exec(gallery.source)?.[0]);
console.log(" read:", gallery.inputs.map(({ file }) => file.slice(root.length)).join(", "));
console.log("2. @example/bad-web/web:", bad.errors.join("; "));
// 3. Plain data: a structured clone (what a message to a page or a worker carries) is the same build.
const shipped = structuredClone(built);
const copy = shipped.facets.find(({ plugin }) => plugin === "@example/gallery")!;
console.log(
"3. cloned:",
copy.kind === "script" && copy.digest === gallery.digest,
"| its plugins:",
Object.keys(shipped.plugins).join(", "),
);
// 4. Where it runs (a page; Node stands in here), the script is evaluated with the host packages and loaded.
const Gallery = local("example.gallery", { version: 1, params: {}, members: { logo: str() } });
if (copy.kind === "script") {
const facet = evaluateFacet(copy, { hostPackages, plugin: shipped.plugins["@example/gallery"]! });
const page = createHost({ onError: () => {} });
await page.load([{ name: "@example/gallery", facet }], context);
console.log("4. the gallery's logo on the page:", (await page.get(Gallery, {}, context)).logo.value);
await page.close(context);
}
plugins.close();

View file

@ -0,0 +1,5 @@
{
"name": "@example/bad-web",
"private": true,
"type": "module"
}

Some files were not shown because too many files have changed in this diff Show more