chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -1,90 +0,0 @@
import { FacetKernel } from "./facets/host.ts";
import { disposeLoadedFacets } from "./facets/loader.ts";
import { RemoteServiceBindingImpl } from "./services/consumer.ts";
import { MutableReplicatedStateImpl } from "./services/state.ts";
import type {
Facet,
FacetHost,
FacetLoader,
FacetOptions,
LoadedFacets,
MutableReplicatedState,
RemoteServiceBinding,
RemoteServiceBindingOptions,
RemoteServiceContract,
Service,
} from "./types.ts";
/** Create an active host for one complete set of facets. */
export async function createFacetHost(options: FacetOptions): Promise<FacetHost> {
const kernel = new FacetKernel(options);
await kernel.activate();
return Object.freeze({
services: kernel.provider,
reload: (facets: readonly Facet[]) => kernel.reload(facets),
dispose: () => kernel.dispose(),
});
}
export function createStaticFacetLoader(facets: readonly Facet[]): FacetLoader {
const loadedFacets = Object.freeze([...facets]);
return {
async load() {
return { facets: loadedFacets, async dispose() {} };
},
};
}
export function combineFacetLoaders(loaders: readonly FacetLoader[]): FacetLoader {
return {
async load() {
const loaded: LoadedFacets[] = [];
try {
for (const loader of loaders) loaded.push(await loader.load());
} catch (error) {
const cleanupErrors = await disposeLoadedFacets(loaded.reverse());
if (cleanupErrors.length > 0) {
throw new AggregateError([error, ...cleanupErrors], "Facet loading and cleanup failed");
}
throw error;
}
let disposed = false;
return {
facets: Object.freeze(loaded.flatMap(({ facets }) => facets)),
async dispose() {
if (disposed) return;
disposed = true;
const errors = await disposeLoadedFacets([...loaded].reverse());
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose loaded facets");
},
};
},
};
}
export function defineFacet(facet: Facet): Facet {
return facet;
}
export function defineService<T>(id: string, options: { readonly local: true }): Service<T>;
export function defineService<T>(
id: string,
...options: [RemoteServiceContract<T>] extends [never]
? readonly [options: never]
: readonly [options?: { readonly local?: false }]
): Service<T>;
export function defineService(id: string, options?: { readonly local?: boolean }): Service<unknown> {
if (id.length === 0) throw new TypeError("Service ID must not be empty");
// TODO: check if the reserved namespace should be part of Chord.
if (id.startsWith("$chord.")) throw new TypeError("Service IDs beginning with $chord. are reserved");
return Object.freeze({ id, local: options?.local ?? false });
}
export function createRemoteServiceBinding(options: RemoteServiceBindingOptions): RemoteServiceBinding {
return new RemoteServiceBindingImpl(options);
}
export function replicatedState<T extends object>(initial: T): MutableReplicatedState<T> {
return new MutableReplicatedStateImpl(initial);
}

View File

@@ -1,9 +0,0 @@
export type {
BundleFacetsOptions,
BundleFacetsResult,
FacetBundlePlatform,
} from "./node/bundle.ts";
export { bundleFacets } from "./node/bundle.ts";
export type { FacetBundleEntry, FacetBundleManifest } from "./node/manifest.ts";
export type { BundleFacetPackageOptions, BundleFacetPackageResult } from "./node/package.ts";
export { bundleFacetPackage } from "./node/package.ts";

View File

@@ -0,0 +1,402 @@
import type { TSchema } from "typebox";
import { type Draft, diffRevisions, type JsonValue, type Op, type Tracker, track } from "./delta/index.ts";
import { ChordError } from "./errors.ts";
import { enqueue, holdPublications } from "./publish.ts";
import { assertValid } from "./schema.ts";
import type { Ctx, Readable, StateEvent } from "./types.ts";
export interface SourceOptions {
readonly dev: boolean;
readonly report: (error: unknown) => void;
}
/** Checks that `ctx` may write an object's cells now; throws otherwise. */
export type WriteGuard = (ctx: Ctx) => void;
let sequence = 0;
/** Fans out values and delta events; listener errors are reported, not thrown to the writer. */
class Fanout<T> {
readonly #values = new Set<(value: T) => void>();
readonly #events = new Set<(event: StateEvent<T>) => void>();
readonly #report: (error: unknown) => void;
constructor(report: (error: unknown) => void) {
this.#report = report;
}
subscribe(listener: (value: T) => void, current: T): () => void {
this.#values.add(listener);
this.#call(listener, current);
return () => this.#values.delete(listener);
}
events(listener: (event: StateEvent<T>) => void, current: T): () => void {
this.#events.add(listener);
this.#call(listener, { type: "snap", value: current });
return () => this.#events.delete(listener);
}
/**
* Queues delivery to the listeners subscribed now. A listener that subscribes later already got this revision in
* its snapshot; one that unsubscribes before delivery is skipped.
*/
emit(event: StateEvent<T>): void {
const events = [...this.#events];
const values = [...this.#values];
enqueue(() => {
for (const listener of events) if (this.#events.has(listener)) this.#call(listener, event);
for (const listener of values) if (this.#values.has(listener)) this.#call(listener, event.value);
});
}
#call<V>(listener: (value: V) => void, value: V): void {
try {
listener(value);
} catch (error) {
this.#report(error);
}
}
}
/**
* Whether a state source has its value (entry 8): a remote reference's member before its first snapshot has none, and
* neither has anything derived from or forwarding it. Sources without this symbol always have their value.
*/
export const HYDRATION: unique symbol = Symbol("chord.hydration");
export interface Hydration {
readonly hydrated: boolean;
/** Resolves once hydrated; rejects if the source ends first. */
whenHydrated(): Promise<void>;
}
/** The hydration of a readable, or undefined for a source that always has its value. */
export function hydrationOf(value: unknown): Hydration | undefined {
if (value === null || typeof value !== "object") return undefined;
return (value as { readonly [HYDRATION]?: Hydration })[HYDRATION];
}
/** The hydrations of `sources` that are not hydrated yet. */
export const unhydrated = (sources: Iterable<unknown>): Hydration[] =>
[...sources].map(hydrationOf).filter((hydration): hydration is Hydration => hydration?.hydrated === false);
/** An object-owned state source (cell or derivation). Writes recompute dependents atomically. */
export abstract class Source<T> implements Readable<T> {
readonly key: string;
readonly seq = ++sequence;
readonly dependents = new Set<Derived<unknown>>();
/** Schemas of remote state members currently exposing this source (checked per write in dev mode). */
schemas: readonly TSchema[] = [];
protected readonly options: SourceOptions;
protected readonly fanout: Fanout<T>;
constructor(key: string, options: SourceOptions) {
this.key = key;
this.options = options;
this.fanout = new Fanout(options.report);
}
abstract get value(): T;
subscribe(listener: (value: T) => void): () => void {
return this.fanout.subscribe(listener, this.value);
}
events(listener: (event: StateEvent<T>) => void): () => void {
return this.fanout.events(listener, this.value);
}
/** Per-write validation, dev mode only. */
validate(value: unknown): void {
if (!this.options.dev) return;
for (const schema of this.ownSchemas()) assertValid("state", `state ${this.key}`, schema, value);
for (const schema of this.schemas) assertValid("state", `state ${this.key}`, schema, value);
}
protected ownSchemas(): readonly TSchema[] {
return [];
}
}
/** The tracker tracks containers, so a cell tracks `{ v: value }` and strips the box from published ops. */
interface Box<T> {
v: T;
}
function unbox(ops: readonly Op[], value: unknown): Op[] {
const result: Op[] = [];
for (const op of ops) {
if (op[0] === "r" || op[1].length === 0) return [["r", value as JsonValue]];
const path = op[1].slice(1);
switch (op[0]) {
case "p":
result.push(["p", path, op[2], op[3], op[4]]);
break;
case "m":
result.push(["m", path, op[2]]);
break;
default:
// s/d/a/t on the value itself replace it.
if (path.length === 0) return [["r", value as JsonValue]];
if (op[0] === "d") result.push(["d", path as [string | number, ...(string | number)[]]]);
else result.push([op[0], path as [string | number, ...(string | number)[]], op[2]] as Op);
}
}
return result;
}
/** A state cell holding any JSON value, validated against its schema at creation and (dev mode) on writes. */
export class Cell<T> extends Source<T> {
/** Replaced at a reload commit that changes the schema (state-version bump). */
schema: TSchema;
#tracker: Tracker<Box<T>>;
readonly #guard: WriteGuard;
/** False until `initialize` for a cell declared with `init`: reads and writes throw `not_ready`. */
#initialized: boolean;
/**
* `initial` is always validated against `schema`; ownership moves to the cell. Without `initial`, the cell has no
* value until `initialize` (a cell declared with `init`, filled before its object goes live).
*/
constructor(
key: string,
schema: TSchema,
initial: { readonly value: T } | undefined,
guard: WriteGuard,
options: SourceOptions,
) {
super(key, options);
if (initial !== undefined) assertValid("state", `initial value of state ${key}`, schema, initial.value);
this.schema = schema;
this.#tracker = track<Box<T>>({ v: (initial?.value ?? null) as T });
this.#guard = guard;
this.#initialized = initial !== undefined;
}
/** Sets the first value of a cell declared with `init` (validated; ownership moves to the cell). Publishes nothing. */
initialize(value: T): void {
if (this.#initialized) throw new Error(`state ${this.key} is already initialized`);
assertValid("state", `initial value of state ${this.key}`, this.schema, value);
this.#tracker = track<Box<T>>({ v: value });
this.#initialized = true;
}
protected override ownSchemas(): readonly TSchema[] {
return [this.schema];
}
get value(): T {
if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);
return this.#tracker.value.v;
}
/**
* Mutates a draft (only mutated paths are published), or returns a new value (`n => n + 1`). A returned value is
* always validated against the schema. The draft is unusable after `fn` returns.
*/
change(ctx: Ctx, fn: (draft: Draft<T>) => T): void;
change(ctx: Ctx, fn: (draft: Draft<T>) => void): void;
change(ctx: Ctx, fn: (draft: Draft<T>) => unknown): void {
this.#guard(ctx);
if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);
const change = this.#tracker.beginChange();
let prepared: ReturnType<typeof change.prepare>;
try {
const draft = change.state as Draft<Box<T>>;
const result: unknown = fn(draft.v as Draft<T>);
if (result instanceof Promise) throw new TypeError("cell.change callbacks must be synchronous");
if (result !== undefined) {
if (result !== draft.v) assertValid("state", `state ${this.key}`, this.schema, result);
draft.v = result as Draft<T>;
}
prepared = change.prepare();
} catch (error) {
change.abort();
throw error;
}
this.#publish(prepared, "ops");
}
/**
* Replaces the whole value. Ownership of `value` moves to the cell: never mutate it afterwards.
*
* Subscribers get a structural diff (`diffRevisions`) when `diff` is true, or by default when the new value shares
* containers with the old one (a new immutable revision of the same state, e.g. mirrored from another store);
* otherwise a single `r` op. The resulting value is the same either way; only the ops differ.
*/
replace(ctx: Ctx, value: T, options: { readonly diff?: boolean } = {}): void {
this.#guard(ctx);
if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);
const before = this.value;
const diff = options.diff ?? sharesStructure(before, value);
const ops = diff ? diffRevisions(before as JsonValue, value as JsonValue) : undefined;
this.#publish(this.#tracker.prepareReplace({ v: value }), "ops", true, ops);
}
/**
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
* over it against every schema that will apply, so nothing is checked here. Subscribers receive a reset.
*/
reset(value: T, schema: TSchema): void {
this.schema = schema;
this.#publish(this.#tracker.prepareReplace({ v: value }), "reset", false);
}
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
#publish(
prepared: ReturnType<Tracker<Box<T>>["prepareReplace"]>,
type: "ops" | "reset",
validate = true,
ops?: readonly Op[],
): void {
if (prepared.ops.length === 0 && type === "ops") {
this.#tracker.adopt(prepared);
return;
}
const value = prepared.value.v;
let candidates: Map<Derived<unknown>, unknown>;
try {
if (validate) this.validate(value);
candidates = computeDependents(this as Source<unknown>, value, validate);
} catch (error) {
prepared.abort();
throw error;
}
holdPublications(() => {
this.#tracker.adopt(prepared);
const published = ops !== undefined && ops.length > 0 ? ops : unbox(prepared.ops, value);
this.fanout.emit(type === "reset" ? { type, value } : { type, ops: published, value });
for (const [derived, next] of candidates) derived.install(next);
});
}
}
/** True if `b` reuses at least one container of `a` at the same key: a new revision of the same state. */
function sharesStructure(a: unknown, b: unknown): boolean {
if (a === null || b === null || typeof a !== "object" || typeof b !== "object" || a === b) return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
for (const [key, child] of Object.entries(b)) {
if (child !== null && typeof child === "object" && (a as Record<string, unknown>)[key] === child) return true;
}
return false;
}
/** Recomputes every transitive dependent of `changed` against candidate values and validates them. */
function computeDependents(changed: Source<unknown>, value: unknown, validate = true): Map<Derived<unknown>, unknown> {
const pending = new Set<Derived<unknown>>();
const visit = (source: Source<unknown>): void => {
for (const derived of source.dependents) {
if (pending.has(derived)) continue;
pending.add(derived);
visit(derived);
}
};
visit(changed);
const values = new Map<Readable<unknown>, unknown>([[changed, value]]);
const result = new Map<Derived<unknown>, unknown>();
for (const derived of [...pending].sort((a, b) => a.seq - b.seq)) {
const next = derived.compute((source) => (values.has(source) ? values.get(source) : source.value));
if (validate) derived.validate(next);
values.set(derived, next);
result.set(derived, next);
}
return result;
}
export type Sources = readonly Readable<unknown>[];
export type SourceValues<S extends Sources> = { [K in keyof S]: S[K] extends Readable<infer T> ? T : never };
const isReadable = (value: unknown): value is Readable<unknown> =>
value !== null &&
typeof value === "object" &&
typeof (value as Readable<unknown>).subscribe === "function" &&
"value" in value;
/**
* A synchronous derivation. Sources are object cells/derivations (recomputed atomically with the write) or other
* readables such as reference state members (recomputed when they publish). Subscribers receive diffs.
*/
export class Derived<T> extends Source<T> {
readonly sources: readonly Readable<unknown>[];
readonly #fn: (...values: never[]) => unknown;
#value: T;
#external: (() => void)[] = [];
constructor(key: string, sources: Sources, fn: (...values: never[]) => unknown, options: SourceOptions) {
super(key, options);
this.sources = sources.map((source) => {
if (!isReadable(source)) throw new TypeError(`derive ${key}: sources must be readables`);
return source;
});
this.#fn = fn;
this.#value = undefined as T;
}
get value(): T {
return this.#value;
}
/** Hydrated once every source is (a derivation over a remote member waits for its snapshot). */
get [HYDRATION](): Hydration | undefined {
const pending = unhydrated(this.sources);
if (pending.length === 0) return undefined;
return {
hydrated: false,
whenHydrated: () => Promise.all(pending.map((hydration) => hydration.whenHydrated())).then(() => {}),
};
}
/**
* Computes the first value (or takes it from `precomputed`, a reload's validated dry run) and starts following
* the sources (at install, never inside a factory).
*/
attach(precomputed?: ReadonlyMap<unknown, unknown>): void {
this.#value = precomputed?.has(this) ? (precomputed.get(this) as T) : this.compute((source) => source.value);
for (const source of this.sources) {
if (source instanceof Source) source.dependents.add(this as Derived<unknown>);
else this.#external.push(source.subscribe(() => this.refresh()));
}
}
detach(): void {
for (const source of this.sources)
if (source instanceof Source) source.dependents.delete(this as Derived<unknown>);
for (const unsubscribe of this.#external.splice(0)) unsubscribe();
}
compute(read: (source: Readable<unknown>) => unknown): T {
const result: unknown = this.#fn(...(this.sources.map(read) as never[]));
if (result instanceof Promise) throw new TypeError(`derive ${this.key}: fn must be synchronous`);
return result as T;
}
/** Recomputes and republishes, including dependents. Errors are reported (the source write already happened). */
refresh(): void {
try {
const next = this.compute((source) => source.value);
const dependents = computeDependents(this as Derived<unknown>, next);
holdPublications(() => {
this.install(next);
for (const [derived, value] of dependents) derived.install(value);
});
} catch (error) {
this.options.report(error);
}
}
install(next: T): void {
const before = this.#value;
this.#value = next;
const ops: Op[] =
before === undefined || next === undefined
? [["r", (next ?? null) as JsonValue]]
: diffRevisions(before as JsonValue, next as JsonValue);
if (ops.length > 0) this.fanout.emit({ type: "ops", ops, value: next });
}
}
export const isSource = (value: unknown): value is Cell<unknown> | Derived<unknown> => value instanceof Source;
/** Thrown by placeholders used before activation finished. */
export const notReady = (what: string): ChordError => new ChordError("not_ready", `${what} is not ready`);

View File

@@ -0,0 +1,251 @@
import type { Connection } from "./connection.ts";
import { ChordError } from "./errors.ts";
import type { HostImpl, PluginRef } from "./host.ts";
import { beginReload, endReload, type Reload, type ReloadOptions, type ReloadReport, reloadError } from "./reload.ts";
import { type Frame, toWireError } from "./wire.ts";
// Cross-host reload, coordinated by the server (the host without a parent):
// server: prepare itself and send `prepare` to every synced child host; collect votes
// all ok: the server installs its new code version, sends `commit` to every child, and only then starts what may make
// new-code-version calls (tasks, listener and registry callbacks). Links are FIFO, so a child commits before it
// can receive any frame of a root started in the new code version.
// any failure or missed vote deadline: `abort` everywhere; each host rolls back to where it was.
// Clients (browsers) are not participants: they see shape/reset pushes, or `upgrade_required`.
// A child that connects behind the server's code version is fenced and reconciled with the same protocol.
let reloadSeq = 0;
const modulesFor = (remote: ReloadOptions["remote"], hostName: string | undefined): string[] => {
let best: [string, readonly string[]] | undefined;
for (const entry of Object.entries(remote ?? {})) {
if (hostName?.startsWith(entry[0]) && (best === undefined || entry[0].length > best[0].length)) best = entry;
}
return [...(best?.[1] ?? [])];
};
/** A vote, or its rejection after `deadline` milliseconds. */
function awaitVote(connection: Connection, id: number, deadline: number): Promise<unknown> {
let timer: ReturnType<typeof setTimeout> | undefined;
const vote = new Promise<unknown>((resolve, reject) => {
connection.votes.set(id, { resolve, reject });
timer = setTimeout(() => {
connection.votes.delete(id);
reject(new ChordError("aborted", `votes missing after ${deadline} ms`, { reason: "vote_deadline" }));
}, deadline);
});
return vote.finally(() => clearTimeout(timer));
}
export async function runClusterReload(
host: HostImpl,
plugins: readonly PluginRef[],
options: ReloadOptions,
): Promise<ReloadReport> {
// Participants are the children in step now. Fenced children (behind, being reconciled) reconcile to the new code
// version afterwards; a child whose hello arrives while the reload runs is synced or reconciled once it ends
// (`HostImpl.peerHello`).
const synced = [...host.connections].filter(
(connection) =>
connection.role === "downstream" && connection.peer.kind === "host" && connection.open && connection.synced,
);
const codeVersion = host.peekNextCodeVersion();
const reload = await beginReload(host, plugins, options, codeVersion);
try {
// Retire first: its `retire` frames reach every child before `prepare` (FIFO), so a child never retires a code
// version this host still runs roots of.
await reload.waitForRetirement();
const participants = synced.filter((connection) => connection.open);
const id = ++reloadSeq;
const deadline = options.voteDeadline ?? (options.gateDeadline ?? 5000) + (options.retireDeadline ?? 5000) + 5000;
const votes = participants.map((connection) => {
const name = connection.name ?? "child";
const vote = awaitVote(connection, id, deadline);
connection.send({
t: "prepare",
reload: id,
codeVersion,
plugins: modulesFor(options.remote, name),
options: {
remove: modulesFor(options.remoteRemove, name),
gateDeadline: options.gateDeadline,
retireDeadline: options.retireDeadline,
},
});
return vote.then(
(report) => [name, report as ReloadReport | undefined] as const,
(error: unknown) => {
const cause = reloadError(error);
throw new ChordError(cause.code, `${name}: ${cause.message}`, { cause, reason: cause.reason });
},
);
});
let local = false;
const results = await Promise.allSettled([
reload.prepare().then(() => {
local = true;
}),
...votes,
]);
const failed = results.find((result) => result.status === "rejected");
if (failed !== undefined) {
for (const connection of participants) {
connection.votes.delete(id);
connection.send({ t: "abort", reload: id });
}
if (local) await reload.rollback();
throw failed.reason;
}
const report = reload.commit(() => {
for (const connection of participants) {
connection.send({ t: "commit", reload: id });
connection.peerCodeVersion = codeVersion;
}
});
host.rememberRemoteModules(options.remote, options.remoteRemove);
const children: Record<string, ReloadReport> = {};
for (const result of results.slice(1)) {
if (result.status !== "fulfilled") continue;
const [name, child] = result.value as readonly [string, ReloadReport | undefined];
if (child !== undefined) children[name] = child;
}
return { ...report, children };
} finally {
endReload(host);
}
}
/**
* Brings a child that connected behind this host's code version up to it: a reload of the child's modules (the ones
* cluster reloads gave its name) to this host's current code version, prepared, voted and committed like a cluster
* reload. Until then the child is fenced: nothing is routed to it. The child's old code is never relabeled as a newer
* code version.
*/
export async function reconcileChild(host: HostImpl, connection: Connection): Promise<void> {
for (;;) {
if (!connection.open) return;
// Not during a reload of this host: the child is not one of its participants, so whatever it would be synced to
// now may be superseded by that reload's commit. Recheck once it ended.
if (host.reloading) {
await host.whenReloadEnded();
continue;
}
const codeVersion = host.current.id;
if (connection.peerCodeVersion === codeVersion) {
connection.markSynced(codeVersion);
host.announceRetired(connection);
return;
}
// Before `prepare`: the child's prepare first retires its previous code version, which waits for this permission.
host.announceRetired(connection);
const id = ++reloadSeq;
const vote = awaitVote(connection, id, 30_000);
connection.send({
t: "prepare",
reload: id,
codeVersion,
plugins: modulesFor(host.remoteModules, connection.name),
options: { remove: modulesFor(host.remoteRemoved, connection.name) },
});
await vote;
if (host.current.id !== codeVersion) {
// This host moved on meanwhile: abort and try again with the newer code version.
connection.send({ t: "abort", reload: id });
continue;
}
connection.send({ t: "commit", reload: id });
connection.peerCodeVersion = codeVersion;
}
}
/** One prepare the child received, registered before its first await so an abort or disconnect is never lost. */
interface ChildOperation {
readonly id: number;
reload: Reload | undefined;
state: "loading" | "preparing" | "prepared";
cancelled: boolean;
}
/**
* The child side: prepare and vote, then wait for the server's commit or abort. An abort or a lost parent link can
* arrive at any point: while loading modules, while preparing (pending opens close themselves when they complete),
* or after the vote. The operation is unwound once its pending work settles, and the reload slot is freed.
*/
export class ChildReloads {
readonly #host: HostImpl;
#operation: ChildOperation | undefined;
constructor(host: HostImpl) {
this.#host = host;
}
handle(connection: Connection, frame: Extract<Frame, { t: "prepare" | "commit" | "abort" }>): void {
if (frame.t === "prepare") {
if (this.#operation !== undefined) {
const error = new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
connection.send({ t: "vote", reload: frame.reload, error: toWireError(error) });
return;
}
const operation: ChildOperation = { id: frame.reload, reload: undefined, state: "loading", cancelled: false };
this.#operation = operation;
this.#run(operation, connection, frame).catch((error: unknown) => this.#host.report(error));
return;
}
const operation = this.#operation;
if (operation === undefined || operation.id !== frame.reload) return;
if (frame.t === "abort") {
this.#cancel(operation);
return;
}
if (operation.state !== "prepared") return;
this.#operation = undefined;
try {
operation.reload?.commit();
} finally {
endReload(this.#host);
}
}
/** The parent link died: nobody will commit, so roll back (now, or once pending work settles). */
parentLost(): void {
if (this.#operation !== undefined) this.#cancel(this.#operation);
}
#cancel(operation: ChildOperation): void {
operation.cancelled = true;
if (operation.state !== "prepared") {
// Loading or preparing: make prepare fail now; #run unwinds when it settles.
operation.reload?.rollback().catch((error: unknown) => this.#host.report(error));
return;
}
this.#operation = undefined;
operation.reload
?.rollback()
.catch((error: unknown) => this.#host.report(error))
.finally(() => endReload(this.#host));
}
async #run(
operation: ChildOperation,
connection: Connection,
frame: Extract<Frame, { t: "prepare" }>,
): Promise<void> {
const cancelled = (): ChordError =>
new ChordError("aborted", "reload aborted by the server", { reason: "aborted" });
try {
operation.reload = await beginReload(this.#host, frame.plugins, frame.options, frame.codeVersion);
if (operation.cancelled) throw cancelled();
operation.state = "preparing";
await operation.reload.prepare();
if (operation.cancelled) throw cancelled();
operation.state = "prepared";
connection.send({ t: "vote", reload: frame.reload, report: operation.reload.summary() });
} catch (error) {
if (operation.reload !== undefined) {
await operation.reload.rollback().catch((cleanup: unknown) => this.#host.report(cleanup));
endReload(this.#host);
}
if (this.#operation === operation) this.#operation = undefined;
if (!operation.cancelled) connection.send({ t: "vote", reload: frame.reload, error: toWireError(error) });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,16 @@
import type { Context, ContextKey } from "../types.ts";
/** Typed identity for one value carried by a {@link Context}. */
export interface ContextKey<T> {
readonly token: symbol;
/** Type-only marker that prevents keys with different value types from being interchangeable. */
readonly valueType?: (value: T) => T;
}
/** Immutable invocation-scoped values passed explicitly through operations. */
export interface Context {
readonly abortSignal: AbortSignal | undefined;
value<T>(key: ContextKey<T>): T | undefined;
toString(): string;
}
const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
token: Symbol("chord.abortSignal"),

View File

@@ -0,0 +1,440 @@
import { type Static, type TSchema, Type } from "typebox";
import { Value } from "typebox/value";
import { ChordError, invalid } from "./errors.ts";
import { canonicalJson, fits, hash64 } from "./schema.ts";
import type { Ctx, Exposed, Readable, RefBase, StateSource } from "./types.ts";
const MEMBER = Symbol("chord.member");
/** A method's arguments and result; `undefined`: it takes no arguments, or returns nothing (void). */
export interface MethodSpec<
A extends TSchema | undefined = TSchema | undefined,
R extends TSchema | undefined = TSchema | undefined,
> {
readonly [MEMBER]: "method";
readonly args: A;
readonly result: R;
}
export interface OpaqueSpec<T = unknown> {
readonly [MEMBER]: "opaque";
readonly id: string;
/** Type carrier only; never set. */
readonly type?: T;
}
/**
* A method. `method()`: no arguments (called as `ref.name(ctx)`, implemented as `(ctx) => …`) and no result.
* `method(args)`: no result, the handler returns nothing (void). `method(args, result)`, or `method(undefined, result)`
* for a method without arguments that returns something.
*/
export function method(): MethodSpec<undefined, undefined>;
export function method<A extends TSchema>(args: A): MethodSpec<A, undefined>;
export function method<A extends TSchema | undefined, R extends TSchema>(args: A, result: R): MethodSpec<A, R>;
export function method(args?: TSchema, result?: TSchema): MethodSpec {
return { [MEMBER]: "method", args, result };
}
/** A native in-process object identified by `name@version` (local contracts only). */
export const opaque = <T = unknown>(id: string): OpaqueSpec<T> => {
if (!/^[^@\s]+@\d+$/.test(id)) throw new TypeError(`opaque id must be name@version: ${id}`);
return { [MEMBER]: "opaque", id };
};
export interface VariantDef {
readonly state?: Readonly<Record<string, TSchema>>;
readonly methods?: Readonly<Record<string, MethodSpec>>;
}
/** Where objects of a remote type live: one named host. */
export interface HostNamed {
readonly name: string;
}
/** Where objects of a remote type live: the host `prefix` + the input's string field `key` (`session:` + `session`). */
export interface HostFamily<K extends string = string> {
readonly prefix: string;
readonly key: K;
}
/** A remote type's placement: data only, so a description carries it and every host can check it. */
export type Placement = HostNamed | HostFamily;
/** Objects of the type live on the host named `name` (`hostNamed("server")`). */
export function hostNamed(name: string): HostNamed {
if (typeof name !== "string" || name === "") throw new TypeError("hostNamed needs a host name");
return { name };
}
/**
* Objects of the type live on `prefix` + the value of the input's string field `key`: `hostFamily("session:",
* "session")` puts `{ session: "S1", … }` on `session:S1`. The field is required and must be a non-empty string.
*/
export function hostFamily<const K extends string>(prefix: string, key: K): HostFamily<K> {
if (typeof prefix !== "string" || prefix === "") throw new TypeError("hostFamily needs a host-name prefix");
if (typeof key !== "string" || key === "") throw new TypeError("hostFamily needs an input field name");
return { prefix, key };
}
/** Required string fields of `I`: what a `hostFamily` may name. */
type StringKeys<I> = { [K in keyof I]-?: I[K] extends string ? K : never }[keyof I] & string;
/** The placements a remote type with input `I` may have. */
export type PlacementFor<I> = HostNamed | HostFamily<StringKeys<I>>;
/** The host an object of `placement` with this (normalized) input lives on. Throws `invalid/placement`. */
export function resolveHost(typeName: string, placement: Placement, input: unknown): string {
if ("name" in placement) return placement.name;
const value =
input !== null && typeof input === "object" ? (input as Record<string, unknown>)[placement.key] : undefined;
if (typeof value !== "string" || value === "")
throw invalid("placement", `${typeName}: input field ${placement.key} names no host`);
return placement.prefix + value;
}
/** Whether the host named `hostName` may serve a type of `placement`. */
export const placedOn = (placement: Placement, hostName: string): boolean =>
"name" in placement
? placement.name === hostName
: hostName.startsWith(placement.prefix) && hostName.length > placement.prefix.length;
export const placementText = (placement: Placement): string =>
"name" in placement ? placement.name : `${placement.prefix}<${placement.key}>`;
export interface RemoteDef {
readonly version: number;
readonly input: TSchema;
/** Where its objects live: `hostNamed(…)` or `hostFamily(…)`. Part of the contract (and its fingerprint). */
readonly host: Placement;
readonly variants: Readonly<Record<string, VariantDef>>;
}
/** A local member: a method, an opaque native object, or a state member described by its schema. */
export type LocalMember = MethodSpec | OpaqueSpec | TSchema;
export interface LocalDef {
readonly version: number;
readonly input: TSchema;
readonly members?: Readonly<Record<string, LocalMember>>;
}
// A description travels as a reference's contract token (wire.ts), so it is declared once, as the schema that validates it.
// `TSchema` is an empty interface: any object is one, which is all a peer's schema is checked for here.
const SchemaObject = Type.Unsafe<TSchema>(Type.Object({}));
const MemberDescriptionSchema = Type.Union([
// Absent `args`: the method takes none. Absent `result`: it returns nothing.
Type.Object({
kind: Type.Literal("method"),
args: Type.Optional(SchemaObject),
result: Type.Optional(SchemaObject),
}),
Type.Object({ kind: Type.Literal("state"), schema: SchemaObject }),
Type.Object({ kind: Type.Literal("opaque"), id: Type.String() }),
]);
export type MemberDescription = Static<typeof MemberDescriptionSchema>;
/** Local contracts have one unnamed variant. */
export const LOCAL_VARIANT = "";
const PlacementSchema = Type.Union([
Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
Type.Object(
{ prefix: Type.String({ minLength: 1 }), key: Type.String({ minLength: 1 }) },
{ additionalProperties: false },
),
]);
export const DescriptionSchema = Type.Object({
name: Type.String({ minLength: 1 }),
scope: Type.Union([Type.Literal("remote"), Type.Literal("local")]),
version: Type.Integer(),
input: SchemaObject,
/** Remote types only (required there): where their objects live. */
host: Type.Optional(PlacementSchema),
variants: Type.Record(Type.String(), Type.Record(Type.String(), MemberDescriptionSchema)),
});
export type Description = Static<typeof DescriptionSchema>;
interface ContractBase {
readonly name: string;
readonly version: number;
readonly input: TSchema;
readonly description: Description;
readonly fingerprint: string;
}
export interface RemoteContract<D extends RemoteDef = RemoteDef> extends ContractBase {
readonly scope: "remote";
readonly def: D;
}
export interface LocalContract<D extends LocalDef = LocalDef> extends ContractBase {
readonly scope: "local";
readonly def: D;
}
export type AnyContract = RemoteContract | LocalContract;
/** Names a reference uses itself; members may not use them. */
export const RESERVED_MEMBERS: ReadonlySet<string> = new Set([
"variant",
"status",
"ready",
"shape",
"error",
"onShape",
"onStatus",
"release",
"then",
"contract",
]);
function describeMember(name: string, member: unknown, scope: "remote" | "local"): MemberDescription {
if (RESERVED_MEMBERS.has(name)) throw new TypeError(`member name is reserved: ${name}`);
const brand = (member as { [MEMBER]?: string })[MEMBER];
if (brand === "method") {
const spec = member as MethodSpec;
return {
kind: "method",
...(spec.args === undefined ? {} : { args: spec.args }),
...(spec.result === undefined ? {} : { result: spec.result }),
};
}
if (brand === "opaque") {
if (scope === "remote") throw new TypeError(`remote contracts cannot expose opaque members: ${name}`);
return { kind: "opaque", id: (member as OpaqueSpec).id };
}
return { kind: "state", schema: member as TSchema };
}
function finish<C extends AnyContract>(contract: Omit<C, "fingerprint">): C {
return { ...contract, fingerprint: hash64(canonicalJson(contract.description)) } as C;
}
function checkHeader(name: string, version: number): void {
if (!name) throw new TypeError("contract name is empty");
if (!Number.isInteger(version) || version < 1) throw new TypeError(`contract version must be a positive integer`);
}
export function remote<const D extends RemoteDef>(
name: string,
def: D & { readonly host: PlacementFor<Static<D["input"]>> },
): RemoteContract<D> {
checkHeader(name, def.version);
const placement = def.host as Placement | undefined;
if (placement === undefined || !Value.Check(PlacementSchema, placement))
throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`);
// A JSON copy: the description travels, and a placement object may carry nothing else.
const host: Placement =
"name" in placement ? { name: placement.name } : { prefix: placement.prefix, key: placement.key };
const variants: Record<string, Record<string, MemberDescription>> = {};
if (Object.keys(def.variants).length === 0) throw new TypeError(`remote contract ${name} has no variants`);
for (const [kind, variant] of Object.entries(def.variants)) {
const members: Record<string, MemberDescription> = {};
for (const [key, schema] of Object.entries(variant.state ?? {}))
members[key] = describeMember(key, schema, "remote");
for (const [key, spec] of Object.entries(variant.methods ?? {})) {
if (key in members) throw new TypeError(`member ${key} is both state and method in ${name}.${kind}`);
members[key] = describeMember(key, spec, "remote");
if (members[key].kind !== "method") throw new TypeError(`methods.${key} is not a method()`);
}
variants[kind] = members;
}
const description: Description = { name, scope: "remote", version: def.version, input: def.input, host, variants };
return finish<RemoteContract<D>>({
scope: "remote",
name,
version: def.version,
input: def.input,
def,
description,
});
}
export function local<const D extends LocalDef>(name: string, def: D): LocalContract<D> {
checkHeader(name, def.version);
const members: Record<string, MemberDescription> = {};
for (const [key, member] of Object.entries(def.members ?? {})) members[key] = describeMember(key, member, "local");
const description: Description = {
name,
scope: "local",
version: def.version,
input: def.input,
variants: { [LOCAL_VARIANT]: members },
};
return finish<LocalContract<D>>({ scope: "local", name, version: def.version, input: def.input, def, description });
}
/**
* A consumer token received over a link (only its description travels). Has no `def`, so it is only good for binding
* and validation, never for TypeScript types.
*/
export function contractFromDescription(description: Description): AnyContract {
const { name, scope, version, input, host } = description;
if (
typeof name !== "string" ||
(scope !== "remote" && scope !== "local") ||
!Number.isInteger(version) ||
(scope === "remote") !== (host !== undefined)
) {
throw new ChordError("invalid", "malformed contract token", { reason: "token" });
}
const base = { name, version, input, description, fingerprint: hash64(canonicalJson(description)) };
return scope === "remote"
? { ...base, scope, def: { version, input, host: host!, variants: {} } }
: { ...base, scope, def: { version, input } };
}
/** Returns why `provider` does not cover `consumer`, or undefined when it does. */
export function coverage(provider: Description, consumer: Description): string | undefined {
if (provider.name !== consumer.name || provider.scope !== consumer.scope) return "different type";
if (!samePlacement(provider, consumer)) return "host";
if (!fits(consumer.input, provider.input)) return "input";
for (const [kind, consumerMembers] of Object.entries(consumer.variants)) {
const providerMembers = provider.variants[kind];
if (providerMembers === undefined) return `variant ${kind} missing`;
for (const [name, wanted] of Object.entries(consumerMembers)) {
const offered = providerMembers[name];
if (offered === undefined || offered.kind !== wanted.kind) return `${kind}.${name} missing or of another kind`;
if (offered.kind === "method" && wanted.kind === "method") {
// Both take arguments or neither does; a consumer that expects no result accepts any.
if ((wanted.args === undefined) !== (offered.args === undefined)) return `${kind}.${name} arguments`;
if (wanted.args !== undefined && offered.args !== undefined && !fits(wanted.args, offered.args))
return `${kind}.${name} arguments`;
if (wanted.result !== undefined && (offered.result === undefined || !fits(offered.result, wanted.result)))
return `${kind}.${name} result`;
} else if (offered.kind === "state" && wanted.kind === "state") {
if (!fits(offered.schema, wanted.schema)) return `${kind}.${name} state`;
} else if (offered.kind === "opaque" && wanted.kind === "opaque" && offered.id !== wanted.id) {
return `${kind}.${name} opaque ${offered.id} != ${wanted.id}`;
}
}
}
return undefined;
}
const samePlacement = (a: Description, b: Description): boolean =>
canonicalJson(a.host ?? null) === canonicalJson(b.host ?? null);
const placementOf = (description: Description): string =>
description.host === undefined ? "(local)" : placementText(description.host);
/** Maps the provider's current implementation to the view a consumer of an older contract version expects. */
export type Adapter = (current: Record<string, unknown>) => Record<string, unknown>;
export type Accepts = Readonly<Record<number, Adapter>>;
/** How a consumer token binds to a provider: directly or through an `accepts` adapter. Throws contract_mismatch. */
export function bind(provider: AnyContract, accepts: Accepts | undefined, consumer: AnyContract): Adapter | undefined {
if (provider.fingerprint === consumer.fingerprint) return undefined;
// A moved type is another type for its consumers: no adapter reaches across hosts.
if (!samePlacement(provider.description, consumer.description)) {
throw new ChordError(
"contract_mismatch",
`${provider.name} lives on ${placementOf(provider.description)}, the consumer expects ${placementOf(consumer.description)}`,
);
}
if (provider.version === consumer.version) {
const reason = coverage(provider.description, consumer.description);
if (reason === undefined) return undefined;
throw new ChordError(
"contract_mismatch",
`${provider.name}@${provider.version} does not cover the consumer's description (${reason}); forgotten version bump?`,
);
}
const adapter = accepts?.[consumer.version];
if (adapter === undefined) {
throw new ChordError(
"contract_mismatch",
`${provider.name}: provider version ${provider.version}, consumer version ${consumer.version}, no accepts adapter`,
);
}
return adapter;
}
// ─── Static types ────────────────────────────────────────────────────────────
type Empty = Record<never, never>;
type StateOf<V> = V extends { readonly state: infer S extends Readonly<Record<string, TSchema>> } ? S : Empty;
type StaticOf<S> = S extends TSchema ? Static<S> : never;
type MethodsOf<V> = V extends { readonly methods: infer M extends Readonly<Record<string, MethodSpec>> } ? M : Empty;
/** What a handler returns: the result's value, or nothing for a void method. */
type Returned<R> = [R] extends [TSchema] ? Static<R> | Promise<Static<R>> : void | Promise<void>;
/** What a call resolves to. */
type Resolved<R> = [R] extends [TSchema] ? Static<R> : undefined;
/** A method's handler; `P` is the app's principal type (`ctx.principal`). */
export type MethodImplementation<M, P = unknown> = M extends MethodSpec<infer A, infer R>
? [A] extends [TSchema]
? (args: Static<A>, ctx: Ctx<P>) => Returned<R>
: (ctx: Ctx<P>) => Returned<R>
: never;
export type Call<M> = M extends MethodSpec<infer A, infer R>
? [A] extends [TSchema]
? (args: Static<A>, ctx?: Ctx) => Promise<Resolved<R>>
: (ctx?: Ctx) => Promise<Resolved<R>>
: never;
/** Whether `contract`'s method `name` takes no arguments (it is called as `ref.name(ctx)`). */
export function takesNoArgs(contract: AnyContract, name: string): boolean {
for (const members of Object.values(contract.description.variants)) {
const member = members[name];
if (member?.kind === "method") return member.args === undefined;
}
return false;
}
type VariantImplementation<K, V, P> = { readonly variant: K } & {
readonly [S in keyof StateOf<V>]: StateSource<StaticOf<StateOf<V>[S]>>;
} & {
readonly [M in keyof MethodsOf<V>]: MethodImplementation<MethodsOf<V>[M], P>;
};
/**
* A reference in one variant: its state members are plain readables (a reference you hold has its state) and its
* methods calls. `variant` discriminates the union `Ref` is for a multi-variant type.
*/
type VariantRef<C extends AnyContract, K, V> = RefBase<C> & { readonly variant: K } & {
readonly [S in keyof StateOf<V>]: Readable<StaticOf<StateOf<V>[S]>>;
} & {
readonly [M in keyof MethodsOf<V>]: Call<MethodsOf<V>[M]>;
};
type LocalImplementationMember<M, P> = M extends MethodSpec
? MethodImplementation<M, P>
: M extends OpaqueSpec<infer T>
? T | Exposed<T>
: M extends TSchema
? StateSource<Static<M>>
: never;
type LocalRefMember<M> = M extends MethodSpec
? Call<M>
: M extends OpaqueSpec<infer T>
? T | undefined
: M extends TSchema
? Readable<Static<M>>
: never;
type LocalMembers<D> = D extends { readonly members: infer M extends Readonly<Record<string, LocalMember>> }
? M
: Empty;
export type InputOf<C> = C extends { readonly def: { readonly input: infer I extends TSchema } } ? Static<I> : never;
/** What a factory returns for contract C; its methods' `ctx.principal` is `P`. */
export type ImplementationOf<C, P = unknown> = C extends RemoteContract<infer D>
? { [K in keyof D["variants"]]: VariantImplementation<K, D["variants"][K], P> }[keyof D["variants"]]
: C extends LocalContract<infer D>
? { readonly [K in keyof LocalMembers<D>]: LocalImplementationMember<LocalMembers<D>[K], P> } & Record<
string,
unknown
>
: never;
/**
* What `get` returns for contract C. A remote type's reference is a union discriminated by `variant`: another variant's
* members are reachable only after narrowing (`if (ref.variant === "editor") ref.add(…)`). A local type's reference has
* its members.
*/
export type Ref<C extends AnyContract = AnyContract> = C extends RemoteContract<infer D>
? { [K in keyof D["variants"]]: VariantRef<C, K, D["variants"][K]> }[keyof D["variants"]]
: C extends LocalContract<infer D>
? RefBase<C> & { readonly [K in keyof LocalMembers<D>]: LocalRefMember<LocalMembers<D>[K]> }
: never;

View File

@@ -0,0 +1,38 @@
import type { Ctx } from "./types.ts";
// What a factory declares through `self` besides state cells: the shapes of resources and listeners.
export interface Subscribable<E> {
subscribe(listener: (event: E) => void): () => void;
}
/** `self.resource(name, declaration)`: something the object opens and later closes (a database, a watch, a socket). */
export interface ResourceDeclaration<T> {
/** Bump to replace the resource at the next reload (a native value has no schema to detect a change). */
readonly version: number;
/** References or resources that must be live/open first. A change replaces the resource. */
readonly deps?: readonly unknown[];
/** Exclusive resources are replaced at the gate: old closed before new opened. */
readonly exclusive?: boolean;
open(options: { readonly signal: AbortSignal }): T | Promise<T>;
close?(value: T): void | Promise<void>;
}
export interface Resource<T> {
readonly key: string;
/** Throws not_ready until opened. */
readonly value: T;
}
/** A listener as `self.on` declares it. */
export interface ListenerDecl {
readonly source: unknown;
readonly select: ((value: never) => Subscribable<unknown>) | undefined;
readonly fn: (event: unknown, ctx: Ctx) => void | Promise<void>;
}
// A cell has a `key` and a `value` too: sources are matched before resources.
/** What `self.on(name, source, …)` passes to `select`: a resource's opened value, or the source itself. */
export type ValueOf<S> = S extends Subscribable<unknown> ? S : S extends Resource<infer T> ? T : S;
/** What `self.on(name, source, handler)` delivers: a readable's values, an event source's events. */
export type EventOf<S> = S extends Subscribable<infer E> ? E : S extends Resource<infer T> ? EventOf<T> : never;

View File

@@ -1,319 +1,193 @@
# Chord Delta
Chord Delta synchronizes JSON values from an authoritative producer to an
ordered replica. It is available from `@earendil-works/chord/delta`.
Chord Delta produces immutable JSON revisions and exact operation batches for
ordered replicas. Import it from `@earendil-works/chord/delta`.
A change is represented by an `Op`: a JSON tuple for replacing, setting,
deleting, updating a string, or splicing an array. Producers use `track()`;
replicas use `apply()` or `applyImmutable()`.
Immutability is an ownership contract. Nothing is frozen or defensively copied,
so an illegal mutation is not detected. It silently corrupts state.
```ts
import { apply, track } from "@earendil-works/chord/delta";
import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "", entries: [] as string[] });
let replica = apply(undefined, tracker.flush());
const initial = { output: "", entries: [] as { id: number }[] };
const tracker = track(initial); // `initial` is transferred: never mutate it again
tracker.state.output += "done\n";
tracker.state.entries.push("result");
replica = apply(replica, tracker.flush());
const change = tracker.beginChange();
change.state.output += "done\n"; // the draft is mutable only while the change is open
change.state.entries.push({ id: 1 }); // placed values are cloned; the caller keeps its object
const prepared = change.prepare(); // draft handles are unusable from here on
// tracker.value, prepared.base, prepared.value, and prepared.ops (including paths
// and payloads) are immutable by contract.
tracker.adopt(prepared); // tracker.value === prepared.value
// Shares containers with prepared.base and the op payloads: never mutate it either.
const replica = applyImmutable(prepared.base, prepared.ops);
```
The first `flush()` returns one operation containing the complete value. Each
later flush returns the operations needed to transform the previously published
value into the current value. It returns `[]` when the value has not changed.
## Mutation rights
`applyImmutable()` copies only containers along changed paths and shares
unchanged subtrees. It does not mutate, clone, or freeze either complete input.
Chord's replicated-state producers mutate a tracked proxy and publish operation
batches; consumers still observe complete immutable values.
| Value | May it be mutated? |
| --- | --- |
| Root passed to `track()`, `prepareReplace()`, `replicatedState()`, or `replace()` | No. Ownership moved to the tracker. |
| `change.state` and handles read from it | Yes, only while that change is open. After `prepare()`, `abort()`, or another adoption, every use throws. Writes through a handle whose element was removed from the draft are ignored. |
| External value after assigning or inserting it into a draft | Yes. The draft stored a validated clone. |
| `tracker.value`, `prepared.base`, retained older revisions | No. |
| `prepared.value`, `prepared.ops`, op tuples, paths, permutations, payloads | No. Payloads may be the same objects as parts of `prepared.value`. |
| `applyImmutable()` / `applyImmutableBatches()` inputs and result | No. The result shares containers with both inputs. |
| Values from replicated state and cells (`value`, listener values, in-process handles) | No. In-process consumers may share the provider's containers. |
| Mutable replica passed to `apply()` | Only through `apply()`, with batches it owns exclusively. Code that edits it between batches breaks convergence. |
| Batch passed to `apply()` | Consumed. `apply()` adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again. |
## Sending or storing changes
## Replaying batches
`flush()` produces decoded `Op[]` with complete paths. This is convenient for
local use but repeats long paths on the wire or disk.
`encoder()` compresses those paths and returns `WireOp[]`. `decoder()` validates
the encoded tuples, restores complete paths, and returns the `Op[]` required by
`apply()`:
Immutable replay is the preferred in-process path. One batch can fan out to any
number of immutable replicas:
```ts
import { apply, decoder, encoder, track } from "@earendil-works/chord/delta";
const tracker = track({ output: "" });
const enc = encoder(); // producer side
const dec = decoder(); // consumer side
let replica: { output: string } | undefined;
const send = () => {
const ops = tracker.flush();
const wire = enc.encode(ops); // serialize or store WireOp[] here
const received = dec.decode(wire);
replica = apply(replica, received);
};
let replica = tracker.value; // shared with authority; never mutated
// For each adopted batch, in order:
replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base
```
Encoding is optional for local application. Never pass `WireOp[]` directly to
`apply()`.
An encoder and decoder are stateful. Use one pair for each ordered stream. The
encoder assigns numeric IDs to paths used across batches; the decoder remembers
the corresponding definitions. A complete-value operation resets both path
dictionaries, so replay can begin at that batch with a fresh decoder.
Path omission is local to one batch. Numeric path IDs may span batches. Each
independently hydrated replicated-state stream needs its own encoder and decoder.
Do not share a pair between state members or subscriptions, even when their
batches use the same ordered transport connection.
## Operation vocabulary
A path is an array of object keys and array indices:
When only the final result of an ordered backlog is needed, replay its batches
without concatenating their operations. One copy-on-write scope is shared across
the complete call, so no intermediate revision is exposed or safe to retain:
```ts
["operation", "message", "content", 0, "text"]
replica = applyImmutableBatches(
replica,
queuedFrames.map((frame) => frame.ops),
);
```
### Decoded `Op`
Use separate `applyImmutable()` calls when every intermediate revision is
published or retained.
`track().flush()` returns these tuples, and `apply()` accepts them:
Mutable replay needs a detached starting root and a detached copy of every batch
for every replica. Never apply one in-memory batch to two mutable replicas:
```ts
import { apply } from "@earendil-works/chord/delta";
const detach = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
let replica = detach(tracker.value); // exclusively owned starting root
// For each adopted batch, in order:
replica = apply(replica, detach(prepared.ops)); // fresh copy for this replica only
// Only apply() may change `replica`; do not edit it in application code.
```
Parsing a separately serialized message per replica is also a valid detachment.
`decode()` does not copy payloads, so decoding one in-memory `WireOp[]` for two
mutable replicas makes them alias each other.
## Do and don't
- Do build a fresh root and then transfer it; validate untrusted roots at your ingestion boundary.
- Do keep structural sharing between revisions, for example `replace(ctx, { ...state.value, changed })`.
- Do clone or serialize before handing any published value to code that may mutate it.
- Do settle every change with `prepare()` or `abort()`.
- Don't reuse one container at two places in a transferred root.
- Don't mutate anything the tracker owns, published, or received from a batch.
- Don't keep draft handles past their change or place a settled handle.
- Don't infer meaning from op shape. Only the resulting value is contractual.
## Roots and placements
Full roots are trusted. `track()` and `prepareReplace()` take ownership in O(1)
and never walk the tree. Roots must be alias-free, acyclic strict JSON: dense
plain arrays, plain or null-prototype objects with enumerable own data
properties, strings, booleans, finite numbers, and `null`. Violations have
unspecified behavior. For example, a container shared by two keys can make
`prepared.value` diverge from what replicas compute from `prepared.ops`.
Draft placements are copied, and that copy walk also validates. Placements come
from property writes, index writes, `push`, `unshift`, `splice`, `fill`, and
`copyWithin`. A non-strict value throws `TypeError` before the draft changes;
for example, `push(valid, invalid)` inserts nothing. Rejected values include:
cycles, accessors (never invoked), symbol keys, class instances, sparse or
non-plain arrays, functions, bigint, `NaN`, infinities, and `undefined`.
Null-prototype objects are accepted and preserved. Placing a draft handle clones
its current content. Placing one value at several paths yields independent
containers.
`undefined` rules:
- `draft.obj.key = undefined` deletes `key`.
- `arr[i] = undefined`, `push(undefined)`, `unshift`, `splice`, and `fill` with `undefined` throw.
- `undefined` nested anywhere inside a placed object or array throws. It is not dropped like `JSON.stringify` does.
Arrays stay dense. Writing past the next index and deleting an element throw.
Growing `length` inserts `null`; shrinking it removes elements.
## Lifecycle
`tracker.value` is the latest adopted revision. `beginChange()` opens an overlay
draft over it and never modifies it. A draft may stay open across `await`.
`prepare()` materializes the candidate and ops and does not change authority.
`adopt()` checks the preparation and swaps the root pointer.
Several changes may be open or prepared from one revision. Adopting one makes all
others stale: open drafts become unusable and their `prepare()` throws, and
prepared competitors are rejected by `adopt()`. `adopt()` also rejects foreign,
aborted, and already consumed preparations. `Change.abort()` or
`Prepared.abort()` after `prepare()` prevents adoption. The candidate stays
readable.
No-op batches:
- Empty `ops` means `prepared.value === prepared.base`. Writes restored to their original value and deeply equal container assignments usually normalize to empty.
- Equality ignores key order and prototype. A deeply equal assignment keeps the previous revision's order and prototype, even if the draft showed the new ones.
- Structural array edits may emit a nonempty exact batch even when the result is deeply equal.
- Adopting a no-op still advances `tracker.revision` and stales competitors. Replicated state does not publish no-ops.
`prepareReplace(value)` is a whole-root operation, not a diff. If `value` is
deeply equal to the current root, `ops` is empty and the current root is kept
(the comparison may traverse both trees). Otherwise `ops` is `[["r", value]]`
and `prepared.value === value`, so replicas receive the complete root.
Array mutators: `push`, `pop`, `shift`, `unshift`, `splice`, `reverse`, `sort`,
`fill`, and `copyWithin`. Held handles follow elements through reindexing.
## Operations
Paths contain object keys and non-negative integer array indices.
| Tuple | Meaning |
| --- | --- |
| `["r", value]` | Replace the complete value. |
| `["s", path, value]` | Set a property or array element. |
| `["d", path]` | Delete an object property. |
| `["s", path, value]` | Set an object property or array element. |
| `["d", path]` | Delete an object property or remove an array element. |
| `["a", path, text]` | Append to a string. |
| `["t", path, count]` | Remove UTF-16 code units from a string's front. |
| `["t", path, count]` | Remove `count` UTF-16 code units from a string's front. |
| `["p", path, index, remove, items]` | Splice an array. |
| `["m", path, permutation]` | Reorder an array: `new[i] = old[permutation[i]]`. |
Except for `r`, every decoded operation carries its complete path. `s`, `d`,
`a`, and `t` cannot address the root. `p` may address a root array.
Batches are exact but not canonical. The same change may use different tuples,
and large edit sets may fold into a region splice, an ancestor `s`, or `r`.
### Encoded `WireOp`
Reserved keys: the tracker never emits `__proto__`, `constructor`, or
`prototype` as a path segment. A mutation at or below such a key is folded into a
set of the nearest safe ancestor, or `r` at the root. `apply()`, `applyImmutable()`, `applyImmutableBatches()`, and `decoder()` reject
those segments with `UnsafePathError`.
The appliers write values as own data properties, never through a prototype setter.
Appliers check op shape and path safety but not payload strictness. Chord's
remote state applies batches decoded from JSON frames, so their payloads are strict
JSON; validate payloads of batches from any other source.
A `PathRef` is either an inline path or a non-negative numeric path ID.
`WireOp` supports the following tuples:
`encoder()` interns repeated paths into `WireOp` tuples; `decoder()` validates
and restores `Op` tuples. Use one encoder/decoder pair per ordered state stream.
Path IDs span batches and an `r` resets both dictionaries. After a decode or
apply error, discard the decoder and replica and recover from a later `r`.
| Tuple | Meaning |
| --- | --- |
| `["r", value]` | Complete replacement; identical to decoded form. |
| `["#", id, path]` | Define a numeric path ID. |
| `["s", pathRef, value]` | Set with an inline or interned path. |
| `["s", value]` | Set using the previous path in this batch. |
| `["d", pathRef]` | Delete with an inline or interned path. |
| `["d"]` | Delete using the previous path. |
| `["a", pathRef, text]` | Append with an inline or interned path. |
| `["a", text]` | Append using the previous path. |
| `["t", pathRef, count]` | Front-truncate with an inline or interned path. |
| `["t", count]` | Front-truncate using the previous path. |
| `["p", pathRef, index, remove, items]` | Splice with an inline or interned path. |
| `["p", index, remove, items]` | Splice using the previous path. |
## Limits and footguns
For example, adjacent decoded operations on one path:
```ts
[
["t", ["output"], 200],
["a", ["output"], "next chunk"],
]
```
encode to:
```ts
[
["t", ["output"], 200],
["a", "next chunk"], // reuses ["output"]
]
```
When `output` is used again in a later batch, the encoder defines an ID on its
second explicit use:
```ts
[
["#", 0, ["output"]],
["a", 0, "more"],
]
```
Later batches can use `0` directly until a complete-value operation resets the
dictionary.
## Producing changes
Read and mutate `tracker.state` as a normal object:
```ts
tracker.state.status = "running";
tracker.state.settings.theme = "dark";
tracker.state.messages.push(message);
delete tracker.state.retry;
```
Only the value at flush time is published:
```ts
tracker.state.status = "starting";
tracker.state.status = "running";
tracker.flush(); // one set to "running"
```
Replacing an object or array is valid. Delta compares its properties and elements
with the previously published value:
```ts
tracker.state.settings = {
...plainSettings,
theme: "dark",
};
```
Unchanged properties produce no operations. Changed nested strings and arrays
still use string and splice operations.
### Strings
Appending text produces an `a` operation:
```ts
tracker.state.output += "next line\n";
```
Moving a bounded text window forward produces `t` followed by `a` when the old
suffix matches the new prefix:
```ts
tracker.state.output = tracker.state.output.slice(200) + nextChunk;
```
An unrelated replacement produces `s`.
### Arrays
Use normal array methods:
```ts
tracker.state.messages.push(first);
tracker.state.messages.push(second);
tracker.state.messages.splice(3, 1, replacement);
```
All `push()` calls before one flush produce one tail `p`. Changes to older
elements remain separate, regardless of whether they happen before or after the
pushes. Changes to newly pushed elements are included in the pushed values.
Front or middle insertion, removal, sorting, reversing, `fill()`, and
`copyWithin()` are supported. A structural change combined with edits to elements
whose indices moved may compare and publish the retained suffix positionally:
```ts
tracker.state.items.shift();
tracker.state.items[0].status = "changed";
// A shift followed by push in the same flush has the same issue.
```
The emitted data can then scale with the retained suffix, or with the complete
array, rather than only the changed element. When batching is under your control,
flush the structural change before editing elements at their new indices.
Sparse arrays are unsupported. Writing beyond the next index throws. Increasing
`length` creates explicit `null` elements; decreasing it removes elements.
`fill()` and `copyWithin()` keep normal JavaScript reference semantics. Do not use
them to place one mutable object at multiple live paths.
### Optional properties
Optional object properties use absence. They do not require `null`:
```ts
type Settings = { label?: string; count: number };
const tracker = track<Settings>({ count: 0 });
tracker.state.label = "active";
tracker.state.label = undefined; // produces d
// `delete tracker.state.label` is equivalent
```
`undefined` is accepted only as assignment syntax for deleting an object
property. It is not a JSON value. Initial and assigned objects cannot contain own
`undefined` values, and array elements cannot be `undefined`. Use `null` when an
array position or explicit empty value must remain present.
## State ownership
The object passed to `track()` becomes tracker-owned. The same applies to objects
later assigned into state or inserted into arrays.
After insertion, a retained reference may be read but must not be mutated or
inserted at another live location. The tracker relies on this ownership rule; it
does not recursively validate values or detect aliases:
```ts
const item = { status: "new" };
tracker.state.item = item;
tracker.state.item.status = "ready"; // supported: tracked mutation
item.status = "broken"; // unsupported: bypasses tracking
tracker.state.other = item; // unsupported: one object at two live paths
```
The same restriction applies across separate array calls:
```ts
tracker.state.items.push(item);
tracker.state.items.push(item); // unsupported alias
```
Use distinct objects when values must appear at multiple paths. Perform
mutations through `tracker.state`; do not put a proxy read from `tracker.state`
back into tracked state.
Tracked state must be a mutable JSON tree:
- strings, booleans, finite numbers, `null`, arrays, and plain objects;
- no cycles or one mutable object stored at multiple locations;
- no sparse arrays, accessors, frozen objects, symbols, classes, functions,
`Map`, or `Set`.
Do not keep a child proxy across an array operation that changes indices. Read
the child again from its new index.
## Tracker lifecycle
```ts
tracker.flush(); // publish changes since the previous flush
tracker.rebase(); // make the next flush a complete replacement
tracker.discard(); // accept current changes without publishing them
tracker.state = replacement; // replace the root; next flush is complete
```
`discard()` intentionally prevents current changes from reaching existing
replicas. Use it only when those replicas do not need the discarded changes.
`apply()` adopts object and array payloads from its input batch. Do not freeze a
batch before applying it, and do not apply one in-memory batch to multiple
mutable replicas unless each replica owns that batch. A serialized and decoded
batch is already detached. `applyImmutable()` instead treats its previous value
and operation payloads as immutable, so one batch can safely fan out in-process.
A `decode()`, `apply()`, or `applyImmutable()` error terminates that stream.
Discard its decoder and replica, then recover from a later base batch. `apply()`
is not transactional; operations before the failing operation may already have
changed the replica.
## Limits
- Delta assumes one authoritative writer and ordered delivery. Sequence numbers,
gap detection, retries, and persistence policy belong to the surrounding
protocol or storage format.
- Object identity is not replicated. Tracked mutable state must be a tree;
immutable inputs may share references, but replicas need not preserve them.
- Object key insertion order is not replicated. Do not compare or hash replicas
using serialized key order.
- Array operations that change indices may publish a wider array region, as
described under Arrays.
- Object-valued keys named `__proto__`, `constructor`, or `prototype` can be read
and serialized, but cannot be mutated through that key. Replace the nearest
ordinarily named parent instead.
- No freezing. Mutating any immutable value corrupts state silently.
- In-process sharing: local handles, cell subscribers, and `replicatedState` subscribers receive the provider's containers. A consumer mutation corrupts authority and makes remote replicas diverge.
- A change inside a large flat array copies that array's pointer storage for the new revision.
- Placement validation costs time and memory on bulk inserts. In a benchmark unshifting 100k three-field objects, it added about 19 ms and 20 MiB of transient heap.
- Draft reads go through proxies. In one measurement, 20,000 hot nested reads took about 9 ms through the tracker versus 0.08 ms on raw objects; compare with `node --expose-gc packages/chord/test/delta-traversal.bench.ts --quick`.
- External proxies as placements, argument coercion callbacks that mutate the draft, non-primitive array indices, and non-numeric comparator results are out of contract. So are sort comparators that mutate, prepare, abort, or adopt. Behavior is unspecified.
- Object identity is not replicated. Each path is an independent value placement.
- One tracker is one revision sequence. Delivery order and persistence belong to the surrounding protocol.

View File

@@ -0,0 +1,128 @@
import type { JsonValue } from "../json.ts";
import type { Op, Path, Seg } from "./index.ts";
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
type CopiedPath = { root: JsonValue; target: JsonContainer };
const DATA_DESCRIPTOR: PropertyDescriptor = {
value: undefined,
writable: true,
enumerable: true,
configurable: true,
};
/** Apply self-produced trusted operations while copying each touched container once. */
export function applyImmutableTrusted<T>(target: T, operations: readonly Op[]): T {
let root = target as unknown as JsonValue;
const owned = new WeakSet<object>();
for (const operation of operations) {
if (operation[0] === "r") {
root = operation[1];
continue;
}
if (operation[0] === "p" || operation[0] === "m") {
const copied = copyPath(root, operation[1], operation[1].length, owned);
root = copied.root;
if (!Array.isArray(copied.target)) throw new TypeError("Trusted array operation target is not an array");
if (operation[0] === "p") spliceTrusted(copied.target, operation[2], operation[3], operation[4]);
else permuteTrusted(copied.target, operation[2]);
continue;
}
const path = operation[1];
const copied = copyPath(root, path, path.length - 1, owned);
root = copied.root;
const key = path[path.length - 1]!;
switch (operation[0]) {
case "s":
defineData(copied.target, key, operation[2]);
break;
case "d":
if (Array.isArray(copied.target)) {
if (typeof key !== "number") throw new TypeError("Trusted array deletion key is not numeric");
spliceTrusted(copied.target, key, 1, []);
} else Reflect.deleteProperty(copied.target, key);
break;
case "a": {
const current = read(copied.target, key);
if (typeof current !== "string") throw new TypeError("Trusted append target is not a string");
defineData(copied.target, key, `${current}${operation[2]}`);
break;
}
case "t": {
const current = read(copied.target, key);
if (typeof current !== "string") throw new TypeError("Trusted truncate target is not a string");
defineData(copied.target, key, current.slice(operation[2]));
break;
}
}
}
return root as unknown as T;
}
function copyPath(root: JsonValue, path: Path, length: number, owned: WeakSet<object>): CopiedPath {
if (!isContainer(root)) throw new TypeError("Trusted operation root is not a container");
let copiedRoot = root;
if (!owned.has(root)) {
copiedRoot = shallowCopy(root);
owned.add(copiedRoot);
}
let destination = copiedRoot as JsonContainer;
for (let index = 0; index < length; index++) {
const segment = path[index]!;
const child = read(destination, segment);
if (!isContainer(child)) throw new TypeError("Trusted operation path is not a container");
if (owned.has(child)) {
destination = child;
continue;
}
const copy = shallowCopy(child);
defineData(destination, segment, copy);
owned.add(copy);
destination = copy;
}
return { root: copiedRoot, target: destination };
}
function shallowCopy(value: JsonContainer): JsonContainer {
if (Array.isArray(value)) return Array.from(value);
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const key of Object.keys(value)) defineData(result, key, value[key]!);
return result;
}
function read(target: JsonContainer, key: Seg): JsonValue {
return (target as Record<Seg, JsonValue>)[key]!;
}
function defineData(target: JsonContainer, key: Seg, value: JsonValue): void {
DATA_DESCRIPTOR.value = value;
Object.defineProperty(target, key, DATA_DESCRIPTOR);
DATA_DESCRIPTOR.value = undefined;
}
function spliceTrusted(target: JsonValue[], start: number, remove: number, items: readonly JsonValue[]): void {
const oldLength = target.length;
const delta = items.length - remove;
if (delta > 0) {
target.length = oldLength + delta;
target.copyWithin(start + items.length, start + remove, oldLength);
} else if (delta < 0) {
target.copyWithin(start + items.length, start + remove, oldLength);
target.length = oldLength + delta;
}
for (let index = 0; index < items.length; index++) defineData(target, start + index, items[index]!);
}
function permuteTrusted(target: JsonValue[], permutation: readonly number[]): void {
if (target.length !== permutation.length) throw new TypeError("Trusted permutation length mismatch");
const previous = target.slice();
for (let index = 0; index < permutation.length; index++) target[index] = previous[permutation[index]!]!;
}
function isContainer(value: unknown): value is JsonContainer {
return value !== null && typeof value === "object";
}

View File

@@ -0,0 +1,523 @@
import type { JsonValue } from "../json.ts";
import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS, type Seg } from "./index.ts";
const DEFAULT_OVERLAP_SCAN = 65_536;
const MAX_DELTA_OPERATIONS = 4_096;
const overflowedBatches = new WeakSet<Op[]>();
const emitOperation = (operations: Op[], operation: Op): void => {
if (overflowedBatches.has(operations)) return;
if (operations.length >= MAX_DELTA_OPERATIONS) {
overflowedBatches.add(operations);
return;
}
operations.push(operation);
};
const isContainer = (value: JsonValue): value is JsonValue[] | Record<string, JsonValue> =>
value !== null && typeof value === "object";
const emitSet = (path: Path, value: JsonValue, operations: Op[]): void => {
if (path.length === 0) emitOperation(operations, ["r", value]);
else emitOperation(operations, ["s", path as NonEmptyPath, value]);
};
const equalJson = (left: JsonValue, right: JsonValue): boolean => {
if (left === right) return true;
if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left)) {
const other = right as JsonValue[];
if (left.length !== other.length) return false;
for (let index = 0; index < left.length; index++) {
if (!equalJson(left[index]!, other[index]!)) return false;
}
return true;
}
const other = right as Record<string, JsonValue>;
const keys = Object.keys(left);
if (keys.length !== Object.keys(other).length) return false;
for (const key of keys) {
if (!Object.hasOwn(other, key) || !equalJson(left[key]!, other[key]!)) return false;
}
return true;
};
const permutation = (before: readonly JsonValue[], after: readonly JsonValue[]): number[] | undefined => {
if (before.length !== after.length) return undefined;
const positions = new Map<JsonValue, { indices: number[]; used: number }>();
for (let index = 0; index < before.length; index++) {
const value = before[index]!;
const entry = positions.get(value);
if (entry === undefined) positions.set(value, { indices: [index], used: 0 });
else entry.indices.push(index);
}
const result = new Array<number>(after.length);
for (let index = 0; index < after.length; index++) {
const entry = positions.get(after[index]!);
if (entry === undefined || entry.used === entry.indices.length) return undefined;
result[index] = entry.indices[entry.used++]!;
}
return result;
};
const emitString = (before: string, after: string, path: NonEmptyPath, operations: Op[]): void => {
if (before === after) return;
if (after.length > before.length && after.slice(0, before.length) === before) {
emitOperation(operations, ["a", path, after.slice(before.length)]);
return;
}
const shared = overlap(before, after, DEFAULT_OVERLAP_SCAN);
if (shared === 0) {
emitOperation(operations, ["s", path, after]);
return;
}
emitOperation(operations, ["t", path, before.length - shared]);
if (after.length > shared) emitOperation(operations, ["a", path, after.slice(shared)]);
};
type ArrayMatch = readonly [before: number, after: number];
type MatchCandidate = {
before: number;
after: number;
previous: number;
};
const MAX_IDENTITY_CANDIDATES = 200_000;
const MAX_SEMANTIC_CELLS = 65_536;
const sameValue = (left: JsonValue, right: JsonValue): boolean => left === right || equalJson(left, right);
const lcsMatches = (
before: readonly JsonValue[],
after: readonly JsonValue[],
equal: (left: JsonValue, right: JsonValue) => boolean,
maxCells: number,
): ArrayMatch[] | undefined => {
if (before.length === 0 || after.length === 0) return [];
if (before.length * after.length > maxCells) return undefined;
const width = after.length + 1;
const lengths = new Uint32Array((before.length + 1) * width);
for (let left = before.length - 1; left >= 0; left--) {
for (let right = after.length - 1; right >= 0; right--) {
const at = left * width + right;
lengths[at] = equal(before[left]!, after[right]!)
? lengths[(left + 1) * width + right + 1]! + 1
: Math.max(lengths[(left + 1) * width + right]!, lengths[left * width + right + 1]!);
}
}
const matches: ArrayMatch[] = [];
let left = 0;
let right = 0;
while (left < before.length && right < after.length) {
if (
equal(before[left]!, after[right]!) &&
lengths[left * width + right] === lengths[(left + 1) * width + right + 1]! + 1
) {
matches.push([left++, right++]);
} else if (lengths[(left + 1) * width + right]! >= lengths[left * width + right + 1]!) left += 1;
else right += 1;
}
return matches;
};
const semanticallyAligned = (left: JsonValue, right: JsonValue): boolean => {
if (sameValue(left, right)) return true;
if (!isContainer(left) || !isContainer(right) || Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) return false;
return left.some((value, index) => isContainer(value) && value === right[index]);
}
const leftObject = left as Record<string, JsonValue>;
const rightObject = right as Record<string, JsonValue>;
for (const key of Object.keys(leftObject)) {
const value = leftObject[key];
if (isContainer(value) && Object.hasOwn(rightObject, key) && value === rightObject[key]) return true;
}
return false;
};
const lowerBound = (values: readonly number[], value: number): number => {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (values[middle]! < value) low = middle + 1;
else high = middle;
}
return low;
};
const identitySubsequence = (
before: readonly JsonValue[],
beforeStart: number,
beforeEnd: number,
after: readonly JsonValue[],
afterStart: number,
afterEnd: number,
): ArrayMatch[] | undefined => {
const beforeCount = beforeEnd - beforeStart;
const afterCount = afterEnd - afterStart;
const matches: ArrayMatch[] = [];
if (afterCount < beforeCount) {
let beforeIndex = beforeStart;
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
while (beforeIndex < beforeEnd && before[beforeIndex] !== after[afterIndex]) beforeIndex += 1;
if (beforeIndex === beforeEnd) return undefined;
matches.push([beforeIndex++, afterIndex]);
}
return matches;
}
if (beforeCount < afterCount) {
let afterIndex = afterStart;
for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex++) {
while (afterIndex < afterEnd && before[beforeIndex] !== after[afterIndex]) afterIndex += 1;
if (afterIndex === afterEnd) return undefined;
matches.push([beforeIndex, afterIndex++]);
}
return matches;
}
return undefined;
};
const greedyIdentityAnchors = (
positions: ReadonlyMap<JsonValue, readonly number[]>,
after: readonly JsonValue[],
afterStart: number,
afterEnd: number,
): ArrayMatch[] => {
const matches: ArrayMatch[] = [];
let previous = -1;
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
const candidates = positions.get(after[afterIndex]!);
if (candidates === undefined) continue;
const at = lowerBound(candidates, previous + 1);
const beforeIndex = candidates[at];
if (beforeIndex === undefined) continue;
matches.push([beforeIndex, afterIndex]);
previous = beforeIndex;
}
return matches;
};
const identityAnchors = (
before: readonly JsonValue[],
beforeStart: number,
beforeEnd: number,
after: readonly JsonValue[],
afterStart: number,
afterEnd: number,
): ArrayMatch[] => {
const positions = new Map<JsonValue, number[]>();
for (let index = beforeStart; index < beforeEnd; index++) {
const value = before[index]!;
const existing = positions.get(value);
if (existing === undefined) positions.set(value, [index]);
else existing.push(index);
}
let candidateCount = 0;
for (let index = afterStart; index < afterEnd; index++) {
candidateCount += positions.get(after[index]!)?.length ?? 0;
if (candidateCount > MAX_IDENTITY_CANDIDATES) {
return greedyIdentityAnchors(positions, after, afterStart, afterEnd);
}
}
if (candidateCount === 0) return [];
const candidates: MatchCandidate[] = [];
const tails: number[] = [];
const tailValues: number[] = [];
for (let afterIndex = afterStart; afterIndex < afterEnd; afterIndex++) {
const beforePositions = positions.get(after[afterIndex]!);
if (beforePositions === undefined) continue;
for (let index = beforePositions.length - 1; index >= 0; index--) {
const beforeIndex = beforePositions[index]!;
const at = lowerBound(tailValues, beforeIndex);
const candidateIndex = candidates.length;
candidates.push({ before: beforeIndex, after: afterIndex, previous: at === 0 ? -1 : tails[at - 1]! });
tails[at] = candidateIndex;
tailValues[at] = beforeIndex;
}
}
const matches: ArrayMatch[] = [];
let candidateIndex = tails[tails.length - 1] ?? -1;
while (candidateIndex >= 0) {
const candidate = candidates[candidateIndex]!;
matches.push([candidate.before, candidate.after]);
candidateIndex = candidate.previous;
}
matches.reverse();
return matches;
};
const processArrayMatches = (
before: JsonValue[],
after: JsonValue[],
path: Path,
operations: Op[],
beforeStart: number,
beforeEnd: number,
afterStart: number,
afterEnd: number,
outputStart: number,
matches: readonly ArrayMatch[],
): void => {
let beforeAt = beforeStart;
let afterAt = afterStart;
let outputAt = outputStart;
for (const [beforeMatch, afterMatch] of matches) {
if (overflowedBatches.has(operations)) return;
diffArrayRegion(before, after, path, operations, beforeAt, beforeMatch, afterAt, afterMatch, outputAt);
outputAt += afterMatch - afterAt;
if (!sameValue(before[beforeMatch]!, after[afterMatch]!)) {
diffValue(before[beforeMatch]!, after[afterMatch]!, [...path, outputAt], operations);
}
outputAt += 1;
beforeAt = beforeMatch + 1;
afterAt = afterMatch + 1;
}
if (!overflowedBatches.has(operations)) {
diffArrayRegion(before, after, path, operations, beforeAt, beforeEnd, afterAt, afterEnd, outputAt);
}
};
function diffArrayRegion(
before: JsonValue[],
after: JsonValue[],
path: Path,
operations: Op[],
beforeStart: number,
beforeEnd: number,
afterStart: number,
afterEnd: number,
outputStart: number,
): void {
if (overflowedBatches.has(operations)) return;
while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeStart]!, after[afterStart]!)) {
beforeStart += 1;
afterStart += 1;
outputStart += 1;
}
while (beforeStart < beforeEnd && afterStart < afterEnd && sameValue(before[beforeEnd - 1]!, after[afterEnd - 1]!)) {
beforeEnd -= 1;
afterEnd -= 1;
}
const beforeCount = beforeEnd - beforeStart;
const afterCount = afterEnd - afterStart;
if (beforeCount === 0 && afterCount === 0) return;
if (beforeCount === 0 || afterCount === 0) {
emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]);
return;
}
if (beforeCount === afterCount) {
const positional: ArrayMatch[] = [];
for (let offset = 0; offset < beforeCount; offset++) {
if (sameValue(before[beforeStart + offset]!, after[afterStart + offset]!)) {
positional.push([beforeStart + offset, afterStart + offset]);
}
}
if (positional.length > 0) {
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
positional,
);
return;
}
}
const subsequence = identitySubsequence(before, beforeStart, beforeEnd, after, afterStart, afterEnd);
if (subsequence !== undefined && subsequence.length > 0) {
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
subsequence,
);
return;
}
const identity = identityAnchors(before, beforeStart, beforeEnd, after, afterStart, afterEnd);
if (identity.length > 0) {
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
identity,
);
return;
}
const semantic = lcsMatches(
before.slice(beforeStart, beforeEnd),
after.slice(afterStart, afterEnd),
semanticallyAligned,
MAX_SEMANTIC_CELLS,
);
if (semantic !== undefined && semantic.length > 0) {
const absolute = semantic.map(
([beforeIndex, afterIndex]) => [beforeStart + beforeIndex, afterStart + afterIndex] as const,
);
processArrayMatches(
before,
after,
path,
operations,
beforeStart,
beforeEnd,
afterStart,
afterEnd,
outputStart,
absolute,
);
return;
}
if (beforeCount === 1 && afterCount === 1) {
diffValue(before[beforeStart]!, after[afterStart]!, [...path, outputStart], operations);
return;
}
emitOperation(operations, ["p", path, outputStart, beforeCount, after.slice(afterStart, afterEnd)]);
}
const diffArray = (before: JsonValue[], after: JsonValue[], path: Path, operations: Op[]): void => {
if (before === after || equalJson(before, after)) return;
if (
before.length === after.length &&
before.length > 1 &&
!sameValue(before[0]!, after[0]!) &&
!sameValue(before[before.length - 1]!, after[after.length - 1]!)
) {
const order = permutation(before, after);
if (order !== undefined) {
emitOperation(operations, ["m", path, order]);
return;
}
}
diffArrayRegion(before, after, path, operations, 0, before.length, 0, after.length, 0);
};
const diffObject = (
before: Record<string, JsonValue>,
after: Record<string, JsonValue>,
path: Path,
operations: Op[],
): void => {
const beforeKeys = Object.keys(before);
const afterKeys = Object.keys(after);
if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) {
if (!equalJson(before, after)) emitSet(path, after, operations);
return;
}
for (const key of afterKeys) {
if (overflowedBatches.has(operations)) return;
if (Object.hasOwn(before, key)) diffValue(before[key]!, after[key]!, [...path, key], operations);
else emitSet([...path, key], after[key]!, operations);
}
for (const key of beforeKeys) {
if (overflowedBatches.has(operations)) return;
if (!Object.hasOwn(after, key)) emitOperation(operations, ["d", [...path, key] as unknown as NonEmptyPath]);
}
};
const diffValue = (before: JsonValue, after: JsonValue, path: Path, operations: Op[]): void => {
if (before === after || overflowedBatches.has(operations)) return;
if (typeof before === "string" && typeof after === "string" && path.length > 0) {
emitString(before, after, path as NonEmptyPath, operations);
return;
}
if (Array.isArray(before) && Array.isArray(after)) {
diffArray(before, after, path, operations);
return;
}
if (isContainer(before) && isContainer(after) && !Array.isArray(before) && !Array.isArray(after)) {
diffObject(before, after, path, operations);
return;
}
emitSet(path, after, operations);
};
const jsonCost = (value: JsonValue): number => {
if (value === null) return 4;
if (typeof value === "string") return value.length + 2;
if (typeof value === "number") return String(value).length;
if (typeof value === "boolean") return value ? 4 : 5;
if (Array.isArray(value)) {
let cost = 2;
for (let index = 0; index < value.length; index++) cost += jsonCost(value[index]!) + (index === 0 ? 0 : 1);
return cost;
}
let cost = 2;
let index = 0;
for (const key of Object.keys(value)) {
cost += key.length + 3 + jsonCost(value[key]!) + (index++ === 0 ? 0 : 1);
}
return cost;
};
const pathCost = (path: Path): number => {
let cost = 2;
for (let index = 0; index < path.length; index++) {
const segment: Seg = path[index]!;
cost += (typeof segment === "string" ? segment.length + 2 : String(segment).length) + (index === 0 ? 0 : 1);
}
return cost;
};
const operationCost = (operation: Op): number => {
switch (operation[0]) {
case "r":
return 6 + jsonCost(operation[1]);
case "s":
return 7 + pathCost(operation[1]) + jsonCost(operation[2]);
case "d":
return 6 + pathCost(operation[1]);
case "a":
return 7 + pathCost(operation[1]) + operation[2].length + 2;
case "t":
return 7 + pathCost(operation[1]) + String(operation[2]).length;
case "p":
return (
10 +
pathCost(operation[1]) +
String(operation[2]).length +
String(operation[3]).length +
jsonCost(operation[4])
);
case "m":
return 7 + pathCost(operation[1]) + jsonCost(operation[2]);
}
};
/** Compute a compact operation batch from two immutable JSON revisions. */
export function diffRevisions(before: JsonValue, after: JsonValue): Op[] {
const operations: Op[] = [];
diffValue(before, after, [], operations);
if (overflowedBatches.has(operations)) return [["r", after]];
if (operations.length === 0 || operations[0]?.[0] === "r") return operations;
let deltaCost = 2;
for (const operation of operations) deltaCost += operationCost(operation) + 1;
if (deltaCost < 65_536) return operations;
const snapshotCost = jsonCost(after) + 6;
return deltaCost >= snapshotCost ? [["r", after]] : operations;
}

View File

@@ -0,0 +1,10 @@
/** A mutable transaction-scoped view of a JSON value, preserving tuple positions. */
export type Draft<T, Depth extends readonly unknown[] = []> = Depth["length"] extends 8
? T
: T extends null | boolean | number | string
? T
: T extends (...args: never[]) => unknown
? T
: T extends object
? { -readonly [Key in keyof T]: Draft<T[Key], [...Depth, unknown]> }
: T;

View File

@@ -1,9 +1,11 @@
import type { JsonValue } from "../types.ts";
import type { JsonValue } from "../json.ts";
export type { JsonValue } from "../types.ts";
export type { JsonValue } from "../json.ts";
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
// ─────────────────────────────────────────────────────────────────────────────
// chord/delta — flush-time change tracking over plain JSON.
// chord/delta — immutable revision tracking and operations over plain JSON.
//
// Depends on nothing else in the harness. Session storage, the runtime and the
// facet host consume it; keep the arrows pointing that way.
@@ -20,9 +22,9 @@ export type PathRef<P extends Path = Path> = P | number;
* Tuples are the form — in memory, on the wire, on disk.
*
* `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target
* the root: the type forbids it. `p` may, and only because a tracked value can
* itself be an array — but a `p` that replaces its entire target is normalised to
* `r`/`s` at flush time, so a root `p` is always a partial modification.
* the root: the type forbids it. `p` and `m` may, because a tracked value can
* itself be an array. Operation shape is not canonical: an array may be emptied by either
* a replacement or a root splice.
*
* `Op` knows nothing about the path dictionary. Interning, id references and
* omitted paths live in `WireOp` and exist only between `encode` and `decode`.
@@ -33,7 +35,9 @@ export type Op =
| readonly ["d", NonEmptyPath]
| readonly ["a", NonEmptyPath, string]
| readonly ["t", NonEmptyPath, number]
| readonly ["p", Path, number, number, JsonValue[]];
| readonly ["p", Path, number, number, JsonValue[]]
/** Reorder an array in place: `new[i] = old[permutation[i]]`. */
| readonly ["m", Path, number[]];
/**
* What crosses a boundary. Adds two compressions and nothing else:
@@ -57,6 +61,8 @@ export type WireOp =
| readonly ["t", number]
| readonly ["p", PathRef, number, number, JsonValue[]]
| readonly ["p", number, number, JsonValue[]]
| readonly ["m", PathRef, number[]]
| readonly ["m", number[]]
| readonly ["#", number, Path];
// ─── Classification ──────────────────────────────────────────────────────────
@@ -103,650 +109,11 @@ export function overlap(a: string, b: string, scan: number, probe = 64, maxCandi
return 0;
}
// ─── Tracker ─────────────────────────────────────────────────────────────────
// ─── Immutable revision tracking ─────────────────────────────────────────────
export interface TrackerOptions {
maxOverlapScan?: number;
}
export interface Tracker<T extends object> {
/**
* The tracked value. Mutate and read state only through this proxy. Values
* inserted into it are adopted: callers may retain read-only references, but
* must not mutate them outside this proxy.
*/
state: T;
/** The untracked current value. Mutating it bypasses change tracking. */
readonly target: T;
flush(): Op[];
/** Make the next flush a complete base batch without changing the value. */
rebase(): void;
/** Accept pending mutations locally without emitting them. */
discard(): void;
readonly dirty: boolean;
}
const isObj = (value: unknown): value is object => value !== null && typeof value === "object";
const cloneJson = <T extends JsonValue>(value: T): T => {
if (!isObj(value)) return value;
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const [key, child] of Object.entries(value)) {
Object.defineProperty(result, key, {
value: cloneJson(child),
writable: true,
enumerable: true,
configurable: true,
});
}
return result as T;
};
const INDEX = /^(?:0|[1-9]\d*)$/;
const norm = (target: object, key: string | symbol): Seg | symbol =>
typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key;
const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
const MISSING = Symbol("missing");
type MaybeJson = JsonValue | typeof MISSING;
type ArrayDirty = { kind: "append"; start: number } | { kind: "diff" } | { kind: "replace" };
type DirtyNode = { valueDirty?: true; array?: ArrayDirty; children: Map<Seg, DirtyNode> };
const dirtyNode = (): DirtyNode => ({ children: new Map() });
const spliceItems = (target: unknown[], index: number, remove: number, items: JsonValue[]): JsonValue[] => {
const removed = Reflect.apply(Array.prototype.splice, target, [index, remove]) as JsonValue[];
const chunkSize = 10_000;
for (let offset = 0; offset < items.length; offset += chunkSize) {
Reflect.apply(Array.prototype.splice, target, [index + offset, 0, ...items.slice(offset, offset + chunkSize)]);
}
return removed;
};
const jsonEqual = (left: JsonValue, right: JsonValue): boolean => {
if (left === right) return true;
if (!isObj(left) || !isObj(right) || Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index++) {
if (!jsonEqual(left[index]!, right[index]!)) return false;
}
return true;
}
const leftObject = left as Record<string, JsonValue>;
const rightObject = right as Record<string, JsonValue>;
const leftKeys = Object.keys(leftObject);
const rightKeys = Object.keys(rightObject);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!Object.hasOwn(rightObject, key) || !jsonEqual(leftObject[key]!, rightObject[key]!)) return false;
}
return true;
};
const ownValue = (value: JsonValue, segment: Seg): MaybeJson => {
if (!isObj(value) || !Object.hasOwn(value, segment)) return MISSING;
return (value as Record<Seg, JsonValue>)[segment]!;
};
const emitSet = (path: Path, value: JsonValue, out: Op[]): void => {
const snapshot = cloneJson(value);
if (path.length === 0) out.push(["r", snapshot]);
else out.push(["s", [...path] as unknown as NonEmptyPath, snapshot]);
};
const emitDelete = (path: Path, out: Op[]): void => {
if (path.length === 0) throw new TypeError("the tracked root cannot be deleted");
out.push(["d", [...path] as unknown as NonEmptyPath]);
};
const diffString = (before: string, after: string, path: Path, scan: number, out: Op[]): void => {
if (before === after) return;
if (path.length === 0) {
emitSet(path, after, out);
return;
}
const at = [...path] as unknown as NonEmptyPath;
// NOT `after.startsWith(before)`. `after` is usually a cons string — the
// producer just did `s += chunk` — and V8's startsWith walks a cons char by
// char. `slice(...) === before` flattens once and compares with memcmp.
// Measured on a 200 KB string growing by 8 bytes per flush: 845 us -> 42 us.
if (after.length > before.length && after.slice(0, before.length) === before) {
out.push(["a", at, after.slice(before.length)]);
return;
}
const shared = overlap(before, after, scan);
if (shared === 0) {
out.push(["s", at, after]);
return;
}
out.push(["t", at, before.length - shared]);
if (after.length > shared) out.push(["a", at, after.slice(shared)]);
};
const diffValue = (before: MaybeJson, after: MaybeJson, path: Path, scan: number, out: Op[]): void => {
if (before === MISSING) {
if (after !== MISSING) emitSet(path, after, out);
return;
}
if (after === MISSING) {
emitDelete(path, out);
return;
}
if (before === after) return;
if (typeof before === "string" && typeof after === "string") {
diffString(before, after, path, scan, out);
return;
}
if (Array.isArray(before) && Array.isArray(after)) {
diffArray(before, after, path, scan, out);
return;
}
if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) {
diffObject(before as Record<string, JsonValue>, after as Record<string, JsonValue>, path, scan, out);
return;
}
emitSet(path, after, out);
};
function diffObject(
before: Record<string, JsonValue>,
after: Record<string, JsonValue>,
path: Path,
scan: number,
out: Op[],
): void {
if ([...Object.keys(before), ...Object.keys(after)].some((key) => RESERVED_SEGMENTS.has(key))) {
emitSet(path, after, out);
return;
}
for (const key of Object.keys(after)) {
diffValue(Object.hasOwn(before, key) ? before[key]! : MISSING, after[key]!, [...path, key], scan, out);
}
for (const key of Object.keys(before)) {
if (!Object.hasOwn(after, key)) emitDelete([...path, key], out);
}
}
function diffArray(before: JsonValue[], after: JsonValue[], path: Path, scan: number, out: Op[]): void {
if (before.length === after.length) {
for (let index = 0; index < after.length; index++) {
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
}
return;
}
let prefix = 0;
while (prefix < before.length && prefix < after.length && jsonEqual(before[prefix]!, after[prefix]!)) prefix++;
let suffix = 0;
while (
suffix < before.length - prefix &&
suffix < after.length - prefix &&
jsonEqual(before[before.length - 1 - suffix]!, after[after.length - 1 - suffix]!)
) {
suffix++;
}
const shorter = Math.min(before.length, after.length);
if (prefix + suffix === shorter) {
const remove = before.length - prefix - suffix;
const items = after.slice(prefix, after.length - suffix);
if (prefix === 0 && remove === before.length) emitSet(path, after, out);
else out.push(["p", [...path], prefix, remove, cloneJson(items)]);
return;
}
// Structural movement combined with retained-index edits has no unique
// alignment. Preserve the retained index deltas and express only the tail
// length change structurally. It may be broader than the producer's intent,
// but never degrades those edits to a whole-array replacement.
for (let index = 0; index < shorter; index++) {
diffValue(before[index]!, after[index]!, [...path, index], scan, out);
}
if (after.length > before.length) {
out.push(["p", [...path], before.length, 0, cloneJson(after.slice(before.length))]);
} else if (before.length > after.length) {
if (after.length === 0) emitSet(path, after, out);
else out.push(["p", [...path], after.length, before.length - after.length, []]);
}
}
const walkDirty = (before: JsonValue, after: JsonValue, node: DirtyNode, path: Path, scan: number, out: Op[]): void => {
if (node.valueDirty) {
diffValue(before, after, path, scan, out);
return;
}
if (node.array !== undefined) {
if (node.array.kind === "replace") {
if (!jsonEqual(before, after)) emitSet(path, after, out);
return;
}
if (!Array.isArray(before) || !Array.isArray(after) || node.array.kind === "diff") {
diffValue(before, after, path, scan, out);
return;
}
const start = node.array.start;
if (before.length !== start || after.length < start) {
diffValue(before, after, path, scan, out);
return;
}
for (const [segment, child] of node.children) {
if (typeof segment !== "number" || segment >= start) continue;
const previous = ownValue(before, segment);
const current = ownValue(after, segment);
if (previous === MISSING || current === MISSING || child.valueDirty) {
diffValue(previous, current, [...path, segment], scan, out);
} else if (isObj(previous) && isObj(current)) {
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
} else {
diffValue(previous, current, [...path, segment], scan, out);
}
}
const items = after.slice(start);
if (items.length > 0) out.push(["p", [...path], start, 0, cloneJson(items)]);
return;
}
for (const [segment, child] of node.children) {
const previous = ownValue(before, segment);
const current = ownValue(after, segment);
if (previous === MISSING || current === MISSING || child.valueDirty) {
diffValue(previous, current, [...path, segment], scan, out);
continue;
}
if (!isObj(previous) || !isObj(current)) {
diffValue(previous, current, [...path, segment], scan, out);
continue;
}
walkDirty(previous as JsonValue, current as JsonValue, child, [...path, segment], scan, out);
}
};
/**
* Bring `baseline` up to `root` along the dirty paths by sharing references.
* Returns false — having changed nothing — if any dirty node is an array
* change other than a pure append, so the caller can replay ops instead.
* Cloning a whole array there is O(n) per flush; replay is O(changes).
*
* Strings are immutable, so root's `after` is shared outright, and sharing it
* also means the next flush compares against a flat string rather than a cons.
* Objects are cloned because root keeps mutating them.
*/
const syncBaseline = (baseline: JsonValue, root: JsonValue, node: DirtyNode): boolean => {
if (!canSync(node)) return false;
syncInto(baseline, root, node);
return true;
};
const canSync = (node: DirtyNode): boolean => {
if (node.array !== undefined && node.array.kind !== "append") return false;
for (const child of node.children.values()) if (!canSync(child)) return false;
return true;
};
const syncInto = (baseline: JsonValue, root: JsonValue, node: DirtyNode): void => {
const parent = baseline as Record<string | number, JsonValue>;
if (node.array?.kind === "append" && Array.isArray(baseline) && Array.isArray(root)) {
const start = node.array.start;
for (const [index, child] of node.children) {
if (typeof index === "number" && index < start) syncChild(parent, root, index, child);
}
for (let i = start; i < root.length; i++) {
baseline.push(isObj(root[i]) ? cloneJson(root[i] as JsonValue) : (root[i] as JsonValue));
}
return;
}
for (const [segment, child] of node.children) syncChild(parent, root, segment, child);
};
const syncChild = (
parent: Record<string | number, JsonValue>,
root: JsonValue,
segment: Seg,
child: DirtyNode,
): void => {
const current = ownValue(root, segment);
const previous = ownValue(parent as JsonValue, segment);
if (current === MISSING) {
if (Array.isArray(parent)) parent.splice(segment as number, 1);
else delete parent[segment];
return;
}
if (child.valueDirty || !isObj(current) || !isObj(previous) || Array.isArray(current) !== Array.isArray(previous)) {
parent[segment] = isObj(current) ? cloneJson(current as JsonValue) : (current as JsonValue);
return;
}
syncInto(previous as JsonValue, current as JsonValue, child);
};
const cloneOp = (op: Op): Op => {
switch (op[0]) {
case "r":
return ["r", cloneJson(op[1])];
case "s":
return ["s", op[1], cloneJson(op[2])];
case "p":
return ["p", op[1], op[2], op[3], cloneJson(op[4])];
default:
return op;
}
};
export function track<T extends object>(root: T, options: TrackerOptions = {}): Tracker<T> {
const scan = options.maxOverlapScan ?? 65_536;
let pending = dirtyNode();
let hasPending = false;
let baseline: JsonValue | undefined;
let forceBase = true;
const clearPending = (): void => {
pending = dirtyNode();
hasPending = false;
};
const ensureNode = (path: Path): DirtyNode | undefined => {
hasPending = true;
let node = pending;
for (const segment of path) {
if (node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") return undefined;
let child = node.children.get(segment);
if (child === undefined) child = dirtyNode();
else node.children.delete(segment);
node.children.set(segment, child);
node = child;
}
return node;
};
const findNode = (path: Path): DirtyNode | undefined => {
let node = pending;
for (const segment of path) {
const child = node.children.get(segment);
if (child === undefined) return undefined;
node = child;
}
return node;
};
const markValue = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined) return;
node.valueDirty = true;
node.array = undefined;
node.children.clear();
};
const markArrayAppend = (path: Path, start: number): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") {
return;
}
if (node.array === undefined) node.array = { kind: "append", start };
};
const markArrayDiff = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty || node.array?.kind === "replace") return;
node.array = { kind: "diff" };
node.children.clear();
};
const markArrayReplace = (path: Path): void => {
const node = ensureNode(path);
if (node === undefined || node.valueDirty) return;
node.array = { kind: "replace" };
node.children.clear();
};
const appendStart = (path: Path): number | undefined => {
const array = findNode(path)?.array;
return array?.kind === "append" ? array.start : undefined;
};
const guard = (segment: Seg | symbol): Seg => {
if (typeof segment === "symbol") throw new UnsafePathError(String(segment));
if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) throw new UnsafePathError(segment);
return segment;
};
const adoptItems = (values: readonly unknown[]): JsonValue[] => values as JsonValue[];
const integer = (value: unknown): number => {
const number = Number(value);
if (Number.isNaN(number) || number === 0) return 0;
return Number.isFinite(number) ? Math.trunc(number) : number;
};
const spliceRange = (length: number, args: readonly unknown[]): { index: number; remove: number } => {
const rawStart = args.length === 0 ? 0 : integer(args[0]);
const index = rawStart < 0 ? Math.max(0, length + rawStart) : Math.min(rawStart, length);
const remove =
args.length === 0
? 0
: args.length === 1
? length - index
: Math.max(0, Math.min(integer(args[1]), length - index));
return { index, remove };
};
const wrap = <V extends object>(object: V, path: Path, blockedSegment?: Seg): V => {
const childProxies = new Map<string | symbol, { target: object; proxy: object }>();
const proxy = new Proxy(object, {
get(target, key, receiver) {
if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) {
return (...args: unknown[]) => {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
const before = target.length;
let result: unknown;
switch (key) {
case "push": {
const items = adoptItems(args);
if (items.length > 0) markArrayAppend(path, before);
spliceItems(target, before, 0, items);
result = target.length;
break;
}
case "unshift": {
const items = adoptItems(args);
if (items.length > 0) markArrayDiff(path);
spliceItems(target, 0, 0, items);
result = target.length;
break;
}
case "pop":
if (before > 0) {
const start = appendStart(path);
if (start === undefined || before - 1 < start) markArrayDiff(path);
}
result = Reflect.apply(Array.prototype.pop, target, args);
break;
case "shift":
if (before > 0) markArrayDiff(path);
result = Reflect.apply(Array.prototype.shift, target, args);
break;
case "splice": {
const items = adoptItems(args.slice(2));
const { index, remove } = spliceRange(before, args);
if (remove > 0 || items.length > 0) {
const start = appendStart(path);
if (index === 0 && remove === before) markArrayReplace(path);
else if (start !== undefined && index >= start) {
// The final append payload includes all tail edits.
} else if (index === before && remove === 0) markArrayAppend(path, before);
else markArrayDiff(path);
}
result = spliceItems(target, index, remove, items);
break;
}
default:
markArrayDiff(path);
result = Reflect.apply(Array.prototype[key as "sort"], target, args);
}
if (key === "pop") childProxies.delete(String(before - 1));
else if (key !== "push") childProxies.clear();
return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? proxy : result;
};
}
const value = Reflect.get(target, key, receiver);
if (!isObj(value)) return value;
const cached = childProxies.get(key);
if (cached?.target === value) return cached.proxy;
const rawSegment = norm(target, key);
let segment: Seg;
let childBlocked = blockedSegment;
if (blockedSegment !== undefined) {
if (typeof rawSegment === "symbol") throw new UnsafePathError(String(rawSegment));
segment = rawSegment;
} else if (
typeof rawSegment === "string" &&
RESERVED_SEGMENTS.has(rawSegment) &&
Object.hasOwn(target, key)
) {
segment = rawSegment;
childBlocked = rawSegment;
} else segment = guard(rawSegment);
const child = wrap(value, [...path, segment], childBlocked);
childProxies.set(key, { target: value, proxy: child });
return child;
},
set(target, key, value) {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
if (Array.isArray(target) && key === "length") {
const before = target.length;
const next = Number(value);
if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) {
return Reflect.set(target, key, value);
}
if (next < before) {
const start = appendStart(path);
if (next === 0) markArrayReplace(path);
else if (start === undefined || next < start) markArrayDiff(path);
Reflect.set(target, key, next);
childProxies.clear();
} else if (next > before) {
markArrayAppend(path, before);
target.length = next;
target.fill(null, before);
}
return true;
}
const segment = guard(norm(target, key));
if (Array.isArray(target)) {
if (typeof segment !== "number") throw new UnsafePathError(segment);
if (segment > target.length) throw new UnsafePathError(segment);
}
const at = [...path, segment] as unknown as NonEmptyPath;
if (value === undefined) {
if (Array.isArray(target)) {
throw new TypeError("undefined would create a sparse array; use splice instead");
}
markValue(at);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
}
const previous = (target as Record<string | symbol, unknown>)[key];
if (previous === value) return true;
const cached = childProxies.get(key);
if (cached !== undefined && cached.target === previous && cached.proxy === value) return true;
if (Array.isArray(target)) {
const index = segment as number;
if (index === target.length) markArrayAppend(path, target.length);
else {
const start = appendStart(path);
if (start === undefined || index < start) markValue(at);
}
} else markValue(at);
childProxies.delete(key);
return Reflect.set(target, key, value);
},
deleteProperty(target, key) {
if (blockedSegment !== undefined) throw new UnsafePathError(blockedSegment);
const segment = guard(norm(target, key));
if (Array.isArray(target)) {
if (typeof segment !== "number") throw new UnsafePathError(segment);
throw new TypeError("delete would create a sparse array; use splice instead");
}
markValue([...path, segment]);
childProxies.delete(key);
return Reflect.deleteProperty(target, key);
},
defineProperty() {
throw new TypeError("defineProperty is not supported on tracked state; use assignment");
},
setPrototypeOf() {
throw new TypeError("setPrototypeOf is not supported on tracked state");
},
preventExtensions() {
throw new TypeError("preventExtensions is not supported on tracked state");
},
});
return proxy as V;
};
let state = wrap(root, []);
return {
get state() {
return state;
},
get target() {
return root;
},
set state(next: T) {
if (next === state) {
clearPending();
forceBase = true;
return;
}
clearPending();
root = next;
state = wrap(root, []);
baseline = undefined;
forceBase = true;
},
rebase() {
clearPending();
forceBase = true;
},
get dirty() {
return forceBase || hasPending;
},
discard() {
baseline = cloneJson(root as unknown as JsonValue);
clearPending();
},
flush() {
if (forceBase) {
const value = cloneJson(root as unknown as JsonValue);
baseline = cloneJson(root as unknown as JsonValue);
forceBase = false;
clearPending();
return [["r", value]];
}
if (!hasPending || baseline === undefined) return [];
const out: Op[] = [];
walkDirty(baseline, root as unknown as JsonValue, pending, [], scan, out);
// Advance the baseline by SHARING references from root where that is
// cheap and exact — scalars, strings, and array appends. Replaying the
// ops rebuilds every touched string via slice + concat: two window-sized
// allocations per flush and a cons the next flush must flatten. For
// anything the sync cannot express cheaply (a non-append array change),
// it declines and the original replay runs unchanged.
if (!syncBaseline(baseline as JsonValue, root as unknown as JsonValue, pending)) {
if (out.length > 0) baseline = apply(baseline, out.map(cloneOp));
}
clearPending();
return out;
},
};
}
export { diffRevisions } from "./diff.ts";
export type { Draft } from "./draft.ts";
export { type Change, type Prepared, type Tracker, track } from "./tracker.ts";
// ─── Path safety ─────────────────────────────────────────────────────────────
@@ -812,6 +179,11 @@ export function assertValidOp(op: unknown): asserts op is Op {
if (!Array.isArray(op[4])) throw new TypeError("p items");
return;
}
case "m":
if (op.length !== 3) throw new TypeError("m arity");
assertPathArg(op[1]);
assertPermutation(op[2]);
return;
// Silently skipping an unknown verb is how a newer producer's op vanishes.
default:
throw new TypeError(`unknown op verb: ${String(op[0])}`);
@@ -824,6 +196,17 @@ function assertPathArg(p: unknown, nonEmpty = false): void {
assertSafePath(p as Path);
}
function assertPermutation(value: unknown): asserts value is number[] {
if (!Array.isArray(value)) throw new TypeError("m permutation is not an array");
const seen = new Uint8Array(value.length);
for (const index of value) {
if (!Number.isInteger(index) || index < 0 || index >= value.length || seen[index] !== 0) {
throw new TypeError("m permutation is not a bijection");
}
seen[index] = 1;
}
}
/** The same, for the wire grammar: ids and short forms are legal here. */
export function assertValidWireOp(op: unknown): asserts op is WireOp {
if (!Array.isArray(op) || op.length === 0) throw new TypeError("op is not a tuple");
@@ -875,6 +258,11 @@ export function assertValidWireOp(op: unknown): asserts op is WireOp {
if (!Array.isArray(items)) throw new TypeError("p items");
return;
}
case "m":
if (op.length === 3) okRef(op[1]);
else if (op.length !== 2) throw new TypeError("m arity");
assertPermutation(op[op.length - 1]);
return;
case "#": {
if (op.length !== 3 || !Number.isInteger(op[1]) || (op[1] as number) < 0 || !Array.isArray(op[2])) {
throw new TypeError("# shape");
@@ -969,6 +357,13 @@ function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
}
continue;
}
if (op[0] === "m") {
const target_ = path.length === 0 ? root : resolve(root, path);
if (!Array.isArray(target_) || target_.length !== op[2].length) throw new PathError(path);
const previous = target_.slice();
for (let index = 0; index < op[2].length; index++) target_[index] = previous[op[2][index]!]!;
continue;
}
// s/d/a/t can never target the root — the type forbids it.
const parent = resolve(root, path.slice(0, -1)) as Record<Seg, JsonValue>;
@@ -1010,22 +405,74 @@ function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
return root as unknown as T;
}
/** Apply decoded operations without mutating the previous immutable value. */
/** Apply one decoded operation batch without mutating the previous immutable value. */
export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {
return applyImmutableBatches(target, [ops]);
}
/**
* Apply decoded operation batches as one final-result-only replay.
*
* Containers copied for an earlier batch may be mutated while applying a later
* batch, so this deliberately exposes no intermediate revisions.
*/
export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T {
let root = target as unknown as JsonValue;
for (const op of ops) {
if (op[0] === "r") {
const owned = new WeakSet<object>();
for (const ops of batches) {
for (const op of ops) {
assertValidOp(op);
root = op[1];
continue;
if (op[0] === "r") {
root = op[1];
continue;
}
if (op[0] === "p") {
const appended = appendCopy(root, op, owned);
if (appended !== undefined) {
root = appended;
continue;
}
}
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned);
root = applyOps(root, [op]);
}
root = copyContainers(root, op[0] === "p" ? op[1] : op[1].slice(0, -1));
root = applyOps(root, [op]);
}
return root as unknown as T;
}
function copyContainers(root: JsonValue, path: Path): JsonValue {
/**
* An append to an array not yet copied in this replay, built as one copy with the items (`concat`) instead of a copy that
* the insert then grows, which copies it again. `undefined` for any other splice and for any path that is not a plain
* walk over own properties, which takes the general path and so fails with its errors.
*/
function appendCopy(root: JsonValue, op: Extract<Op, { 0: "p" }>, owned: WeakSet<object>): JsonValue | undefined {
const [, path, index, deleteCount, items] = op;
if (deleteCount !== 0) return undefined;
let target: JsonValue = root;
for (const segment of path) {
const valid =
typeof segment === "string"
? !RESERVED_SEGMENTS.has(segment) && isObj(target) && !Array.isArray(target)
: Number.isInteger(segment) && segment >= 0 && Array.isArray(target);
if (!valid || !Object.hasOwn(target as object, segment)) return undefined;
target = (target as Record<Seg, JsonValue>)[segment]!;
}
if (!Array.isArray(target) || owned.has(target) || index !== target.length) return undefined;
const appended = target.concat(items);
owned.add(appended);
if (path.length === 0) return appended;
const parentPath = path.slice(0, -1);
const copiedRoot = copyContainers(root, parentPath, owned);
Object.defineProperty(resolve(copiedRoot, parentPath), path[path.length - 1]!, {
value: appended,
writable: true,
enumerable: true,
configurable: true,
});
return copiedRoot;
}
function copyContainers(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
if (Array.isArray(value)) return value.slice();
if (!isObj(value)) throw new PathError(path);
@@ -1043,13 +490,23 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
}
return result;
};
const copiedRoot = copy(root);
let source = root;
if (!isObj(root)) throw new PathError(path);
let copiedRoot: JsonValue[] | Record<string, JsonValue>;
if (owned.has(root)) copiedRoot = root as JsonValue[] | Record<string, JsonValue>;
else {
copiedRoot = copy(root);
owned.add(copiedRoot);
}
let destination = copiedRoot;
for (const segment of path) {
if (!isObj(source) || !Object.hasOwn(source, segment)) throw new PathError(path);
if (Array.isArray(source) && typeof segment !== "number") throw new UnsafePathError(segment);
const child = (source as Record<Seg, JsonValue>)[segment]!;
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
const child = (destination as Record<Seg, JsonValue>)[segment]!;
if (!isObj(child)) throw new PathError(path);
if (owned.has(child)) {
destination = child as JsonValue[] | Record<string, JsonValue>;
continue;
}
const copiedChild = copy(child);
Object.defineProperty(destination, segment, {
value: copiedChild,
@@ -1057,7 +514,7 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
enumerable: true,
configurable: true,
});
source = child;
owned.add(copiedChild);
destination = copiedChild;
}
return copiedRoot;
@@ -1087,6 +544,10 @@ function resolve(root: JsonValue, path: Path): JsonValue {
// Path interning and arity omission live between the tracker and a boundary;
// `Op` and `apply` know nothing about them.
//
// Consecutive batches of one encoder may be concatenated into one batch (links
// coalesce waiting state this way): only path ids span batches, and every batch
// starts with an explicit path.
//
// ONE PAIR PER INDEPENDENT STATE STREAM. Every decoder must observe exactly the
// batches encoded by its matching encoder, beginning with that state's base.
// Sharing a transport connection does not make separately hydrated states one
@@ -1150,6 +611,9 @@ export function encoder(): Encoder {
case "p":
out.push(["p", op[2], op[3], op[4]]);
break;
case "m":
out.push(["m", op[2]]);
break;
}
continue;
}
@@ -1183,6 +647,9 @@ export function encoder(): Encoder {
case "p":
out.push(["p", ref, op[2], op[3], op[4]]);
break;
case "m":
out.push(["m", ref, op[2]]);
break;
}
previous = key;
}
@@ -1192,7 +659,8 @@ export function encoder(): Encoder {
}
export interface Decoder {
decode(wire: readonly WireOp[]): Op[];
/** Ops as received: each is validated (`assertValidWireOp`) before use. */
decode(wire: readonly unknown[]): Op[];
}
export function decoder(): Decoder {
@@ -1238,7 +706,7 @@ export function decoder(): Decoder {
previous = path;
}
if (op[0] !== "p" && path.length === 0) throw new PathError(path);
if (op[0] !== "p" && op[0] !== "m" && path.length === 0) throw new PathError(path);
switch (op[0]) {
case "s":
out.push(["s", path as NonEmptyPath, (short ? op[1] : op[2]) as JsonValue]);
@@ -1259,6 +727,9 @@ export function decoder(): Decoder {
out.push(["p", path, i, r, items]);
break;
}
case "m":
out.push(["m", path, (short ? op[1] : op[2]) as number[]]);
break;
}
}
return out;

View File

@@ -0,0 +1,75 @@
import type { JsonValue } from "../json.ts";
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
const isContainer = (value: JsonValue): value is JsonContainer => typeof value === "object" && value !== null;
/** Validates immutable replica revisions while skipping containers validated in earlier revisions. */
export class JsonRevisionValidator {
readonly #validated = new WeakSet<object>();
validate<T extends JsonValue>(value: T): T {
return this.#validate(value, new Set<object>(), new WeakSet<object>()) as T;
}
#validate(value: JsonValue, ancestors: Set<object>, finished: WeakSet<object>): JsonValue {
if (!isContainer(value) || this.#validated.has(value)) return isContainer(value) ? value : assertPrimitive(value);
if (finished.has(value)) return value;
if (ancestors.has(value)) throw new TypeError("Replicated state cannot contain cycles");
ancestors.add(value);
try {
if (Array.isArray(value)) {
assertDenseArray(value);
for (let index = 0; index < value.length; index++) this.#validate(value[index]!, ancestors, finished);
} else {
assertPlainObject(value);
for (const key of Reflect.ownKeys(value)) {
if (typeof key === "symbol") throw new TypeError("Replicated state cannot contain symbol properties");
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
throw new TypeError("Replicated state objects must contain enumerable data properties");
}
this.#validate(descriptor.value as JsonValue, ancestors, finished);
}
}
finished.add(value);
this.#validated.add(value);
return value;
} finally {
ancestors.delete(value);
}
}
}
function assertPrimitive(value: unknown): null | boolean | number | string {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
throw new TypeError("Replicated state values must be strict JSON");
}
function assertPlainObject(value: object): void {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("Replicated state containers must be plain objects or arrays");
}
}
function assertDenseArray(value: readonly unknown[]): void {
const keys = Reflect.ownKeys(value);
if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) {
throw new TypeError("Replicated state arrays must be dense and contain only indexed entries");
}
for (let index = 0; index < value.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (
descriptor === undefined ||
!descriptor.enumerable ||
!("value" in descriptor) ||
descriptor.value === undefined
) {
throw new TypeError(
"Replicated state arrays must contain enumerable indexed data properties with defined values",
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
/** Error codes callers see (guide table). A refused or aborted reload keeps the code of its cause. */
export const CHORD_ERROR_CODES = [
"aborted",
"contract_mismatch",
"forbidden",
"gone",
"invalid",
"not_in_variant",
"not_ready",
"released",
"superseded",
"unavailable",
"upgrade_conflict",
"upgrade_required",
] as const;
export type ChordErrorCode = (typeof CHORD_ERROR_CODES)[number];
/**
* Finer cause inside a code, for logs and tests: e.g. `invalid` + `args`, `result`, `state`, `input`, `implementation`,
* `duplicate`, `cycle`, `read_only`, `not_host_lifetime`, `registry_conflict`; `unavailable` + `unknown_type`.
*/
export type ChordErrorReason = string;
const RETRYABLE: ReadonlySet<ChordErrorCode> = new Set(["superseded", "upgrade_conflict"]);
export class ChordError extends Error {
readonly code: ChordErrorCode;
readonly reason: ChordErrorReason | undefined;
readonly retryable: boolean;
readonly details: unknown;
constructor(
code: ChordErrorCode,
message: string,
options?: { cause?: unknown; details?: unknown; reason?: ChordErrorReason },
) {
super(`${code}${options?.reason ? `/${options.reason}` : ""}: ${message}`, {
cause: options?.cause,
});
this.name = "ChordError";
this.code = code;
this.reason = options?.reason;
this.retryable = RETRYABLE.has(code);
this.details = options?.details;
}
}
export const isChordError = (error: unknown, code?: ChordErrorCode): error is ChordError =>
error instanceof ChordError && (code === undefined || error.code === code);
/** For factories and methods refusing a principal: `throw forbidden()`. */
export const forbidden = (message = "forbidden"): ChordError => new ChordError("forbidden", message);
export const invalid = (reason: ChordErrorReason, message: string, details?: unknown): ChordError =>
new ChordError("invalid", message, { reason, details });
/** Keeps a ChordError's code; anything else becomes `unavailable` with the original as cause. */
export const asChordError = (error: unknown, context: string): ChordError =>
error instanceof ChordError
? error
: new ChordError("unavailable", `${context}: ${error instanceof Error ? error.message : String(error)}`, {
cause: error,
});
/** Runs every step, then throws one error, or an AggregateError when several failed. */
export async function runAll(steps: readonly (() => unknown)[], message: string): Promise<void> {
const errors: unknown[] = [];
for (const step of steps) {
try {
await step();
} catch (error) {
errors.push(error);
}
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, message);
}

View File

@@ -1,906 +0,0 @@
import { BACKGROUND_CONTEXT } from "../context/index.ts";
import { RemoteServiceBindingImpl } from "../services/consumer.ts";
import { ServiceSlot } from "../services/handle.ts";
import { InstanceDirectory, type InstanceDirectoryEntry } from "../services/instances.ts";
import { createLoopbackServiceTransport } from "../services/loopback.ts";
import { RemoteServiceProvider, validateRemoteServiceImplementation } from "../services/provider.ts";
import { MutableReplicatedStateImpl } from "../services/state.ts";
import type {
Context,
Facet,
FacetEnvironment,
FacetOptions,
RemoteServiceContract,
RemoteServiceSource,
RemoteServices,
Service,
ServiceMode,
ServiceSpawner,
} from "../types.ts";
interface FacetServiceReference {
readonly serviceId: string;
readonly service: Service<unknown>;
readonly mode: ServiceMode;
}
interface ExternalService {
readonly service: Service<unknown>;
readonly mode: ServiceMode;
readonly source: RemoteServiceSource;
}
interface FacetShape {
facetId: string;
requires: readonly FacetServiceReference[];
provides: readonly FacetServiceReference[];
}
interface FacetRuntime extends FacetShape {
requires: FacetServiceReference[];
provides: FacetServiceReference[];
readonly lifecycle: FacetLifecycle;
readonly provisions: FacetProvision[];
readonly singletonViews: Map<string, unknown>;
}
type LifecycleState = "setting_up" | "prepared" | "active" | "disposing" | "dead";
type GenerationPhase =
| "setup"
| "assembling"
| "connecting"
| "activating"
| "active"
| "reloading"
| "disposing"
| "dead";
type Disposal = () => void | Promise<void>;
class FacetLifecycle {
readonly id: string;
readonly #effects: Disposal[] = [];
readonly #observations: Array<() => Disposal> = [];
readonly #activate: Array<() => void | Promise<void>> = [];
#state: LifecycleState = "setting_up";
#serviceAccess = false;
constructor(id: string) {
this.id = id;
}
assertSettingUp(operation: string): void {
if (this.#state !== "setting_up") {
throw new Error(`Facet ${this.id} can ${operation} only during setup`);
}
}
assertRunning(operation: string): void {
if (this.#state !== "setting_up" && this.#state !== "active") {
throw new Error(`Facet ${this.id} cannot ${operation} while ${this.#state}`);
}
}
assertActive(operation: string): void {
if (this.#state !== "active") throw new Error(`Facet ${this.id} can ${operation} only while active`);
}
assertServiceAccess(): void {
if (!this.#serviceAccess) {
throw new Error(`Facet ${this.id} service handles cannot be used while ${this.#state}`);
}
}
revoke(): void {
this.#serviceAccess = false;
}
own(disposal: Disposal): void {
this.assertRunning("own resources");
this.#effects.push(disposal);
}
observe(start: () => Disposal): void {
this.assertSettingUp("observe services");
this.#observations.push(start);
}
onActivate(callback: () => void | Promise<void>): void {
this.assertSettingUp("register activation callbacks");
this.#activate.push(callback);
}
prepared(): void {
this.assertSettingUp("finish setup");
this.#state = "prepared";
}
async activate(): Promise<void> {
if (this.#state !== "prepared") throw new Error(`Facet ${this.id} is not prepared`);
this.#state = "active";
this.#serviceAccess = true;
for (const start of this.#observations) this.#effects.push(start());
for (const callback of this.#activate) await callback();
}
async dispose(): Promise<void> {
if (this.#state === "dead") return;
this.#state = "disposing";
const errors: unknown[] = [];
for (const effect of this.#effects.splice(0).reverse()) {
try {
await effect();
} catch (error) {
errors.push(error);
}
}
this.#observations.length = 0;
this.#activate.length = 0;
this.#serviceAccess = false;
this.#state = "dead";
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, `Failed to dispose facet ${this.id}`);
}
}
interface KeyedServiceSource {
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
}
interface LocalKeyedRegistration {
readonly generations: Map<string, number>;
readonly directory: InstanceDirectory<InstanceDirectoryEntry>;
}
class LocalKeyedServiceRegistry implements KeyedServiceSource {
readonly #registrations = new Map<string, LocalKeyedRegistration>();
#disposed = false;
constructor(services: readonly { readonly id: string }[], onError: (error: Error) => void) {
const ids = services.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new TypeError("Local keyed service registry has duplicate IDs");
for (const serviceId of ids) {
this.#registrations.set(serviceId, {
generations: new Map(),
directory: new InstanceDirectory({ ready: true, onError }),
});
}
}
spawn<T>(service: Service<T>, key: string, implementation: T): () => void {
this.#assertActive();
if (key.length === 0) throw new TypeError("Local service instance key must not be empty");
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Local service ${service.id} implementation must be an object`);
}
const registration = this.#registration(service.id);
if (registration.directory.get(key) !== undefined) {
throw new Error(`Local service ${service.id} already has a live instance with key ${key}`);
}
const generation = (registration.generations.get(key) ?? 0) + 1;
registration.generations.set(key, generation);
const instance: InstanceDirectoryEntry = {
key,
generation,
service: implementation as object,
deactivate() {},
};
registration.directory.insert(instance);
let closed = false;
return () => {
if (closed) return;
closed = true;
registration.directory.remove(instance);
};
}
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
this.#assertActive();
return this.#registration(service.id).directory.observe(handler);
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
for (const registration of this.#registrations.values()) registration.directory.dispose();
this.#registrations.clear();
}
#registration(serviceId: string): LocalKeyedRegistration {
const registration = this.#registrations.get(serviceId);
if (registration === undefined) throw new Error(`Local keyed service ${serviceId} is not registered`);
return registration;
}
#assertActive(): void {
if (this.#disposed) throw new Error("Local keyed service registry is disposed");
}
}
class HostServiceSlots {
readonly #singletons = new Map<string, ServiceSlot>();
readonly #keyedSources = new Map<string, KeyedServiceSource>();
getSingleton<T>(service: Service<T>, assertAccess: () => void): T {
let slot = this.#singletons.get(service.id);
if (slot === undefined) {
slot = new ServiceSlot(service.id, !service.local);
this.#singletons.set(service.id, slot);
}
return slot.view<T>(assertAccess);
}
hasSingleton(serviceId: string): boolean {
return this.#singletons.has(serviceId);
}
observe<T>(
service: Service<T>,
assertAccess: () => void,
handler: (service: T, context: Context) => void | Promise<void>,
): Disposal {
const source = this.#keyedSources.get(service.id);
if (source === undefined) throw new Error(`Service ${service.id} is disconnected`);
let stopped = false;
const stop = source.observe(service, (target, context) => {
const slot = new ServiceSlot(service.id, !service.local);
slot.bind(target as object);
return handler(
slot.view<T>(() => {
assertAccess();
if (stopped || context.abortSignal?.aborted) {
throw new Error(`Keyed service ${service.id} observation is closed`);
}
}),
context,
);
});
return () => {
if (stopped) return;
stopped = true;
stop();
};
}
bindSingleton(serviceId: string, target: object): void {
this.#singletons.get(serviceId)?.bind(target);
}
bindKeyed(serviceId: string, services: KeyedServiceSource): void {
this.#keyedSources.set(serviceId, services);
}
dispose(): void {
for (const slot of this.#singletons.values()) slot.unbind();
this.#singletons.clear();
this.#keyedSources.clear();
}
}
type ServiceInstanceInstaller<T> = (key: string, implementation: T) => () => void;
interface StagedServiceInstance<T> {
readonly key: string;
readonly implementation: T;
release?: () => void;
}
class StagedServiceSpawner<T> implements ServiceSpawner<T> {
readonly #lifecycle: FacetLifecycle;
readonly #validate: (key: string, implementation: T) => void;
readonly #instances = new Map<string, StagedServiceInstance<T>>();
#installer: ServiceInstanceInstaller<T> | undefined;
constructor(lifecycle: FacetLifecycle, validate: (key: string, implementation: T) => void) {
this.#lifecycle = lifecycle;
this.#validate = validate;
}
connect(installer: ServiceInstanceInstaller<T>): void {
if (this.#installer !== undefined) throw new Error("Facet service provider is already connected");
this.#installer = installer;
for (const instance of this.#instances.values()) {
instance.release = installer(instance.key, instance.implementation);
}
}
spawn(key: string, implementation: T): () => void {
this.#lifecycle.assertActive("spawn service instances");
this.#validate(key, implementation);
if (this.#instances.has(key)) throw new Error(`Facet service already has a live instance with key ${key}`);
const instance: StagedServiceInstance<T> = { key, implementation };
this.#instances.set(key, instance);
if (this.#installer !== undefined) instance.release = this.#installer(key, implementation);
const close = (): void => {
if (this.#instances.get(key) !== instance) return;
this.#instances.delete(key);
instance.release?.();
};
this.#lifecycle.own(close);
return close;
}
}
type FacetProvision =
| {
readonly kind: "singleton";
readonly service: { readonly id: string; readonly local: boolean };
readonly implementation: object;
install(provider: RemoteServiceProvider): void;
validateReplacement(provider: RemoteServiceProvider): void;
replace(provider: RemoteServiceProvider): void;
}
| {
readonly kind: "keyed";
readonly service: { readonly id: string; readonly local: boolean };
connectLocal(registry: LocalKeyedServiceRegistry): void;
connectRemote(provider: RemoteServiceProvider): void;
};
/** Private lifecycle and dependency kernel behind the atomic host entry point. */
export class FacetKernel {
readonly #initialFacets: readonly Facet[];
readonly #serviceSources: readonly RemoteServiceSource[];
readonly #onError: (error: Error) => void;
readonly #facets = new Map<string, FacetRuntime>();
readonly #serviceSlots: HostServiceSlots;
readonly #sourceBindings = new Map<RemoteServiceSource, RemoteServices>();
#activationOrder: readonly string[] = [];
#provider: RemoteServiceProvider | undefined;
#internalServices: RemoteServices | undefined;
#localKeyedServices: LocalKeyedServiceRegistry | undefined;
#phase: GenerationPhase = "setup";
constructor(options: FacetOptions) {
const ids = options.facets.map((facet) => facet.id);
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty");
if (new Set(ids).size !== ids.length) throw new Error("Facet IDs must be unique within a generation");
this.#initialFacets = options.facets;
this.#serviceSources = options.serviceSources ?? [];
this.#onError = options.onError ?? (() => {});
this.#serviceSlots = new HostServiceSlots();
}
get provider(): RemoteServiceProvider {
if (this.#provider === undefined) throw new Error("Facet service provider is not assembled");
return this.#provider;
}
#createFacetRuntime(facetId: string): FacetRuntime {
return {
facetId,
requires: [],
provides: [],
lifecycle: new FacetLifecycle(facetId),
provisions: [],
singletonViews: new Map(),
};
}
#setupFacet(facet: Facet, record: FacetRuntime): void {
const result: unknown = facet.setup(this.#environment(record));
if (isPromiseLike(result)) {
void Promise.resolve(result).catch(() => {});
throw new Error(`Facet ${facet.id} setup must be synchronous`);
}
record.lifecycle.prepared();
}
async activate(): Promise<void> {
const records: FacetRuntime[] = [];
try {
for (const facet of this.#initialFacets) {
const record = this.#createFacetRuntime(facet.id);
this.#facets.set(facet.id, record);
this.#setupFacet(facet, record);
records.push(record);
}
this.#phase = "assembling";
const externalServices = await this.#resolveExternalServices(records);
this.#activationOrder = validateFacets(records, externalServices);
this.#assembleProviders();
this.#bindServices(externalServices);
this.#phase = "connecting";
await Promise.all(
[...this.#sourceBindings.values(), this.#internalServiceBinding].map((services) =>
services.ready(BACKGROUND_CONTEXT),
),
);
this.#phase = "activating";
for (const id of this.#activationOrder) await this.#facets.get(id)!.lifecycle.activate();
this.#phase = "active";
} catch (error) {
const cleanupErrors = await this.#terminate();
if (cleanupErrors.length > 0) {
throw new AggregateError([error, ...cleanupErrors], "Facet generation startup and cleanup failed");
}
throw error;
}
}
async reload(facets: readonly Facet[]): Promise<void> {
if (this.#phase !== "active") throw new Error(`Facet host cannot reload while ${this.#phase}`);
const ids = facets.map(({ id }) => id);
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty");
if (new Set(ids).size !== ids.length) throw new Error("Reloaded facet IDs must be unique");
for (const id of ids) {
if (!this.#facets.has(id)) throw new Error(`Facet ${id} is not active`);
}
this.#phase = "reloading";
const staged: FacetRuntime[] = [];
const candidates: FacetRuntime[] = [];
try {
for (const facet of facets) {
const record = this.#createFacetRuntime(facet.id);
staged.push(record);
this.#setupFacet(facet, record);
const previous = this.#facets.get(facet.id)!;
if (!sameFacetShape(previous, record)) {
throw new Error(`Reloaded facet ${facet.id} must preserve its service requirements and provisions`);
}
this.#validateReplacementProvisions(record.provisions);
candidates.push(record);
}
} catch (error) {
const cleanupErrors = await disposeFacetRecords(staged.reverse());
if (cleanupErrors.length > 0) {
const abortErrors = await this.#abort();
throw new AggregateError(
[error, ...cleanupErrors, ...abortErrors],
"Facet reload setup and cleanup failed",
);
}
this.#phase = "active";
throw error;
}
const replacements = new Map(candidates.map((record) => [record.facetId, record]));
const candidateOrder = this.#activationOrder.flatMap((id) => {
const candidate = replacements.get(id);
return candidate === undefined ? [] : [candidate];
});
try {
for (const candidate of candidateOrder) await candidate.lifecycle.activate();
for (const candidate of candidateOrder) this.#validateReplacementProvisions(candidate.provisions);
} catch (error) {
const cleanupErrors = await disposeFacetRecords([...candidateOrder].reverse());
if (cleanupErrors.length > 0) {
const abortErrors = await this.#abort();
throw new AggregateError(
[error, ...cleanupErrors, ...abortErrors],
"Facet reload activation and cleanup failed",
);
}
this.#phase = "active";
throw error;
}
const previous = candidateOrder.map(({ facetId }) => this.#facets.get(facetId)!);
for (const candidate of candidateOrder) this.#facets.set(candidate.facetId, candidate);
try {
for (const candidate of candidateOrder) {
for (const provision of candidate.provisions) {
if (provision.kind !== "singleton") continue;
if (provision.service.local) {
this.#serviceSlots.bindSingleton(provision.service.id, provision.implementation);
} else {
provision.replace(this.provider);
}
}
}
const retirementErrors = await disposeFacetRecords(previous.reverse());
if (retirementErrors.length === 1) throw retirementErrors[0];
if (retirementErrors.length > 1) {
throw new AggregateError(retirementErrors, "Failed to retire replaced facets");
}
for (const candidate of candidateOrder) {
for (const provision of candidate.provisions) {
if (provision.kind !== "keyed") continue;
if (provision.service.local) provision.connectLocal(this.#localKeyedRegistry);
else provision.connectRemote(this.provider);
}
}
} catch (error) {
const abortErrors = await this.#abort(previous);
throw new AggregateError([error, ...abortErrors], "Facet reload failed after cutover");
}
this.#phase = "active";
}
async dispose(): Promise<void> {
if (this.#phase === "dead") return;
if (this.#phase !== "active") throw new Error(`Facet host cannot be disposed while ${this.#phase}`);
const errors = await this.#terminate();
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet generation");
}
#validateReplacementProvisions(provisions: readonly FacetProvision[]): void {
for (const provision of provisions) {
if (provision.kind !== "singleton" || provision.service.local) continue;
provision.validateReplacement(this.provider);
}
}
#environment(runtime: FacetRuntime): FacetEnvironment {
const { lifecycle, provisions } = runtime;
return {
provide: <T>(service: Service<T>, implementation: NoInfer<T>): void => {
lifecycle.assertSettingUp("provide services");
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Service ${service.id} implementation must be an object`);
}
recordServiceReference(runtime.provides, service, "singleton");
provisions.push({
kind: "singleton",
service,
implementation,
install: (provider) => provider.provide(service, implementation as NoInfer<RemoteServiceContract<T>>),
validateReplacement: (provider) =>
provider.validateReplacement(service, implementation as NoInfer<RemoteServiceContract<T>>),
replace: (provider) => provider.replace(service, implementation as NoInfer<RemoteServiceContract<T>>),
});
},
provideMany: <T>(service: Service<T>): ServiceSpawner<T> => {
lifecycle.assertSettingUp("provide service instances");
recordServiceReference(runtime.provides, service, "keyed");
const instances = new StagedServiceSpawner<T>(lifecycle, (key, implementation) => {
if (key.length === 0) throw new TypeError("Facet service instance key must not be empty");
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Facet service ${service.id} implementation must be an object`);
}
if (!service.local) validateRemoteServiceImplementation(service.id, implementation);
});
provisions.push({
kind: "keyed",
service,
connectLocal: (registry) =>
instances.connect((key, implementation) => registry.spawn(service, key, implementation)),
connectRemote: (provider) =>
instances.connect((key, implementation) =>
provider.spawn(service, key, implementation as NoInfer<RemoteServiceContract<T>>),
),
});
return instances;
},
use: <T>(service: Service<T>): T => {
lifecycle.assertSettingUp("acquire services");
recordServiceReference(runtime.requires, service, "singleton");
let view = runtime.singletonViews.get(service.id);
if (view === undefined) {
view = this.#serviceSlots.getSingleton<T>(service, () => lifecycle.assertServiceAccess());
runtime.singletonViews.set(service.id, view);
}
return view as T;
},
observe: <T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): void => {
lifecycle.assertSettingUp("observe services");
recordServiceReference(runtime.requires, service, "keyed");
lifecycle.observe(() =>
this.#serviceSlots.observe(service, () => lifecycle.assertServiceAccess(), handler),
);
},
replicatedState: <T extends object>(initial: T) => {
lifecycle.assertRunning("create replicated state");
return new MutableReplicatedStateImpl(initial);
},
own: (disposal) => lifecycle.own(disposal),
onActivate: (callback) => lifecycle.onActivate(callback),
onDeactivate: (callback) => lifecycle.own(callback),
};
}
async #resolveExternalServices(records: readonly FacetShape[]): Promise<ReadonlyMap<string, ExternalService>> {
const catalogues = await Promise.all(
this.#serviceSources.map(async (source) => ({
source,
entries: await source.catalogue(BACKGROUND_CONTEXT),
})),
);
const offered = new Map<string, { readonly mode: ServiceMode; readonly source: RemoteServiceSource }>();
for (const { source, entries } of catalogues) {
for (const { serviceId, mode } of entries) {
if (offered.has(serviceId)) {
throw new Error(`Facet host service ${serviceId} is offered by more than one source`);
}
offered.set(serviceId, { mode, source });
}
}
const local = new Set(records.flatMap(({ provides }) => provides.map(({ serviceId }) => serviceId)));
const external = new Map<string, ExternalService>();
for (const { requires } of records) {
for (const requirement of requires) {
if (local.has(requirement.serviceId) || external.has(requirement.serviceId)) continue;
let source = offered.get(requirement.serviceId);
if (source === undefined) {
const deferred = this.#serviceSources.filter(
({ acceptsUnavailableServices }) => acceptsUnavailableServices,
);
if (deferred.length > 1) {
throw new Error(`Facet host service ${requirement.serviceId} has more than one deferred source`);
}
if (deferred.length === 1) source = { mode: requirement.mode, source: deferred[0]! };
}
if (source !== undefined) {
external.set(requirement.serviceId, { ...source, service: requirement.service });
}
}
}
const serviceIdsBySource = new Map<RemoteServiceSource, string[]>();
for (const [serviceId, { source }] of external) {
let serviceIds = serviceIdsBySource.get(source);
if (serviceIds === undefined) {
serviceIds = [];
serviceIdsBySource.set(source, serviceIds);
}
serviceIds.push(serviceId);
}
for (const [source, serviceIds] of serviceIdsBySource) {
this.#sourceBindings.set(
source,
source.open({
services: serviceIds.map((id) => ({ id })),
assertAccess: () => this.#assertServiceTargetAccess(),
onError: this.#onError,
}),
);
}
return external;
}
#assembleProviders(): void {
const provisions = this.#provisions();
const remoteProvisions = provisions.filter(({ service }) => !service.local);
const provider = new RemoteServiceProvider(
remoteProvisions.map(({ service, kind }) => ({ service, mode: kind })),
);
const internalServices = new RemoteServiceBindingImpl({
services: remoteProvisions.map(({ service }) => service),
transport: createLoopbackServiceTransport(provider),
assertAccess: () => this.#assertServiceTargetAccess(),
onError: this.#onError,
});
const localKeyedServices = new LocalKeyedServiceRegistry(
provisions.flatMap((provision) =>
provision.kind === "keyed" && provision.service.local ? [provision.service] : [],
),
this.#onError,
);
this.#provider = provider;
this.#internalServices = internalServices;
this.#localKeyedServices = localKeyedServices;
for (const provision of provisions) {
if (provision.kind === "singleton") {
if (!provision.service.local) provision.install(provider);
} else if (provision.service.local) {
provision.connectLocal(localKeyedServices);
} else {
provision.connectRemote(provider);
}
}
}
#bindServices(externalServices: ReadonlyMap<string, ExternalService>): void {
for (const provision of this.#provisions()) {
if (provision.kind === "singleton") {
if (!this.#serviceSlots.hasSingleton(provision.service.id)) continue;
const target = provision.service.local
? provision.implementation
: (this.#internalServiceBinding.use(provision.service) as object);
this.#serviceSlots.bindSingleton(provision.service.id, target);
} else {
this.#serviceSlots.bindKeyed(
provision.service.id,
provision.service.local ? this.#localKeyedRegistry : this.#internalServiceBinding,
);
}
}
for (const [serviceId, { service, mode, source }] of externalServices) {
const services = this.#sourceBindings.get(source);
if (services === undefined) throw new Error(`Service source for ${serviceId} is not open`);
if (mode === "singleton") {
this.#serviceSlots.bindSingleton(serviceId, services.use(service) as object);
} else {
this.#serviceSlots.bindKeyed(serviceId, services);
}
}
}
#provisions(): FacetProvision[] {
return [...this.#facets.values()].flatMap(({ provisions }) => provisions);
}
get #localKeyedRegistry(): LocalKeyedServiceRegistry {
if (this.#localKeyedServices === undefined) throw new Error("Facet keyed services are not assembled");
return this.#localKeyedServices;
}
get #internalServiceBinding(): RemoteServices {
if (this.#internalServices === undefined) throw new Error("Facet remote services are not assembled");
return this.#internalServices;
}
async #disposeServiceBindings(): Promise<unknown[]> {
const bindings = [...this.#sourceBindings.values()];
this.#sourceBindings.clear();
if (this.#internalServices !== undefined) bindings.push(this.#internalServices);
this.#internalServices = undefined;
const results = await Promise.allSettled(bindings.map((services) => services.dispose(BACKGROUND_CONTEXT)));
return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
}
#assertServiceTargetAccess(): void {
if (
this.#phase !== "activating" &&
this.#phase !== "active" &&
this.#phase !== "reloading" &&
this.#phase !== "disposing"
) {
throw new Error(`Facet service targets cannot be used during ${this.#phase}`);
}
}
async #abort(extraRecords: readonly FacetRuntime[] = []): Promise<unknown[]> {
for (const record of this.#facets.values()) record.lifecycle.revoke();
for (const record of extraRecords) record.lifecycle.revoke();
return this.#terminate(extraRecords);
}
async #terminate(extraRecords: readonly FacetRuntime[] = []): Promise<unknown[]> {
this.#phase = "disposing";
const errors = await this.#disposeLifecycles();
errors.push(...(await disposeFacetRecords([...extraRecords].reverse())));
try {
this.#localKeyedServices?.dispose();
} catch (error) {
errors.push(error);
}
this.#localKeyedServices = undefined;
errors.push(...(await this.#disposeServiceBindings()));
try {
this.#serviceSlots.dispose();
} catch (error) {
errors.push(error);
}
try {
this.#provider?.dispose();
} catch (error) {
errors.push(error);
}
this.#phase = "dead";
return errors;
}
async #disposeLifecycles(): Promise<unknown[]> {
const errors: unknown[] = [];
const order =
this.#activationOrder.length > 0 ? [...this.#activationOrder].reverse() : [...this.#facets.keys()].reverse();
for (const id of order) {
const record = this.#facets.get(id);
if (record === undefined) continue;
this.#facets.delete(id);
try {
await record.lifecycle.dispose();
} catch (error) {
errors.push(error);
}
}
return errors;
}
}
async function disposeFacetRecords(records: readonly FacetRuntime[]): Promise<unknown[]> {
const errors: unknown[] = [];
for (const record of records) {
try {
await record.lifecycle.dispose();
} catch (error) {
errors.push(error);
}
}
return errors;
}
function validateFacets(
records: readonly FacetShape[],
externalServices: ReadonlyMap<string, { readonly mode: ServiceMode }>,
): string[] {
const providers = new Map<
string,
{ readonly facetId: string | undefined; readonly mode: ServiceMode | undefined }
>();
for (const [serviceId, { mode }] of externalServices) providers.set(serviceId, { facetId: undefined, mode });
for (const record of records) {
for (const provision of record.provides) {
const existing = providers.get(provision.serviceId);
if (existing !== undefined) {
if (existing.mode !== undefined && existing.mode !== provision.mode) {
throw new Error(`Service ${provision.serviceId} is provided as both singleton and keyed`);
}
if (existing.facetId === undefined) {
throw new Error(`Service ${provision.serviceId} is provided by both the host and ${record.facetId}`);
}
throw new Error(
`Service ${provision.serviceId} is provided by both ${existing.facetId} and ${record.facetId}`,
);
}
providers.set(provision.serviceId, { facetId: record.facetId, mode: provision.mode });
}
}
const dependencies = new Map(records.map((record) => [record.facetId, new Set<string>()]));
const dependents = new Map(records.map((record) => [record.facetId, new Set<string>()]));
for (const record of records) {
for (const requirement of record.requires) {
const provider = providers.get(requirement.serviceId);
if (provider === undefined) {
throw new Error(
`Facet ${record.facetId} requires local/${requirement.serviceId}/${requirement.mode}, but no facet provides it`,
);
}
if (provider.mode !== undefined && provider.mode !== requirement.mode) {
throw new Error(
`Facet ${record.facetId} requires ${requirement.serviceId} as ${requirement.mode}, but ${provider.facetId ?? "the host"} provides it as ${provider.mode}`,
);
}
if (provider.facetId === undefined || provider.facetId === record.facetId) continue;
dependencies.get(record.facetId)!.add(provider.facetId);
dependents.get(provider.facetId)!.add(record.facetId);
}
}
return topologicalOrder(records, dependencies, dependents);
}
function topologicalOrder(
records: readonly FacetShape[],
dependencies: ReadonlyMap<string, ReadonlySet<string>>,
dependents: ReadonlyMap<string, ReadonlySet<string>>,
): string[] {
const remaining = new Map([...dependencies].map(([id, values]) => [id, values.size]));
const ready = records.map((record) => record.facetId).filter((id) => remaining.get(id) === 0);
const order: string[] = [];
while (ready.length > 0) {
const id = ready.shift()!;
order.push(id);
for (const dependent of dependents.get(id) ?? []) {
const count = remaining.get(dependent)! - 1;
remaining.set(dependent, count);
if (count === 0) ready.push(dependent);
}
}
if (order.length !== records.length) {
const cycle = records.map((record) => record.facetId).filter((id) => remaining.get(id)! > 0);
throw new Error(`Facet dependency cycle: ${cycle.join(", ")}`);
}
return order;
}
function recordServiceReference(
target: FacetServiceReference[],
service: { readonly id: string },
mode: ServiceMode,
): void {
if (target.some((reference) => reference.serviceId === service.id && reference.mode === mode)) return;
target.push({ serviceId: service.id, service: service as Service<unknown>, mode });
}
function sameFacetShape(left: FacetShape, right: FacetShape): boolean {
return sameReferences(left.requires, right.requires) && sameReferences(left.provides, right.provides);
}
function sameReferences(left: readonly FacetServiceReference[], right: readonly FacetServiceReference[]): boolean {
return (
left.length === right.length &&
left.every((reference) =>
right.some((other) => other.serviceId === reference.serviceId && other.mode === reference.mode),
)
);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
}

View File

@@ -1,6 +0,0 @@
import type { LoadedFacets } from "../types.ts";
export async function disposeLoadedFacets(loaded: readonly LoadedFacets[]): Promise<unknown[]> {
const results = await Promise.allSettled(loaded.map((entry) => entry.dispose()));
return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
}

1303
third_party/pi/packages/chord/src/host.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +1,82 @@
/**
* Chord is a standalone application-composition runtime for agentic applications.
*/
export type { Cell } from "./cell.ts";
export type { AcceptOptions, LinkOptions } from "./connection.ts";
export type { Context, ContextKey } from "./context/index.ts";
export {
combineFacetLoaders,
createFacetHost,
createRemoteServiceBinding,
createStaticFacetLoader,
type Accepts,
type Adapter,
type AnyContract,
bind,
coverage,
type Description,
type HostFamily,
type HostNamed,
hostFamily,
hostNamed,
type ImplementationOf,
type InputOf,
type LocalContract,
local,
method,
opaque,
type Placement,
type PlacementFor,
type Ref,
type RemoteContract,
remote,
} from "./contract.ts";
export type { EventOf, Resource, ResourceDeclaration, Subscribable, ValueOf } from "./declare.ts";
export type { Draft } from "./delta/index.ts";
export { ChordError, type ChordErrorCode, forbidden, invalid, isChordError } from "./errors.ts";
export type { ConnectOptions, PrincipalMatch, PrincipalPattern, Route } from "./host.ts";
export {
createHost,
defineFacet,
defineService,
replicatedState,
} from "./api.ts";
export { isJsonValue } from "./json.ts";
type Facet,
type FacetApi,
type FacetDefinition,
type FacetLoader,
type Host,
type HostOptions,
type LoadOptions,
type PluginRef,
type RootOptions,
} from "./host.ts";
export { type CopyJsonOptions, copyJson, isJsonValue, type JsonRepresentation, type JsonValue } from "./json.ts";
export {
isRemoteServiceErrorCode,
REMOTE_SERVICE_ERROR_CODES,
RemoteServiceError,
type RemoteServiceErrorCode,
} from "./services/errors.ts";
export {
createRemoteServiceEndpoint,
type RemoteServiceEndpoint,
RemoteServiceProvider,
type ServiceUpdatePublisher,
} from "./services/provider.ts";
export {
createServiceStateDecoder,
createServiceStateEncoder,
type ServiceStateDecoder,
type ServiceStateEncoder,
} from "./services/state-codec.ts";
export {
createServiceCatalogueCall,
createServiceSubscribeCall,
createServiceUnsubscribeCall,
decodeServiceControlCall,
parseServiceCall,
parseServiceCatalogue,
parseServiceProviderUpdate,
parseServiceSubscriptionSnapshot,
parseWireServiceProviderUpdate,
parseWireServiceSubscriptionSnapshot,
type ServiceControlCall,
type WireServiceInstanceSnapshot,
type WireServiceMemberSnapshot,
type WireServiceProviderUpdate,
type WireServiceSubscriptionSnapshot,
} from "./services/wire.ts";
type IpcChannel,
inflateMessage,
ipcLink,
type Link,
memoryLinkPair,
PACK_THRESHOLD,
type WebSocketLike,
type WebSocketLinkOptions,
webSocketLink,
} from "./link.ts";
export type {
Context,
ContextKey,
Facet,
FacetEnvironment,
FacetHost,
FacetLoader,
FacetOptions,
JsonRepresentation,
JsonValue,
LoadedFacets,
MutableReplicatedState,
RemoteServiceBinding,
RemoteServiceBindingOptions,
RemoteServiceSource,
RemoteServices,
RemoteServiceTransport,
ReplicatedState,
ReplicatedStateDelivery,
Service,
ServiceCall,
ServiceCatalogueEntry,
ServiceInstanceAddress,
ServiceInstanceSnapshot,
ServiceMemberSnapshot,
ServiceMode,
ServiceProviderUpdate,
ServiceSpawner,
ServiceSubscription,
ServiceSubscriptionSnapshot,
} from "./types.ts";
Factory,
HostServeOptions,
Lifetime,
ReferencedServeOptions,
Self,
ServeOptions,
ServeOptionsFor,
StateInit,
StateOptions,
} from "./object.ts";
export { defineRegistry, type Registry, type RegistryTable, type RegistryView } from "./registry.ts";
export type { ReloadOptions, ReloadReport } from "./reload.ts";
export {
type AttachedReplicatedState,
type MutableReplicatedState,
type ReplicatedState,
type ReplicatedStateDelivery,
type ReplicatedStateSource,
type ReplicatedStateSourceAttachment,
type ReplicatedStateSourceFrame,
type ReplicatedStateSourceOptions,
replicatedState,
} from "./state.ts";
export type { Ctx, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts";
export { type Frame, PROTOCOL_VERSION } from "./wire.ts";

View File

@@ -1,15 +1,100 @@
import type { JsonValue } from "./types.ts";
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Strict-JSON representation of an application data type. Unknown payloads become JsonValue. */
export type JsonRepresentation<T> = IsAny<T> extends true
? JsonValue
: unknown extends T
? JsonValue
: T extends null | boolean | number | string
? T
: T extends readonly (infer TItem)[]
? JsonRepresentation<TItem>[]
: T extends object
? { [TKey in keyof T]: JsonRepresentation<T[TKey]> }
: never;
const DATA_DESCRIPTOR: PropertyDescriptor = {
value: undefined,
writable: true,
enumerable: true,
configurable: true,
};
export type CopyJsonOptions = {
/** Omit undefined object properties while preserving strict array semantics. */
readonly omitUndefinedProperties?: boolean;
};
/** Copy a value into an alias-free strict-JSON tree owned by the caller. */
export function copyJson(value: unknown, options?: CopyJsonOptions): JsonValue {
return copy(value, undefined, options?.omitUndefinedProperties === true);
}
function copy(value: unknown, ancestors: Set<object> | undefined, omitUndefinedProperties: boolean): JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
throw new TypeError("Value contains a non-finite number and is not strict JSON");
}
if (typeof value !== "object")
throw new TypeError(`Value contains a non-JSON ${typeof value}; expected strict JSON`);
const active = ancestors ?? new Set<object>();
if (active.has(value)) throw new TypeError("Value contains cycles and is not strict JSON");
active.add(value);
try {
if (Array.isArray(value)) {
// Indices plus `length` only: extra, symbol, and missing keys all change the count.
if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1) {
throw new TypeError("Value must contain strict JSON dense plain arrays");
}
const result = new Array<JsonValue>(value.length);
for (let index = 0; index < value.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(value, index);
if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) {
throw new TypeError("Value must contain strict JSON enumerable indexed data properties");
}
defineData(result, String(index), copy(descriptor.value, active, omitUndefinedProperties));
}
return result;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("Value must contain strict JSON plain objects or arrays");
}
const result = Object.create(prototype) as Record<string, JsonValue>;
for (const key of Reflect.ownKeys(value)) {
if (typeof key === "symbol") throw new TypeError("Value contains a symbol key and is not strict JSON");
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
if (!descriptor.enumerable || !("value" in descriptor)) {
throw new TypeError("Value must contain strict JSON enumerable data properties");
}
if (descriptor.value === undefined && omitUndefinedProperties) continue;
defineData(result, key, copy(descriptor.value, active, omitUndefinedProperties));
}
return result;
} finally {
active.delete(value);
}
}
function defineData(target: object, key: PropertyKey, value: JsonValue): void {
DATA_DESCRIPTOR.value = value;
Object.defineProperty(target, key, DATA_DESCRIPTOR);
DATA_DESCRIPTOR.value = undefined;
}
/** Return whether a value is finite strict JSON with plain objects and no cycles. */
export function isJsonValue(value: unknown): value is JsonValue {
return check(value, new Set<object>(), 0);
return check(value, new Set<object>());
}
function check(value: unknown, ancestors: Set<object>, depth: number): boolean {
if (depth > 512) return false;
function check(value: unknown, ancestors: Set<object>): boolean {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) return false;
const keys = Reflect.ownKeys(value);
if (keys.length !== value.length + 1 || keys.some((key) => typeof key !== "string")) return false;
if (ancestors.has(value)) return false;
@@ -21,7 +106,7 @@ function check(value: unknown, ancestors: Set<object>, depth: number): boolean {
descriptor === undefined ||
!descriptor.enumerable ||
!("value" in descriptor) ||
!check(descriptor.value, ancestors, depth + 1)
!check(descriptor.value, ancestors)
) {
return false;
}
@@ -39,7 +124,7 @@ function check(value: unknown, ancestors: Set<object>, depth: number): boolean {
ancestors.add(value);
try {
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) {
if (!descriptor.enumerable || !("value" in descriptor) || !check(descriptor.value, ancestors, depth + 1)) {
if (!descriptor.enumerable || !("value" in descriptor) || !check(descriptor.value, ancestors)) {
return false;
}
}

View File

@@ -0,0 +1,415 @@
import type * as Zlib from "node:zlib";
import type { Frame } from "./wire.ts";
/** An ordered (FIFO), bidirectional frame channel between two hosts. */
export interface Link {
readonly closed: boolean;
/**
* Returns the size of the encoded frame as sent (JSON text length, or bytes of a packed message) when the transport
* knows it. The connection's flow control counts these sizes; frames without one count as zero, so a link that never
* reports sizes is not windowed.
*/
send(frame: Frame): number | undefined;
/** Sets the single receiver. Frames arriving earlier are queued. */
receive(handler: (frame: Frame) => void): void;
onClose(handler: (reason: string) => void): void;
/** Closes both ends: the peer gets a `bye`. */
close(reason?: string): void;
}
/** Shared plumbing: receive queue, close handlers, `bye`. Transports implement `transmit` and `shutdown`. */
abstract class BaseLink implements Link {
#handler: ((frame: Frame) => void) | undefined;
#queue: Frame[] = [];
readonly #closeHandlers = new Set<(reason: string) => void>();
#closed = false;
/** The tail of frames being decoded asynchronously; undefined when none is. */
#decoding: Promise<void> | undefined;
get closed(): boolean {
return this.#closed;
}
protected abstract transmit(frame: Frame): number | undefined;
protected abstract shutdown(): void;
send(frame: Frame): number | undefined {
if (this.#closed) return undefined;
return this.transmit(frame);
}
receive(handler: (frame: Frame) => void): void {
this.#handler = handler;
for (const frame of this.#queue.splice(0)) handler(frame);
}
onClose(handler: (reason: string) => void): void {
if (this.#closed) handler("closed");
else this.#closeHandlers.add(handler);
}
close(reason = "closed"): void {
if (this.#closed) return;
try {
this.transmit({ t: "bye", reason });
} catch {
// The transport may already be gone.
}
this.ended(reason);
}
/** Delivers a parsed frame from the transport, after any frame still being decoded (`deliverLater`). */
protected deliver(frame: Frame): void {
if (this.#decoding !== undefined) this.deliverLater(Promise.resolve(frame));
else this.#deliverNow(frame);
}
/** Delivers a frame that is still being decoded (inflated in a browser); later frames wait for it, in order. */
protected deliverLater(frame: Promise<Frame>): void {
const next = (this.#decoding ?? Promise.resolve())
.then(() => frame)
.then(
(decoded) => this.#deliverNow(decoded),
() => this.close("protocol error"),
)
.finally(() => {
if (this.#decoding === next) this.#decoding = undefined;
});
this.#decoding = next;
}
#deliverNow(frame: Frame): void {
if (this.#closed) return;
if (frame.t === "bye") {
this.ended(frame.reason ?? "peer closed");
return;
}
if (this.#handler === undefined) this.#queue.push(frame);
else this.#handler(frame);
}
/** Marks the link closed (transport ended) and notifies once. */
protected ended(reason: string): void {
if (this.#closed) return;
this.#closed = true;
this.#queue = [];
try {
this.shutdown();
} finally {
for (const handler of this.#closeHandlers) handler(reason);
this.#closeHandlers.clear();
}
}
}
// ─── payload packing ─────────────────────────────────────────────────────────
//
// A transport optimization, invisible above the links. A frame's payload (call args, a result value, a state snapshot)
// whose JSON exceeds `PACK_THRESHOLD` characters is sent deflated. A host that only relays the frame forwards the
// deflated bytes unread (`Packed`); the host that uses the payload inflates it (`unpack`). Only hosts with Node's zlib
// deflate (synchronously, so frames keep their order); a browser inflates with `DecompressionStream` before delivery.
/** Payloads whose JSON is longer than this many characters are deflated. */
export const PACK_THRESHOLD = 64 * 1024;
const zlib: typeof Zlib | undefined = (
globalThis as { process?: { getBuiltinModule?(id: "node:zlib"): typeof Zlib } }
).process?.getBuiltinModule?.("node:zlib");
/** A deflated JSON payload, kept as received until a host uses it. */
export class Packed {
readonly bytes: Uint8Array;
constructor(bytes: Uint8Array) {
this.bytes = bytes;
}
}
/** A frame payload as its user needs it: inflated and parsed if it arrived packed. */
export function unpack(value: unknown): unknown {
if (!(value instanceof Packed)) return value;
if (zlib === undefined) throw new TypeError("packed payload without zlib");
return JSON.parse(zlib.inflateRawSync(value.bytes).toString("utf8"));
}
type PayloadField = "args" | "value";
const PAYLOAD_FIELDS: ReadonlySet<string> = new Set<PayloadField>(["args", "value"]);
function payloadField(frame: Frame): PayloadField | undefined {
if (frame.t === "call") return frame.args === undefined ? undefined : "args";
if (frame.t === "result" || frame.t === "state") return frame.value === undefined ? undefined : "value";
return undefined;
}
/** The frame without its payload field (the envelope). */
function envelopeOf(frame: Frame, field: PayloadField): Record<string, unknown> {
const envelope: Record<string, unknown> = { ...frame };
delete envelope[field];
return envelope;
}
/** The payload's deflated bytes: as received, or deflated now if large. Undefined: send it as JSON. */
function packedBytes(value: unknown, compress: boolean, json?: string): Uint8Array | undefined {
if (value instanceof Packed) return value.bytes;
if (!compress || zlib === undefined || json === undefined || json.length <= PACK_THRESHOLD) return undefined;
return zlib.deflateRawSync(json, { level: 1 });
}
/** For object transports (Node IPC, memory): a packed payload travels as `z: [field, base64]`. */
function packObject(frame: Frame, compress: boolean): unknown {
const field = payloadField(frame);
if (field === undefined) return frame;
const value = (frame as Record<string, unknown>)[field];
const bytes = packedBytes(value, compress, compress && zlib !== undefined ? JSON.stringify(value) : undefined);
if (bytes === undefined) return frame;
return {
...envelopeOf(frame, field),
z: [field, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64")],
};
}
function unpackObject(message: unknown): unknown {
if (message === null || typeof message !== "object" || !("z" in message)) return message;
const { z, ...rest } = message as { z: unknown } & Record<string, unknown>;
if (!Array.isArray(z) || !PAYLOAD_FIELDS.has(z[0]) || typeof z[1] !== "string")
throw new TypeError("bad packed payload");
return { ...rest, [z[0]]: new Packed(Buffer.from(z[1], "base64")) };
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/**
* For WebSockets: a frame with a packed payload is a binary message, `[envelope length: u32 BE][envelope JSON, with
* z: field][deflated payload]`; any other frame is text. The payload's JSON is spliced in, not encoded twice.
*/
function packMessage(frame: Frame, compress: boolean): string | Uint8Array {
const field = payloadField(frame);
if (field === undefined) return JSON.stringify(frame);
const value = (frame as Record<string, unknown>)[field];
const json = value instanceof Packed ? undefined : JSON.stringify(value);
const bytes = packedBytes(value, compress, json);
const envelope = envelopeOf(frame, field);
if (bytes === undefined) {
const head = JSON.stringify(envelope);
return `${head.slice(0, -1)},${JSON.stringify(field)}:${json}}`;
}
const head = encoder.encode(JSON.stringify({ ...envelope, z: field }));
const message = new Uint8Array(4 + head.byteLength + bytes.byteLength);
new DataView(message.buffer).setUint32(0, head.byteLength);
message.set(head, 4);
message.set(bytes, 4 + head.byteLength);
return message;
}
/** The envelope of a binary message and its payload bytes. */
function splitMessage(data: Uint8Array): { envelope: Record<string, unknown>; field: PayloadField; bytes: Uint8Array } {
const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0);
const envelope = JSON.parse(decoder.decode(data.subarray(4, 4 + length))) as unknown;
if (envelope === null || typeof envelope !== "object") throw new TypeError("bad envelope");
const { z: field, ...rest } = envelope as { z?: unknown } & Record<string, unknown>;
if (typeof field !== "string" || !PAYLOAD_FIELDS.has(field)) throw new TypeError("bad packed payload");
return { envelope: rest, field: field as PayloadField, bytes: data.slice(4 + length) };
}
/**
* The text frame of a binary (packed) WebSocket message, inflated: for a transport that relays text frames only (the
* TUI launcher feeding its sandbox). Needs zlib.
*/
export function inflateMessage(data: Uint8Array): string {
if (zlib === undefined) throw new TypeError("inflateMessage needs zlib");
const { envelope, field, bytes } = splitMessage(data);
const head = JSON.stringify(envelope);
return `${head.slice(0, -1)},${JSON.stringify(field)}:${zlib.inflateRawSync(bytes).toString("utf8")}}`;
}
/** Inflates without zlib (browsers). */
async function inflate(bytes: Uint8Array): Promise<unknown> {
const stream = new Blob([bytes as Uint8Array<ArrayBuffer>])
.stream()
.pipeThrough(new DecompressionStream("deflate-raw"));
return JSON.parse(await new Response(stream).text());
}
/** Parses a JSON frame; anything else is a protocol error. */
function parseFrame(data: string): Frame {
const frame = unpackObject(JSON.parse(data)) as Frame;
if (frame === null || typeof frame !== "object" || typeof frame.t !== "string") throw new TypeError("not a frame");
return frame;
}
// ─── memory ──────────────────────────────────────────────────────────────────
export class MemoryLink extends BaseLink {
peer: MemoryLink | undefined;
#severed = false;
#pending = Promise.resolve();
/** Simulates a dead network: frames are dropped silently in both directions and no close is signalled. */
sever(): void {
this.#severed = true;
if (this.peer) this.peer.#severed = true;
}
protected transmit(frame: Frame): number | undefined {
if (this.#severed) return undefined;
// A JSON round trip detaches the frame (and rejects what JSON would mangle silently, e.g. bigint).
const data = JSON.stringify(packObject(frame, true));
const peer = this.peer;
// One promise chain per direction keeps delivery FIFO and asynchronous, like a real transport.
this.#pending = this.#pending.then(() => {
if (!this.#severed && peer !== undefined) peer.deliver(parseFrame(data));
});
return data.length;
}
protected shutdown(): void {
const peer = this.peer;
this.#pending = this.#pending.then(() => peer?.ended("peer closed"));
}
}
/** Two connected in-memory links (tests). */
export function memoryLinkPair(): [MemoryLink, MemoryLink] {
const a = new MemoryLink();
const b = new MemoryLink();
a.peer = b;
b.peer = a;
return [a, b];
}
// ─── Node IPC ────────────────────────────────────────────────────────────────
/** `process` in a forked child, or the parent's `ChildProcess`. Structural, so Chord needs no Node types here. */
export interface IpcChannel {
readonly connected: boolean;
/**
* With a callback, Node reports a failed write (the peer process exited and the pipe is broken: `EPIPE`) to it
* instead of emitting `error`, which nobody would handle.
*/
send(message: unknown, callback?: (error: Error | null) => void): boolean;
on(event: "message", listener: (message: unknown) => void): unknown;
on(event: "disconnect", listener: () => void): unknown;
off(event: "message", listener: (message: unknown) => void): unknown;
off(event: "disconnect", listener: () => void): unknown;
}
/**
* Chord frames on an IPC channel are wrapped as `{ chord2: frame }`; other messages (control) pass by. Both ends are
* Node processes of one application, so large payloads are packed both ways. Node serializes the messages itself, so
* the link reports no sizes and is not windowed: both ends are local processes.
*/
export class IpcLink extends BaseLink {
readonly #channel: IpcChannel;
readonly #onMessage = (message: unknown): void => {
const frame = (message as { chord2?: unknown } | null)?.chord2;
if (frame === undefined) return;
try {
this.deliver(unpackObject(frame) as Frame);
} catch {
this.close("protocol error");
}
};
readonly #onDisconnect = (): void => this.ended("ipc disconnected");
/**
* A write can fail after the peer exited and before the channel reports `disconnect` (an `ack` or a `bye` sent then).
* The peer is gone either way: the link ends, as on `disconnect`.
*/
readonly #onSent = (error: Error | null): void => {
if (error !== null) this.ended(`ipc send failed: ${(error as { code?: string }).code ?? error.message}`);
};
constructor(channel: IpcChannel) {
super();
this.#channel = channel;
channel.on("message", this.#onMessage);
channel.on("disconnect", this.#onDisconnect);
}
protected transmit(frame: Frame): undefined {
if (this.#channel.connected) this.#channel.send({ chord2: packObject(frame, true) }, this.#onSent);
return undefined;
}
protected shutdown(): void {
this.#channel.off("message", this.#onMessage);
this.#channel.off("disconnect", this.#onDisconnect);
}
}
export const ipcLink = (channel: IpcChannel): IpcLink => new IpcLink(channel);
// ─── WebSocket ───────────────────────────────────────────────────────────────
/** The WHATWG WebSocket surface: the browser `WebSocket`, Node's global `WebSocket`, and `ws` sockets. */
export interface WebSocketLike {
readonly readyState: number;
send(data: string | Uint8Array): void;
close(code?: number, reason?: string): void;
addEventListener(type: "message", listener: (event: { readonly data: unknown }) => void): void;
addEventListener(type: "open" | "close" | "error", listener: () => void): void;
}
const OPEN = 1;
export interface WebSocketLinkOptions {
/** Deflate large payloads of outgoing frames (default false; the server's side of client sockets). */
readonly compress?: boolean;
/** Accept packed (binary) frames from the peer (default true; false for a server's untrusted clients). */
readonly acceptPacked?: boolean;
}
export class WebSocketLink extends BaseLink {
readonly #socket: WebSocketLike;
readonly #compress: boolean;
#outbox: (string | Uint8Array)[] = [];
constructor(socket: WebSocketLike, options: WebSocketLinkOptions = {}) {
super();
this.#socket = socket;
this.#compress = options.compress ?? false;
const acceptPacked = options.acceptPacked ?? true;
// Binary messages as ArrayBuffer (browsers and Node's WebSocket default to Blob).
if ("binaryType" in socket) (socket as { binaryType: string }).binaryType = "arraybuffer";
socket.addEventListener("open", () => {
for (const data of this.#outbox.splice(0)) socket.send(data);
});
socket.addEventListener("message", (event) => {
try {
const data = event.data;
if (typeof data === "string") {
this.deliver(parseFrame(data));
return;
}
if (!acceptPacked) throw new TypeError("packed frames are not accepted");
const bytes =
data instanceof ArrayBuffer
? new Uint8Array(data)
: ArrayBuffer.isView(data)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
: undefined;
if (bytes === undefined) throw new TypeError("unknown message type");
const { envelope, field, bytes: payload } = splitMessage(bytes);
if (zlib !== undefined) this.deliver({ ...envelope, [field]: new Packed(payload) } as Frame);
else this.deliverLater(inflate(payload).then((value) => ({ ...envelope, [field]: value }) as Frame));
} catch {
this.close("protocol error");
}
});
socket.addEventListener("close", () => this.ended("socket closed"));
}
protected transmit(frame: Frame): number {
const data = packMessage(frame, this.#compress);
if (this.#socket.readyState === OPEN) this.#socket.send(data);
else this.#outbox.push(data);
return typeof data === "string" ? data.length : data.byteLength;
}
protected shutdown(): void {
this.#outbox = [];
if (this.#socket.readyState <= OPEN) this.#socket.close(1000);
}
}
export const webSocketLink = (socket: WebSocketLike, options?: WebSocketLinkOptions): WebSocketLink =>
new WebSocketLink(socket, options);

View File

@@ -0,0 +1,67 @@
// Node facet loader: `createHost({ loader: nodeLoader })`. Kept out of index.ts so the core stays browser-safe.
//
// TODO(loader): replace with an isolating, evicting loader. Every reload imports fresh module copies under a new
// query and the old copies are never evicted (they leak until the process exits). TUI plugins need isolation, which
// this loader does not provide.
import { registerHooks } from "node:module";
import { pathToFileURL } from "node:url";
import type { Facet, FacetDefinition, FacetLoader } from "./host.ts";
const PARAM = "chord2-reload";
/** Directory URLs (trailing slash) of loaded facet modules; their files are re-imported on reload. */
const roots = new Set<string>();
let hooked = false;
let generation = 0;
/**
* A query on the entry URL only re-imports the entry module: its relative imports would resolve to cached copies.
* This hook carries the reload query from a re-imported parent to children inside a facet module's directory.
* Modules outside (Chord itself, shared code) stay single instances.
*/
function hook(): void {
if (hooked) return;
hooked = true;
registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
const version = context.parentURL === undefined ? null : new URL(context.parentURL).searchParams.get(PARAM);
if (version === null || !result.url.startsWith("file:")) return result;
const url = new URL(result.url);
if (url.searchParams.has(PARAM) || ![...roots].some((root) => url.href.startsWith(root))) return result;
url.searchParams.set(PARAM, version);
return { ...result, url: url.href };
},
});
}
const toUrl = (module: string | URL): URL =>
module instanceof URL
? new URL(module.href)
: new URL(module.startsWith("file:") ? module : pathToFileURL(module).href);
/** Imports fresh copies of facet modules (default export: a FacetDefinition); each facet is named by its URL. */
export const nodeLoader: FacetLoader = async (modules) => {
hook();
const version = String(++generation);
const facets: Facet[] = [];
const urls = modules.map((module) => {
const url = toUrl(module);
url.search = "";
return url;
});
// Every root first: a facet importing another facet's directory gets this load's copy, whatever the order.
for (const url of urls) roots.add(new URL(".", url).href);
for (const url of urls) {
const name = url.href;
url.searchParams.set(PARAM, version);
// Owner-approved exception to the no-dynamic-import rule; see TODO(loader) above.
const loaded = (await import(url.href)) as { default?: FacetDefinition };
const definition = loaded.default;
if (definition === undefined || typeof definition.setup !== "function") {
throw new TypeError(`${name}: default export must be a facet (defineFacet)`);
}
facets.push({ name, setup: definition.setup, uses: definition.uses });
}
return facets;
};

View File

@@ -0,0 +1,443 @@
// Host manager (Node only): runs a child host process and keeps at most one instance of it alive.
import { type ChildProcess, fork } from "node:child_process";
import { createHash } from "node:crypto";
import { unlinkSync } from "node:fs";
import { lstat, mkdir, rm } from "node:fs/promises";
import { connect, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ChordError } from "./errors.ts";
import { type IpcChannel, type IpcLink, ipcLink } from "./link.ts";
/** Control messages share the IPC channel with chord frames: `{ chord2control: message }`. */
export type ControlMessage =
| { readonly type: "init"; readonly value: unknown }
| { readonly type: "ready" }
| { readonly type: "fatal"; readonly message: string; readonly reason?: string }
| { readonly type: "shutdown" }
| {
readonly type: "status";
readonly activeCalls?: number;
readonly viewers?: number;
readonly durableTasks?: number;
};
export interface Liveness {
/** Started but not ready yet. */
readonly booting: boolean;
readonly activeCalls: number;
/** Viewers the child reports (it counts heartbeating subscribers). */
readonly viewers: number;
readonly durableTasks: number;
/** Milliseconds since the child last reported work (calls, viewers or tasks), or since it became ready. */
readonly idleFor: number;
}
export interface Exit {
readonly code: number | null;
readonly signal: string | null;
}
/** A running child: its link (for `host.accept`) and its exit. */
export interface Instance {
readonly pid: number;
readonly link: IpcLink;
readonly exited: Promise<Exit>;
send(message: ControlMessage): void;
/** Kills the child and everything it spawned. */
killTree(): void;
onControl(listener: (message: ControlMessage) => void): void;
}
export interface Driver {
spawn(): Instance;
}
export interface ForkDriverOptions {
/** The child's entry module. */
readonly entry: string;
readonly args?: readonly string[];
readonly env?: Readonly<Record<string, string>>;
/** Node flags for the child, e.g. `["--import", sourceResolver]` (default: none). */
readonly execArgv?: readonly string[];
/** Sent to the child as its first control message (`childControl().init`). */
readonly init?: unknown;
/**
* A lock name: one live process per name, across managers and processes. The child acquires it as a kernel lock
* (`childControl().lock()`, before opening its storage); a second child fails to start with `unavailable/locked`.
*/
readonly lock?: string;
/** Optional stdout/stderr tap. Streams are still mirrored to the parent's standard output. */
readonly onOutput?: (stream: "stdout" | "stderr", chunk: Uint8Array) => void;
/** Optional application metadata tap; control messages and chord frames keep their normal handling. */
readonly onMessage?: (message: unknown) => void;
}
/**
* A process lock owned by the kernel for the holder's lifetime: an abstract socket (Linux, Android) or a named pipe
* (Windows) vanishes with its process, so there is no stale state to take over and no race between two would-be
* owners. Other platforms need a socket file, removed on exit; a leftover one nobody accepts on is taken over.
* The child acquires it (before opening storage), so the lock follows the process that owns the data.
*/
export async function processLock(name: string): Promise<() => Promise<void>> {
const key = createHash("sha256").update(name).digest("hex").slice(0, 32);
const file = process.platform !== "win32" && process.platform !== "linux" && process.platform !== "android";
const address =
process.platform === "win32"
? `\\\\.\\pipe\\chord2-${key}`
: file
? await lockSocketPath(`chord2-${key}.sock`)
: `\0chord2-${key}`;
const server = createServer((socket) => socket.end());
for (;;) {
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(address, () => {
server.removeListener("error", reject);
resolve();
});
});
break;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
if (!file || (await accepting(address))) {
throw new ChordError("unavailable", `lock ${name} is held by another process`, { reason: "locked" });
}
await rm(address, { force: true });
}
}
const removeFile = (): void => {
try {
unlinkSync(address);
} catch {
// Already gone.
}
};
if (file) process.once("exit", removeFile);
return () =>
new Promise<void>((resolve, reject) => {
if (file) process.removeListener("exit", removeFile);
server.close((error) => (error ? reject(error) : resolve()));
});
}
/** sun_path references 104 bytes on macOS and the BSDs, including the terminating NUL. */
const MAX_SOCKET_PATH_BYTES = 103;
/**
* The lock socket's path: in the temporary directory, or, when that path does not fit in sun_path (a deep TMPDIR,
* such as a test runner's below macOS's per-user one), in a private per-user directory under /tmp. Other users must
* not be able to create or remove sockets there, so the directory has to be ours and closed to everyone else.
*/
async function lockSocketPath(file: string): Promise<string> {
const preferred = join(tmpdir(), file);
if (Buffer.byteLength(preferred) <= MAX_SOCKET_PATH_BYTES) return preferred;
const uid = process.getuid?.() ?? 0;
const directory = `/tmp/chord2-${uid}`;
await mkdir(directory, { mode: 0o700, recursive: true });
const stats = await lstat(directory);
if (!stats.isDirectory() || stats.uid !== uid || (stats.mode & 0o077) !== 0) {
throw new ChordError("unavailable", `lock directory ${directory} must be a directory only its owner can access`);
}
return join(directory, file);
}
function accepting(address: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect(address);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", (error: NodeJS.ErrnoException) =>
resolve(error.code !== "ECONNREFUSED" && error.code !== "ENOENT"),
);
});
}
const LOCK_ENV = "CHORD2_LOCK";
/** A control message to a peer process that is gone: dropped (its exit or `disconnect` is reported separately). */
const ignoreSendError = (): void => {};
export function forkDriver(options: ForkDriverOptions): Driver {
return {
spawn() {
// A lock this process inherited (e.g. as a child host itself) is not the child's: it gets only its own.
const env: NodeJS.ProcessEnv = { ...process.env };
delete env[LOCK_ENV];
Object.assign(env, options.env);
if (options.lock !== undefined) env[LOCK_ENV] = options.lock;
// Detached: the child leads a process group, so the whole tree can be killed.
const child: ChildProcess = fork(options.entry, [...(options.args ?? [])], {
env,
execArgv: [...(options.execArgv ?? [])],
detached: true,
stdio: ["ignore", options.onOutput ? "pipe" : "inherit", options.onOutput ? "pipe" : "inherit", "ipc"],
});
if (options.onOutput) {
for (const stream of ["stdout", "stderr"] as const) {
child[stream]?.on("data", (chunk: Buffer) => {
options.onOutput!(stream, chunk);
process[stream].write(chunk);
});
}
}
const pid = child.pid ?? -1;
const killGroup = (): void => {
try {
process.kill(-pid, "SIGKILL");
} catch {
// No group left.
}
};
const exited = new Promise<Exit>((resolve) => {
child.once("exit", (code, signal) => {
// However the leader ended (graceful, crash, kill), nothing it spawned outlives it.
killGroup();
resolve({ code, signal });
});
});
const listeners = new Set<(message: ControlMessage) => void>();
child.on("message", (message) => {
options.onMessage?.(message);
const control = (message as { chord2control?: ControlMessage } | null)?.chord2control;
if (control !== undefined) for (const listener of listeners) listener(control);
});
const send = (message: ControlMessage): void => {
// A child that exited before the channel reported it fails the write (EPIPE): its exit is what counts.
if (child.connected) child.send({ chord2control: message }, ignoreSendError);
};
if (options.init !== undefined) send({ type: "init", value: options.init });
return {
pid,
link: ipcLink(child as unknown as IpcChannel),
exited,
send,
killTree: () => {
try {
process.kill(-pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
},
onControl: (listener) => listeners.add(listener),
};
},
};
}
export interface HostManagerOptions {
readonly driver: Driver;
/** Evaluated every `checkInterval` while running; `false` stops the child. */
readonly keepAlive?: (liveness: Liveness) => boolean;
readonly checkInterval?: number;
/** Milliseconds a child may take to shut down before its process tree is killed (default 5000). */
readonly grace?: number;
/** Milliseconds to wait for `ready` (default 30000). */
readonly readyTimeout?: number;
/** The child exited on its own (crash, fatal) or after a stop. */
readonly onExit?: (exit: Exit) => void;
}
export interface HostManager {
readonly status: "stopped" | "starting" | "running" | "stopping";
readonly instance: Instance | undefined;
readonly liveness: Liveness;
/** Starts the child (or returns the running one) once it reported ready. Serialized with stop/replace. */
start(): Promise<Instance>;
/** Asks the child to shut down, then kills its process tree after the grace period. */
stop(): Promise<void>;
/** Stop, wait for the exit, start. */
replace(): Promise<Instance>;
}
export function createHostManager(options: HostManagerOptions): HostManager {
let status: HostManager["status"] = "stopped";
let instance: Instance | undefined;
let queue: Promise<unknown> = Promise.resolve();
let report = { activeCalls: 0, viewers: 0, durableTasks: 0 };
/** When the child last became idle; undefined while it reports work. */
let idleSince: number | undefined;
let booting = false;
let timer: ReturnType<typeof setInterval> | undefined;
const serialized = <T>(step: () => Promise<T>): Promise<T> => {
const next = queue.then(step, step);
queue = next.catch(() => {});
return next;
};
const liveness = (): Liveness => ({
booting,
...report,
idleFor: idleSince === undefined ? 0 : Date.now() - idleSince,
});
const doStop = async (): Promise<void> => {
const current = instance;
if (current === undefined) {
status = "stopped";
booting = false;
return;
}
status = "stopping";
if (timer !== undefined) clearInterval(timer);
current.send({ type: "shutdown" });
let graceTimer: ReturnType<typeof setTimeout> | undefined;
const graceful = await Promise.race([
current.exited.then(() => true),
new Promise<boolean>((resolve) => {
graceTimer = setTimeout(() => resolve(false), options.grace ?? 5000);
}),
]);
clearTimeout(graceTimer);
if (!graceful) current.killTree();
await current.exited;
if (instance === current) instance = undefined;
status = "stopped";
};
const doStart = async (): Promise<Instance> => {
if (instance !== undefined && status === "running") return instance;
status = "starting";
booting = true;
report = { activeCalls: 0, viewers: 0, durableTasks: 0 };
let current: Instance;
try {
current = options.driver.spawn();
} catch (error) {
status = "stopped";
booting = false;
throw error;
}
instance = current;
current.exited.then((exit) => {
if (instance === current) {
instance = undefined;
status = "stopped";
if (timer !== undefined) clearInterval(timer);
}
options.onExit?.(exit);
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new ChordError("unavailable", "child not ready in time")),
options.readyTimeout ?? 30_000,
);
const settle = (fn: () => void): void => {
clearTimeout(timeout);
fn();
};
current.exited.then((exit) =>
settle(() =>
reject(new ChordError("unavailable", `child exited before ready (${exit.code ?? exit.signal})`)),
),
);
current.onControl((message) => {
if (message.type === "ready") settle(resolve);
else if (message.type === "fatal")
settle(() =>
reject(new ChordError("unavailable", `child failed: ${message.message}`, { reason: message.reason })),
);
else if (message.type === "status") {
report = {
activeCalls: message.activeCalls ?? 0,
viewers: message.viewers ?? 0,
durableTasks: message.durableTasks ?? 0,
};
// The idle clock starts at the busy → idle transition, not at the last busy report.
if (report.activeCalls + report.viewers + report.durableTasks > 0) idleSince = undefined;
else idleSince ??= Date.now();
}
});
}).catch(async (error: unknown) => {
current.killTree();
await current.exited;
throw error;
});
booting = false;
if (report.activeCalls + report.viewers + report.durableTasks === 0) idleSince ??= Date.now();
status = "running";
if (options.keepAlive !== undefined) {
const keepAlive = options.keepAlive;
timer = setInterval(() => {
if (status === "running" && !keepAlive(liveness())) void serialized(doStop);
}, options.checkInterval ?? 1000);
timer.unref?.();
}
return current;
};
return {
get status() {
return status;
},
get instance() {
return instance;
},
get liveness() {
return liveness();
},
start: () => serialized(doStart),
stop: () => serialized(doStop),
replace: () =>
serialized(async () => {
await doStop();
return doStart();
}),
};
}
/** The child side of the control channel (call in the child's entry). */
export function childControl(channel: NodeJS.Process = process): {
readonly init: Promise<unknown>;
/**
* Acquires the manager's lock (if one was configured) as a kernel lock held for this process's lifetime. Call it
* before opening storage. Fails with `unavailable/locked`, also reported to the manager as a fatal start error.
*/
lock(): Promise<void>;
ready(): void;
fatal(error: unknown): void;
report(status: { activeCalls?: number; viewers?: number; durableTasks?: number }): void;
onShutdown(handler: () => void | Promise<void>): void;
} {
const send = (message: ControlMessage): void => {
// A parent that is gone fails the write (EPIPE); the `disconnect` that follows is how a child notices.
if (channel.connected) channel.send?.({ chord2control: message }, undefined, undefined, ignoreSendError);
};
let resolveInit: (value: unknown) => void = () => {};
const init = new Promise<unknown>((resolve) => {
resolveInit = resolve;
});
const shutdown = new Set<() => void | Promise<void>>();
channel.on("message", (message: unknown) => {
const control = (message as { chord2control?: ControlMessage } | null)?.chord2control;
if (control?.type === "init") resolveInit(control.value);
if (control?.type === "shutdown") for (const handler of shutdown) void handler();
});
const fatal = (error: unknown): void =>
send({
type: "fatal",
message: error instanceof Error ? error.message : String(error),
...(error instanceof ChordError && error.reason !== undefined ? { reason: error.reason } : {}),
});
return {
init,
lock: async () => {
const name = process.env[LOCK_ENV];
if (name === undefined) return;
try {
await processLock(name);
} catch (error) {
fatal(error);
throw error;
}
},
ready: () => send({ type: "ready" }),
fatal,
report: (status) => send({ type: "status", ...status }),
onShutdown: (handler) => shutdown.add(handler),
};
}

View File

@@ -1,24 +1,16 @@
export type {
FacetBundleArtifactLoaderOptions,
FacetBundleExternalResolver,
FacetBundleLoaderOptions,
} from "./node/bundle-loader.ts";
/** Node-only parts of Chord: child host processes, their manager, and the facet module loader. */
export { nodeLoader } from "./loader.ts";
export {
createFacetBundleArtifactLoader,
createFacetBundleLoader,
readFacetBundleArtifact,
readFacetBundleManifest,
} from "./node/bundle-loader.ts";
export type {
FacetBundleArtifact,
FacetBundleEntry,
FacetBundleManifest,
FacetBundlePlugin,
} from "./node/manifest.ts";
export {
FACET_BUNDLE_ARTIFACT_FORMAT,
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
FACET_BUNDLE_FORMAT,
FACET_BUNDLE_FORMAT_VERSION,
FACET_BUNDLE_MANIFEST_FILE,
} from "./node/manifest.ts";
type ControlMessage,
childControl,
createHostManager,
type Driver,
type Exit,
type ForkDriverOptions,
forkDriver,
type HostManager,
type HostManagerOptions,
type Instance,
type Liveness,
processLock,
} from "./manager.ts";

View File

@@ -1,416 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire, isBuiltin } from "node:module";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { compileFunction } from "node:vm";
import type { Facet, FacetLoader, LoadedFacets } from "../types.ts";
import {
FACET_BUNDLE_ARTIFACT_FORMAT,
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
FACET_BUNDLE_FORMAT,
FACET_BUNDLE_FORMAT_VERSION,
FACET_BUNDLE_MANIFEST_FILE,
type FacetBundleArtifact,
type FacetBundleEntry,
type FacetBundleManifest,
} from "./manifest.ts";
export type FacetBundleExternalResolver = (specifier: string) => string | URL | undefined;
export interface FacetBundleLoaderOptions {
readonly manifestPath: string | URL;
readonly entry: string;
/** Verify the entry's SHA-256 integrity before evaluating it. Defaults to true. */
readonly verifyIntegrity?: boolean;
/** Resolve host-provided external imports when the bundle is outside the host's package tree. */
readonly resolveExternal?: FacetBundleExternalResolver;
}
export interface FacetBundleArtifactLoaderOptions {
readonly artifact: unknown;
/** Resolve host-provided external imports against the receiving application. */
readonly resolveExternal?: FacetBundleExternalResolver;
/** Parent directory for materialized module generations. Defaults to the operating system temp directory. */
readonly temporaryDirectory?: string;
}
interface CommonJsModule {
exports: unknown;
}
/** Read and validate a versioned facet bundle manifest. */
export async function readFacetBundleManifest(path: string | URL): Promise<FacetBundleManifest> {
const manifestPath = toFilePath(path);
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (error) {
throw new Error(`Could not read facet bundle manifest ${manifestPath}`, { cause: error });
}
return validateManifest(parsed, manifestPath);
}
/** Read and verify one transportable entry from a facet bundle on disk. */
export async function readFacetBundleArtifact(options: {
readonly manifestPath: string | URL;
readonly entry: string;
}): Promise<FacetBundleArtifact> {
if (options.entry.length === 0) throw new TypeError("Facet bundle entry name must not be empty");
const manifestPath = toFilePath(options.manifestPath);
const manifest = await readFacetBundleManifest(manifestPath);
const entry = manifest.entries[options.entry];
if (entry === undefined) throw new Error(`Facet bundle ${manifest.plugin.id} has no entry named ${options.entry}`);
const modulePath = resolveBundleFile(manifestPath, entry.file, "entry");
const source = await readFile(modulePath, "utf8");
verifySource(source, entry);
const sourceMapContents =
entry.sourceMap === undefined
? undefined
: await readFile(resolveBundleFile(manifestPath, entry.sourceMap, "source map"), "utf8");
return Object.freeze({
format: FACET_BUNDLE_ARTIFACT_FORMAT,
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
plugin: manifest.plugin,
entryName: options.entry,
entry,
source,
...(sourceMapContents === undefined ? {} : { sourceMapContents }),
});
}
/** Materialize a transported artifact and create a fresh VM-compiled CommonJS generation for each load. */
export function createFacetBundleArtifactLoader(options: FacetBundleArtifactLoaderOptions): FacetLoader {
const artifact = validateArtifact(options.artifact);
const temporaryParent = resolve(options.temporaryDirectory ?? tmpdir());
return {
async load(): Promise<LoadedFacets> {
await mkdir(temporaryParent, { recursive: true });
const directory = await mkdtemp(join(temporaryParent, "chord-facet-"));
try {
await materializeArtifact(directory, artifact);
const loaded = await createFacetBundleLoader({
manifestPath: join(directory, FACET_BUNDLE_MANIFEST_FILE),
entry: artifact.entryName,
resolveExternal: options.resolveExternal,
}).load();
let disposed = false;
return {
get facets() {
return loaded.facets;
},
async dispose() {
if (disposed) return;
disposed = true;
const errors: unknown[] = [];
try {
await loaded.dispose();
} catch (error) {
errors.push(error);
}
try {
await rm(directory, { force: true, recursive: true });
} catch (error) {
errors.push(error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose facet bundle artifact");
},
};
} catch (error) {
try {
await rm(directory, { force: true, recursive: true });
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Facet bundle artifact loading and cleanup failed");
}
throw error;
}
},
};
}
/** Create a reusable loader for one opaque entry in a facet bundle manifest. */
export function createFacetBundleLoader(options: FacetBundleLoaderOptions): FacetLoader {
if (options.entry.length === 0) throw new TypeError("Facet bundle entry name must not be empty");
const manifestPath = toFilePath(options.manifestPath);
return {
async load(): Promise<LoadedFacets> {
const manifest = await readFacetBundleManifest(manifestPath);
const entry = manifest.entries[options.entry];
if (entry === undefined) {
throw new Error(`Facet bundle ${manifest.plugin.id} has no entry named ${options.entry}`);
}
const modulePath = resolveBundleFile(manifestPath, entry.file, "entry");
try {
const source = await readFile(modulePath, "utf8");
if (options.verifyIntegrity !== false) verifySource(source, entry);
const exported = executeCommonJsModule(source, modulePath, entry.externalImports, options.resolveExternal);
let facets = facetsFromModule(exported, manifest.plugin.id, options.entry);
let disposed = false;
return {
get facets() {
return facets;
},
async dispose() {
if (disposed) return;
disposed = true;
facets = Object.freeze([]);
},
};
} catch (error) {
const detail = error instanceof Error ? `: ${error.message}` : "";
throw new Error(`Could not load facet bundle entry ${manifest.plugin.id}/${options.entry}${detail}`, {
cause: error,
});
}
},
};
}
function executeCommonJsModule(
source: string,
modulePath: string,
externalImports: readonly string[],
resolver?: FacetBundleExternalResolver,
): unknown {
const requireFromModule = createRequire(modulePath);
const externalTargets = new Map<string, string>();
for (const specifier of externalImports) {
if (!isBuiltin(specifier)) validatePackageSpecifier(specifier);
externalTargets.set(specifier, toRequireSpecifier(resolveExternalTarget(specifier, resolver)));
}
const targetFor = (specifier: string): string => {
const target = externalTargets.get(specifier);
if (target === undefined) throw new Error(`Facet bundle required undeclared external import: ${specifier}`);
return target;
};
const requireExternal = Object.assign((specifier: string): unknown => requireFromModule(targetFor(specifier)), {
resolve: (specifier: string): string => requireFromModule.resolve(targetFor(specifier)),
});
const commonJsModule: CommonJsModule = { exports: Object.create(null) };
const compiled = compileFunction(source, ["exports", "require", "module", "__filename", "__dirname"], {
filename: modulePath,
});
Reflect.apply(compiled, commonJsModule.exports, [
commonJsModule.exports,
requireExternal,
commonJsModule,
modulePath,
dirname(modulePath),
]);
return commonJsModule.exports;
}
function toRequireSpecifier(target: string): string {
if (isAbsolute(target) || isBuiltin(target)) return target;
let url: URL;
try {
url = new URL(target);
} catch {
return target;
}
if (url.protocol === "file:") return fileURLToPath(url);
if (url.protocol === "node:") return url.href;
throw new Error(`Facet bundle external target must be a file, package, or built-in module: ${target}`);
}
function toFilePath(path: string | URL): string {
if (typeof path === "string") return resolve(path);
if (path.protocol !== "file:") throw new TypeError(`Facet bundle manifest must be a file URL, not ${path.protocol}`);
return fileURLToPath(path);
}
function resolveBundleFile(manifestPath: string, file: string, label: string): string {
if (file.length === 0 || isAbsolute(file) || basename(file) !== file || file === "." || file === "..") {
throw new Error(`Facet bundle ${label} must be a filename relative to its manifest`);
}
return resolve(dirname(manifestPath), file);
}
function verifySource(source: string, entry: FacetBundleEntry): void {
const expected = parseIntegrity(entry.integrity);
const actual = createHash("sha256").update(source, "utf8").digest("base64");
if (actual !== expected) throw new Error(`Facet bundle integrity check failed for ${entry.file}`);
}
function parseIntegrity(integrity: string): string {
const prefix = "sha256-";
if (!integrity.startsWith(prefix) || integrity.length === prefix.length) {
throw new Error("Facet bundle entry has an invalid SHA-256 integrity value");
}
return integrity.slice(prefix.length);
}
function facetsFromModule(imported: unknown, pluginId: string, entryName: string): readonly Facet[] {
if (!isRecord(imported)) throw new Error(`Facet bundle entry ${pluginId}/${entryName} did not export a module`);
const exported = imported.default;
const candidates: readonly unknown[] = Array.isArray(exported) ? (exported as readonly unknown[]) : [exported];
if (candidates.length === 0) {
throw new Error(`Facet bundle entry ${pluginId}/${entryName} exported no facets`);
}
const facets: Facet[] = [];
for (const candidate of candidates) {
if (!isRecord(candidate) || typeof candidate.id !== "string" || candidate.id.length === 0) {
throw new Error(`Facet bundle entry ${pluginId}/${entryName} has a facet with an invalid ID`);
}
if (typeof candidate.setup !== "function") {
throw new Error(`Facet bundle entry ${pluginId}/${entryName} facet ${candidate.id} has no setup function`);
}
facets.push(candidate as unknown as Facet);
}
const ids = facets.map(({ id }) => id);
if (new Set(ids).size !== ids.length) {
throw new Error(`Facet bundle entry ${pluginId}/${entryName} exports duplicate facet IDs`);
}
return Object.freeze(facets);
}
function validateArtifact(value: unknown): FacetBundleArtifact {
if (
!isRecord(value) ||
value.format !== FACET_BUNDLE_ARTIFACT_FORMAT ||
value.formatVersion !== FACET_BUNDLE_ARTIFACT_FORMAT_VERSION ||
typeof value.entryName !== "string" ||
value.entryName.length === 0 ||
typeof value.source !== "string"
) {
throw new Error("Invalid facet bundle artifact");
}
const manifest = validateManifest(
{
format: FACET_BUNDLE_FORMAT,
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
plugin: value.plugin,
entries: { [value.entryName]: value.entry },
},
"facet bundle artifact",
);
const entry = manifest.entries[value.entryName]!;
if (entry.sourceMap === undefined) {
if (value.sourceMapContents !== undefined) {
throw new Error("Facet bundle artifact has source map contents without a source map");
}
} else if (typeof value.sourceMapContents !== "string") {
throw new Error("Facet bundle artifact is missing its source map contents");
}
verifySource(value.source, entry);
return Object.freeze({
format: FACET_BUNDLE_ARTIFACT_FORMAT,
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
plugin: manifest.plugin,
entryName: value.entryName,
entry,
source: value.source,
...(value.sourceMapContents === undefined ? {} : { sourceMapContents: value.sourceMapContents }),
});
}
async function materializeArtifact(directory: string, artifact: FacetBundleArtifact): Promise<void> {
const manifest: FacetBundleManifest = {
format: FACET_BUNDLE_FORMAT,
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
plugin: artifact.plugin,
entries: { [artifact.entryName]: artifact.entry },
};
await Promise.all([
writeFile(join(directory, artifact.entry.file), artifact.source, "utf8"),
writeFile(join(directory, FACET_BUNDLE_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
...(artifact.entry.sourceMap === undefined
? []
: [writeFile(join(directory, artifact.entry.sourceMap), artifact.sourceMapContents!, "utf8")]),
]);
}
function resolveExternalTarget(specifier: string, resolver?: FacetBundleExternalResolver): string {
const resolved = resolver?.(specifier);
if (resolved !== undefined) return typeof resolved === "string" ? resolved : resolved.href;
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
if (specifier === "@earendil-works/chord") return new URL(`../index.${extension}`, import.meta.url).href;
if (specifier === "@earendil-works/chord/context") {
return new URL(`../context/index.${extension}`, import.meta.url).href;
}
if (specifier === "@earendil-works/chord/node") return new URL(`../node.${extension}`, import.meta.url).href;
return import.meta.resolve(specifier);
}
function validatePackageSpecifier(specifier: string): void {
const segments = specifier.split("/");
const packageParts = specifier.startsWith("@") ? 2 : 1;
if (
specifier.startsWith(".") ||
specifier.startsWith("/") ||
specifier.startsWith("#") ||
specifier.includes(":") ||
specifier.includes("\\") ||
segments.length < packageParts ||
segments.some((part) => part.length === 0 || part === "." || part === "..")
) {
throw new Error(`Facet bundle artifact has an unsupported external import: ${specifier}`);
}
}
function validateManifest(value: unknown, path: string): FacetBundleManifest {
if (!isRecord(value) || value.format !== FACET_BUNDLE_FORMAT) {
throw new Error(`Invalid facet bundle manifest format in ${path}`);
}
if (value.formatVersion !== FACET_BUNDLE_FORMAT_VERSION) {
throw new Error(`Unsupported facet bundle manifest version in ${path}: ${String(value.formatVersion)}`);
}
if (!isRecord(value.plugin) || typeof value.plugin.id !== "string" || value.plugin.id.length === 0) {
throw new Error(`Facet bundle manifest has an invalid plugin identity in ${path}`);
}
if (
value.plugin.version !== undefined &&
(typeof value.plugin.version !== "string" || value.plugin.version.length === 0)
) {
throw new Error(`Facet bundle manifest has an invalid plugin version in ${path}`);
}
if (!isRecord(value.entries) || Object.keys(value.entries).length === 0) {
throw new Error(`Facet bundle manifest has no entries in ${path}`);
}
const entries: Record<string, FacetBundleEntry> = {};
for (const [name, candidate] of Object.entries(value.entries)) {
if (name.length === 0 || !isRecord(candidate)) {
throw new Error(`Facet bundle manifest has an invalid entry in ${path}`);
}
if (typeof candidate.file !== "string") throw new Error(`Facet bundle entry ${name} has no file`);
resolveBundleFile(path, candidate.file, `entry ${name}`);
if (typeof candidate.integrity !== "string") throw new Error(`Facet bundle entry ${name} has no integrity`);
parseIntegrity(candidate.integrity);
if (
!Array.isArray(candidate.externalImports) ||
candidate.externalImports.some((item) => typeof item !== "string")
) {
throw new Error(`Facet bundle entry ${name} has invalid external imports`);
}
const externalImports = Object.freeze([...(candidate.externalImports as readonly string[])]);
if (new Set(externalImports).size !== externalImports.length) {
throw new Error(`Facet bundle entry ${name} has duplicate external imports`);
}
if (candidate.sourceMap !== undefined) {
if (typeof candidate.sourceMap !== "string")
throw new Error(`Facet bundle entry ${name} has an invalid source map`);
resolveBundleFile(path, candidate.sourceMap, `entry ${name} source map`);
}
entries[name] = Object.freeze({
file: candidate.file,
integrity: candidate.integrity,
externalImports,
...(candidate.sourceMap === undefined ? {} : { sourceMap: candidate.sourceMap }),
});
}
return Object.freeze({
format: FACET_BUNDLE_FORMAT,
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
plugin: Object.freeze({
id: value.plugin.id,
...(value.plugin.version === undefined ? {} : { version: value.plugin.version }),
}),
entries: Object.freeze(entries),
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -1,222 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { type BuildOptions, build, type Message } from "esbuild";
import {
FACET_BUNDLE_FORMAT,
FACET_BUNDLE_FORMAT_VERSION,
FACET_BUNDLE_MANIFEST_FILE,
type FacetBundleEntry,
type FacetBundleManifest,
} from "./manifest.ts";
export type FacetBundlePlatform = "node" | "browser" | "neutral";
export interface BundleFacetsOptions {
readonly plugin: {
readonly id: string;
readonly version?: string;
};
/** Opaque application-selected entry names mapped to TypeScript or JavaScript source files. */
readonly entries: Readonly<Record<string, string>>;
readonly outdir: string;
readonly workingDirectory?: string;
/** Additional package imports intentionally left for the loading application to resolve. */
readonly external?: readonly string[];
readonly sourceMap?: boolean;
readonly minify?: boolean;
readonly define?: Readonly<Record<string, string>>;
readonly platform?: FacetBundlePlatform;
readonly target?: string | readonly string[];
}
export interface BundleFacetsResult {
readonly manifest: FacetBundleManifest;
readonly manifestPath: string;
}
/** Bundle each opaque facet entry into an independent content-addressed CommonJS file. */
export async function bundleFacets(options: BundleFacetsOptions): Promise<BundleFacetsResult> {
validateOptions(options);
const workingDirectory = resolve(options.workingDirectory ?? process.cwd());
const outputDirectory = resolve(workingDirectory, options.outdir);
const outputParent = dirname(outputDirectory);
await mkdir(outputParent, { recursive: true });
const temporaryDirectory = join(outputParent, `.${basename(outputDirectory)}.tmp-${randomUUID()}`);
await mkdir(temporaryDirectory);
try {
const entries: Record<string, FacetBundleEntry> = {};
for (const [entryName, source] of Object.entries(options.entries).sort(([left], [right]) =>
left.localeCompare(right),
)) {
entries[entryName] = await bundleEntry({
entryName,
source: resolve(workingDirectory, source),
temporaryDirectory,
workingDirectory,
options,
});
}
const manifest: FacetBundleManifest = Object.freeze({
format: FACET_BUNDLE_FORMAT,
formatVersion: FACET_BUNDLE_FORMAT_VERSION,
plugin: Object.freeze({
id: options.plugin.id,
...(options.plugin.version === undefined ? {} : { version: options.plugin.version }),
}),
entries: Object.freeze(entries),
});
await writeFile(join(temporaryDirectory, FACET_BUNDLE_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
await replaceDirectory(temporaryDirectory, outputDirectory);
return Object.freeze({
manifest,
manifestPath: join(outputDirectory, FACET_BUNDLE_MANIFEST_FILE),
});
} catch (error) {
await rm(temporaryDirectory, { force: true, recursive: true });
throw error;
}
}
async function bundleEntry(input: {
readonly entryName: string;
readonly source: string;
readonly temporaryDirectory: string;
readonly workingDirectory: string;
readonly options: BundleFacetsOptions;
}): Promise<FacetBundleEntry> {
const entryPrefix = `facet-${shortHash(input.entryName)}`;
const requestedPlatform = input.options.platform ?? "node";
const buildOptions: BuildOptions = {
absWorkingDir: input.workingDirectory,
banner: { js: '"use strict";' },
bundle: true,
define: input.options.define,
entryNames: `${entryPrefix}-[hash]`,
entryPoints: [input.source],
external: [...new Set(["@earendil-works/chord", "@earendil-works/chord/*", ...(input.options.external ?? [])])],
format: "cjs",
legalComments: "none",
logLevel: "silent",
metafile: true,
minify: input.options.minify ?? false,
outdir: input.temporaryDirectory,
outExtension: { ".js": ".cjs" },
platform: requestedPlatform,
sourcemap: input.options.sourceMap === true ? "external" : false,
supported: { "dynamic-import": false },
target:
input.options.target === undefined
? requestedPlatform === "node"
? "node22.19"
: "es2022"
: typeof input.options.target === "string"
? input.options.target
: [...input.options.target],
write: true,
};
let result: Awaited<ReturnType<typeof build>>;
try {
result = await build(buildOptions);
} catch (error) {
const diagnostics = esbuildMessages(error);
const detail = diagnostics.length === 0 ? "" : `\n${diagnostics.join("\n")}`;
throw new Error(`Could not bundle facet entry ${input.entryName}${detail}`, { cause: error });
}
const metafile = result.metafile;
if (metafile === undefined) throw new Error(`Facet entry ${input.entryName} produced no build metadata`);
const outputs = Object.entries(metafile.outputs).filter(
([path, output]) => output.entryPoint !== undefined && extname(path) === ".cjs",
);
if (outputs.length !== 1)
throw new Error(`Facet entry ${input.entryName} did not produce exactly one JavaScript file`);
const [outputPath, metadata] = outputs[0]!;
const absoluteOutputPath = resolve(input.workingDirectory, outputPath);
const file = relative(input.temporaryDirectory, absoluteOutputPath);
if (file.length === 0 || file.startsWith(`..${sep}`) || basename(file) !== file) {
throw new Error(`Facet entry ${input.entryName} produced an invalid output path`);
}
const sourceMap = input.options.sourceMap === true ? `${file}.map` : undefined;
const allowedOutputs = new Set([absoluteOutputPath]);
if (sourceMap !== undefined) allowedOutputs.add(join(input.temporaryDirectory, sourceMap));
const unexpectedOutputs = Object.keys(metafile.outputs)
.map((path) => resolve(input.workingDirectory, path))
.filter((path) => !allowedOutputs.has(path));
if (unexpectedOutputs.length > 0) {
throw new Error(`Facet entry ${input.entryName} produced files other than its JavaScript bundle and source map`);
}
const contents = await readFile(absoluteOutputPath);
if (sourceMap !== undefined) await stat(join(input.temporaryDirectory, sourceMap));
return Object.freeze({
file,
integrity: `sha256-${createHash("sha256").update(contents).digest("base64")}`,
externalImports: Object.freeze(
[...new Set(metadata.imports.filter(({ external }) => external).map(({ path }) => path))].sort(),
),
...(sourceMap === undefined ? {} : { sourceMap }),
});
}
function validateOptions(options: BundleFacetsOptions): void {
if (options.plugin.id.length === 0) throw new TypeError("Facet bundle plugin ID must not be empty");
if (options.plugin.version !== undefined && options.plugin.version.length === 0) {
throw new TypeError("Facet bundle plugin version must not be empty");
}
const entries = Object.entries(options.entries);
if (entries.length === 0) throw new TypeError("Facet bundle must contain at least one entry");
for (const [name, source] of entries) {
if (name.length === 0) throw new TypeError("Facet bundle entry name must not be empty");
if (source.length === 0) throw new TypeError(`Facet bundle entry ${name} must have a source path`);
}
for (const external of options.external ?? []) {
if (external.length === 0) throw new TypeError("Facet bundle external import must not be empty");
}
}
async function replaceDirectory(temporaryDirectory: string, outputDirectory: string): Promise<void> {
const backupDirectory = `${outputDirectory}.old-${randomUUID()}`;
let movedExisting = false;
try {
await rename(outputDirectory, backupDirectory);
movedExisting = true;
} catch (error) {
if (!isMissingPath(error)) throw error;
}
try {
await rename(temporaryDirectory, outputDirectory);
} catch (error) {
if (movedExisting) await rename(backupDirectory, outputDirectory);
throw error;
}
if (movedExisting) await rm(backupDirectory, { force: true, recursive: true });
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
function esbuildMessages(error: unknown): string[] {
if (!isRecord(error) || !Array.isArray(error.errors)) return [];
return (error.errors as readonly unknown[]).flatMap((candidate) =>
isEsbuildMessage(candidate) ? [formatEsbuildMessage(candidate)] : [],
);
}
function formatEsbuildMessage(message: Message): string {
const location = message.location;
return location === null
? message.text
: `${location.file}:${location.line}:${location.column + 1}: ${message.text}`;
}
function isEsbuildMessage(value: unknown): value is Message {
return isRecord(value) && typeof value.text === "string" && (value.location === null || isRecord(value.location));
}
function isMissingPath(error: unknown): boolean {
return isRecord(error) && error.code === "ENOENT";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -1,39 +0,0 @@
export const FACET_BUNDLE_FORMAT = "chord.facet-bundle";
export const FACET_BUNDLE_FORMAT_VERSION = 2;
export const FACET_BUNDLE_MANIFEST_FILE = "chord-facets.json";
export const FACET_BUNDLE_ARTIFACT_FORMAT = "chord.facet-bundle-artifact";
export const FACET_BUNDLE_ARTIFACT_FORMAT_VERSION = 2;
export interface FacetBundleEntry {
/** Content-addressed CommonJS filename relative to the manifest. */
readonly file: string;
/** SHA-256 subresource-integrity value for the JavaScript file. */
readonly integrity: string;
/** Imports intentionally left for the loading application to resolve. */
readonly externalImports: readonly string[];
/** Source map filename relative to the manifest, when emitted. */
readonly sourceMap?: string;
}
export interface FacetBundleManifest {
readonly format: typeof FACET_BUNDLE_FORMAT;
readonly formatVersion: typeof FACET_BUNDLE_FORMAT_VERSION;
readonly plugin: FacetBundlePlugin;
readonly entries: Readonly<Record<string, FacetBundleEntry>>;
}
export interface FacetBundlePlugin {
readonly id: string;
readonly version?: string;
}
/** One self-contained manifest entry suitable for storage or transport to another Node host. */
export interface FacetBundleArtifact {
readonly format: typeof FACET_BUNDLE_ARTIFACT_FORMAT;
readonly formatVersion: typeof FACET_BUNDLE_ARTIFACT_FORMAT_VERSION;
readonly plugin: FacetBundlePlugin;
readonly entryName: string;
readonly entry: FacetBundleEntry;
readonly source: string;
readonly sourceMapContents?: string;
}

View File

@@ -1,232 +0,0 @@
import { readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { type BundleFacetsResult, bundleFacets } from "./bundle.ts";
export interface BundleFacetPackageOptions {
/** Plugin package directory or its package.json path. */
readonly packagePath: string;
readonly outdir: string;
/** Application conventions applied when the corresponding source file exists. */
readonly defaultFacets?: Readonly<Record<string, string>>;
}
export interface BundleFacetPackageResult extends BundleFacetsResult {
readonly packageDirectory: string;
readonly packageJsonPath: string;
}
interface FacetPackageMetadata {
readonly packageDirectory: string;
readonly packageJsonPath: string;
readonly name: string;
readonly version: string;
readonly peerDependencies: readonly string[];
readonly configuredFacets: Readonly<Record<string, string | false>>;
readonly external: readonly string[];
readonly sourceMap: boolean;
}
/** Build a plugin package using package.json metadata and application-provided facet conventions. */
export async function bundleFacetPackage(options: BundleFacetPackageOptions): Promise<BundleFacetPackageResult> {
const metadata = await readFacetPackageMetadata(options.packagePath);
const entries = await resolveFacetEntries(metadata, options.defaultFacets ?? {});
const external = [...metadata.peerDependencies, ...metadata.external].flatMap((specifier) => [
specifier,
`${specifier}/*`,
]);
const result = await bundleFacets({
plugin: { id: metadata.name, version: metadata.version },
entries,
outdir: options.outdir,
workingDirectory: metadata.packageDirectory,
external,
sourceMap: metadata.sourceMap,
});
return Object.freeze({
...result,
packageDirectory: metadata.packageDirectory,
packageJsonPath: metadata.packageJsonPath,
});
}
async function readFacetPackageMetadata(packagePath: string): Promise<FacetPackageMetadata> {
if (packagePath.length === 0) throw new TypeError("Facet package path must not be empty");
const candidate = resolve(packagePath);
let packageDirectory: string;
let packageJsonPath: string;
let candidateStats: Awaited<ReturnType<typeof stat>>;
try {
candidateStats = await stat(candidate);
} catch (error) {
throw new Error(`Could not access facet package ${candidate}`, { cause: error });
}
if (candidateStats.isDirectory()) {
packageDirectory = await realpath(candidate);
packageJsonPath = join(packageDirectory, "package.json");
} else if (candidateStats.isFile() && basename(candidate) === "package.json") {
packageJsonPath = await realpath(candidate);
packageDirectory = dirname(packageJsonPath);
} else {
throw new Error(`Facet package path must name a directory or package.json: ${candidate}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(packageJsonPath, "utf8"));
} catch (error) {
throw new Error(`Could not read facet package metadata ${packageJsonPath}`, { cause: error });
}
if (!isRecord(parsed)) throw new Error(`Facet package metadata must be an object: ${packageJsonPath}`);
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
throw new Error(`Facet package must have a non-empty name: ${packageJsonPath}`);
}
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
throw new Error(`Facet package must have a non-empty version: ${packageJsonPath}`);
}
const peerDependencies = parsePeerDependencies(parsed.peerDependencies, packageJsonPath);
const chord = parseChordConfiguration(parsed.chord, packageJsonPath);
return {
packageDirectory,
packageJsonPath,
name: parsed.name,
version: parsed.version,
peerDependencies,
configuredFacets: chord.facets,
external: chord.external,
sourceMap: chord.sourceMap,
};
}
function parsePeerDependencies(value: unknown, packageJsonPath: string): readonly string[] {
if (value === undefined) return [];
if (
!isRecord(value) ||
Object.entries(value).some(([name, version]) => name.length === 0 || typeof version !== "string")
) {
throw new Error(`Facet package has invalid peerDependencies: ${packageJsonPath}`);
}
return Object.freeze(Object.keys(value).sort());
}
function parseChordConfiguration(
value: unknown,
packageJsonPath: string,
): {
readonly facets: Readonly<Record<string, string | false>>;
readonly external: readonly string[];
readonly sourceMap: boolean;
} {
if (value === undefined) return { facets: Object.freeze({}), external: Object.freeze([]), sourceMap: true };
if (!isRecord(value)) throw new Error(`Facet package chord configuration must be an object: ${packageJsonPath}`);
if (Object.keys(value).some((key) => key !== "facets" && key !== "external" && key !== "sourceMap")) {
throw new Error(`Facet package chord configuration has an unknown field: ${packageJsonPath}`);
}
const facets: Record<string, string | false> = {};
if (value.facets !== undefined) {
if (!isRecord(value.facets)) {
throw new Error(`Facet package chord.facets must be an object: ${packageJsonPath}`);
}
for (const [name, source] of Object.entries(value.facets)) {
if (name.length === 0 || (typeof source !== "string" && source !== false) || source === "") {
throw new Error(`Facet package has an invalid chord.facets entry: ${packageJsonPath}`);
}
facets[name] = source;
}
}
let external: readonly string[] = [];
if (value.external !== undefined) {
if (
!Array.isArray(value.external) ||
value.external.some((specifier) => typeof specifier !== "string" || specifier.length === 0)
) {
throw new Error(`Facet package chord.external must contain non-empty strings: ${packageJsonPath}`);
}
external = Object.freeze([...new Set(value.external as readonly string[])].sort());
}
if (value.sourceMap !== undefined && typeof value.sourceMap !== "boolean") {
throw new Error(`Facet package chord.sourceMap must be a boolean: ${packageJsonPath}`);
}
return {
facets: Object.freeze(facets),
external,
sourceMap: value.sourceMap ?? true,
};
}
async function resolveFacetEntries(
metadata: FacetPackageMetadata,
defaultFacets: Readonly<Record<string, string>>,
): Promise<Readonly<Record<string, string>>> {
const entries: Record<string, string> = {};
for (const [name, source] of Object.entries(defaultFacets)) {
validateFacetMapping(name, source, "default");
const path = resolvePackageEntry(metadata.packageDirectory, source, name);
try {
const entryStats = await stat(path);
if (!entryStats.isFile()) throw new Error(`Default facet entry ${name} is not a file: ${path}`);
const canonicalPath = await realpath(path);
validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name);
entries[name] = canonicalPath;
} catch (error) {
if (isMissingPath(error)) continue;
throw error;
}
}
for (const [name, source] of Object.entries(metadata.configuredFacets)) {
if (source === false) {
delete entries[name];
continue;
}
validateFacetMapping(name, source, "configured");
const path = resolvePackageEntry(metadata.packageDirectory, source, name);
let entryStats: Awaited<ReturnType<typeof stat>>;
try {
entryStats = await stat(path);
} catch (error) {
throw new Error(`Could not access configured facet entry ${name}: ${path}`, { cause: error });
}
if (!entryStats.isFile()) throw new Error(`Configured facet entry ${name} is not a file: ${path}`);
const canonicalPath = await realpath(path);
validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name);
entries[name] = canonicalPath;
}
if (Object.keys(entries).length === 0) {
throw new Error(`Facet package ${metadata.name} has no configured or conventional facet entries`);
}
return Object.freeze(entries);
}
function validateFacetMapping(name: string, source: string, kind: string): void {
if (name.length === 0) throw new Error(`Facet package ${kind} entry name must not be empty`);
if (source.length === 0) throw new Error(`Facet package ${kind} entry ${name} must have a source path`);
}
function resolvePackageEntry(packageDirectory: string, source: string, name: string): string {
if (isAbsolute(source)) throw new Error(`Facet package entry ${name} must be relative to the package directory`);
const path = resolve(packageDirectory, source);
const relativePath = relative(packageDirectory, path);
if (
relativePath.length === 0 ||
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
) {
throw new Error(`Facet package entry ${name} escapes the package directory`);
}
return path;
}
function validateCanonicalPackageEntry(packageDirectory: string, path: string, name: string): void {
const relativePath = relative(packageDirectory, path);
if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
throw new Error(`Facet package entry ${name} resolves outside the package directory`);
}
}
function isMissingPath(error: unknown): boolean {
return isRecord(error) && error.code === "ENOENT";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
/**
* Bounded output streams and adaptive progress commits: `OutputBuffer` keeps a head or tail window of a stream at a cost
* proportional to each chunk, and `Progress` paces commits of a changing value by their size.
*/
/** Retention limits of one output stream. */
export type OutputLimits = { readonly maxBytes: number; readonly maxLines: number; readonly retain: "head" | "tail" };
/** Retained output and what the limits dropped. */
export type BoundedOutput = { readonly text: string; readonly droppedBytes: number; readonly droppedLines: number };
/** An exact slice of the input within the limits, and what it left out. */
export type OutputSlice = {
readonly text: string;
readonly bytes: number;
readonly droppedBytes: number;
readonly droppedLines: number;
};
/**
* Output a producer omitted before a chunk, measured on the decoded text: every U+FFFD counts as three bytes, and no
* sanitizing is applied.
*/
export interface OutputSkip {
/** UTF-8 byte length of the omitted text. */
readonly bytes: number;
/** Newlines (U+000A) in the omitted text. */
readonly newlines: number;
/** Whether the omitted text ends with a newline. */
readonly endsWithNewline: boolean;
}
const NEWLINE = 0x0a;
const INVALID_OUTPUT = /[\x00-\x08\x0b-\x1f\ufff9-\ufffb]/g;
const encoder = new TextEncoder();
/** Slices decode exactly: a U+FEFF at a slice's start is text, not a byte-order mark. */
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
/** Remove control characters that break display and transcripts; tabs and newlines stay. */
export function sanitizeOutput(text: string): string {
return text.replace(INVALID_OUTPUT, "");
}
/**
* Bound `text` to whole lines within the limits: the first lines for `head`, the last lines for `tail`. The result is an
* exact slice, trailing newline included. A single line longer than `maxBytes` is cut at the byte limit on a character
* boundary.
*/
export function boundOutput(text: string, limits: OutputLimits): OutputSlice {
const bytes = encoder.encode(text);
const [from, to] = limits.retain === "head" ? headRange(bytes, limits) : tailRange(bytes, limits);
const kept = bytes.subarray(from, to);
return {
text: kept.length === bytes.length ? text : decoder.decode(kept),
bytes: kept.length,
droppedBytes: bytes.length - kept.length,
droppedLines: lineCount(bytes) - lineCount(kept),
};
}
function headRange(bytes: Uint8Array, limits: OutputLimits): [number, number] {
if (limits.maxLines === 0 || limits.maxBytes === 0) return [0, 0];
let end = bytes.length;
let lines = 0;
for (let index = bytes.indexOf(NEWLINE); index !== -1; index = bytes.indexOf(NEWLINE, index + 1)) {
if (++lines === limits.maxLines) {
end = index + 1;
break;
}
}
if (end > limits.maxBytes) {
const newline = bytes.lastIndexOf(NEWLINE, limits.maxBytes - 1);
end = newline === -1 ? characterEnd(bytes, limits.maxBytes) : newline + 1;
}
return [0, end];
}
function tailRange(bytes: Uint8Array, limits: OutputLimits): [number, number] {
if (limits.maxLines === 0 || limits.maxBytes === 0) return [bytes.length, bytes.length];
// A trailing newline ends the last line rather than starting another.
const last = bytes[bytes.length - 1] === NEWLINE ? bytes.length - 2 : bytes.length - 1;
let start = 0;
let lines = 1;
for (let index = last < 0 ? -1 : bytes.lastIndexOf(NEWLINE, last); index !== -1; ) {
if (lines === limits.maxLines) {
start = index + 1;
break;
}
lines++;
index = index === 0 ? -1 : bytes.lastIndexOf(NEWLINE, index - 1);
}
if (bytes.length - start > limits.maxBytes) {
const from = bytes.length - limits.maxBytes;
const newline = bytes.indexOf(NEWLINE, from - 1);
// The first line starting inside the byte window, or a cut of the last line when it alone is too long.
start = newline !== -1 && newline + 1 < bytes.length ? newline + 1 : characterStart(bytes, from);
}
return [start, bytes.length];
}
/** The last character boundary at or before `index`. */
export function characterEnd(bytes: Uint8Array, index: number): number {
let end = index;
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end--;
return end;
}
/** The first character boundary at or after `index`. */
function characterStart(bytes: Uint8Array, index: number): number {
let start = index;
while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++;
return start;
}
function lineCount(bytes: Uint8Array): number {
if (bytes.length === 0) return 0;
let newlines = 0;
for (let index = bytes.indexOf(NEWLINE); index !== -1; index = bytes.indexOf(NEWLINE, index + 1)) newlines++;
return newlines + (bytes[bytes.length - 1] === NEWLINE ? 0 : 1);
}
/**
* Bounded running output of one stream, such as a tool call's or a process's. Accepting a chunk costs time proportional to the chunk: head retention stops
* storing once the window is full, and tail retention drops stored text the window no longer needs when it snapshots.
* Counts of the whole stream are kept so the dropped totals stay exact.
*/
export class OutputBuffer {
readonly #limits: OutputLimits;
/**
* Decoder of byte chunks. A string chunk or a skip ends an incomplete character of earlier bytes (it becomes U+FFFD);
* the next byte chunk then starts a new character. As in `StreamDecoder`, only a byte-order mark at the very start of
* the output is dropped, never a U+FEFF later in it.
*/
readonly #decoder = new TextDecoder("utf-8", { ignoreBOM: true });
#started = false;
/** Stored chunks: for head the start of the stream, for tail a suffix that still contains the next window. */
#chunks: { readonly text: string; readonly bytes: number; readonly newlines: number }[] = [];
#storedBytes = 0;
#storedNewlines = 0;
#full = false;
#totalBytes = 0;
#totalNewlines = 0;
#endsWithNewline = true;
constructor(limits: OutputLimits) {
this.#limits = limits;
}
/** Bytes currently held; bounded by the limits plus one chunk. */
get storedBytes(): number {
return this.#storedBytes;
}
/**
* Accept a chunk; returns whether anything was accepted. `skipped` is output omitted right before the chunk, which
* must be more than the tail window by at least one byte or line; only tail retention accepts it.
*/
push(chunk: string | Uint8Array, skipped?: OutputSkip): boolean {
// Bytes of an incomplete character from an earlier byte chunk come first.
const pending = typeof chunk === "string" || skipped !== undefined ? this.#decoder.decode() : "";
let text = typeof chunk === "string" ? chunk : this.#decoder.decode(chunk, { stream: true });
const first = !this.#started && pending === "" && skipped === undefined;
if (pending !== "" || text !== "" || skipped !== undefined) this.#started = true;
if (first && typeof chunk !== "string" && text.startsWith("\ufeff")) text = text.slice(1);
if (skipped === undefined) return this.#accept(pending + text);
if (this.#limits.retain !== "tail") throw new Error("Skipped output requires tail retention");
this.#accept(pending);
this.#skip(skipped);
this.#accept(text);
return true;
}
/** Count omitted output; nothing stored before it can be in the window once the text after it arrives. */
#skip(skipped: OutputSkip): void {
if (skipped.bytes === 0) return;
this.#totalBytes += skipped.bytes;
this.#totalNewlines += skipped.newlines;
this.#endsWithNewline = skipped.endsWithNewline;
this.#chunks = [];
this.#storedBytes = 0;
this.#storedNewlines = 0;
}
/** Flush an incomplete trailing character as a replacement character; call when the stream ends. */
end(): void {
this.#accept(this.#decoder.decode());
}
#accept(text: string): boolean {
if (text.length === 0) return false;
const bytes = utf8ByteLength(text);
const newlines = countNewlines(text);
this.#totalBytes += bytes;
this.#totalNewlines += newlines;
this.#endsWithNewline = text.endsWith("\n");
if (this.#full) return true;
this.#chunks.push({ text, bytes, newlines });
this.#storedBytes += bytes;
this.#storedNewlines += newlines;
if (this.#limits.retain === "head") {
// Nothing past a full window is ever needed.
this.#full = this.#storedBytes > this.#limits.maxBytes || this.#storedNewlines >= this.#limits.maxLines;
return true;
}
// Drop leading chunks while the rest still holds more than a window: more than `maxBytes` bytes or `maxLines`
// newlines, plus one, so the window's line start can still be found. Each chunk is dropped once.
while (this.#chunks.length > 1) {
const first = this.#chunks[0]!;
const bytesAfter = this.#storedBytes - first.bytes;
const newlinesAfter = this.#storedNewlines - first.newlines;
if (bytesAfter <= this.#limits.maxBytes + 1 && newlinesAfter <= this.#limits.maxLines + 1) break;
this.#chunks.shift();
this.#storedBytes = bytesAfter;
this.#storedNewlines = newlinesAfter;
}
return true;
}
/** Retained, sanitized output and what the limits dropped from the whole stream. */
snapshot(): BoundedOutput {
const stored =
this.#chunks.length === 1 ? this.#chunks[0]!.text : this.#chunks.map((chunk) => chunk.text).join("");
const kept = boundOutput(stored, this.#limits);
const storedLines = lines(this.#storedNewlines, stored === "" || stored.endsWith("\n"));
const keptLines = storedLines - kept.droppedLines;
// Tail windows never reach back before this one, but finding a later window's first line needs what precedes it:
// keep the shortest suffix longer than the window by a byte or a line, as `#accept` does.
if (this.#limits.retain === "tail" || this.#chunks.length > 1) {
const text = this.#limits.retain === "tail" ? tailMargin(stored, this.#limits) : stored;
const bytes = this.#limits.retain === "tail" ? utf8ByteLength(text) : this.#storedBytes;
this.#chunks = text === "" ? [] : [{ text, bytes, newlines: countNewlines(text) }];
this.#storedBytes = bytes;
this.#storedNewlines = this.#chunks[0]?.newlines ?? 0;
}
return {
text: sanitizeOutput(kept.text),
droppedBytes: this.#totalBytes - kept.bytes,
droppedLines: lines(this.#totalNewlines, this.#endsWithNewline) - keptLines,
};
}
}
/**
* The shortest suffix of `text` with more than `maxBytes` bytes or more than `maxLines` newlines, or all of it. The
* tail window of any text that ends with this suffix, followed by anything, is the same as of `text` followed by it.
*/
function tailMargin(text: string, limits: OutputLimits): string {
const bytes = encoder.encode(text);
const byteStart = bytes.length > limits.maxBytes ? characterEnd(bytes, bytes.length - limits.maxBytes - 1) : 0;
let lineStart = 0;
let newlines = 0;
for (let index = bytes.lastIndexOf(NEWLINE); index !== -1; index = bytes.lastIndexOf(NEWLINE, index - 1)) {
if (++newlines > limits.maxLines) {
lineStart = index;
break;
}
if (index === 0) break;
}
const start = Math.max(byteStart, lineStart);
return start === 0 ? text : decoder.decode(bytes.subarray(start));
}
/** Lines of text with `newlines` newlines; a final unterminated line counts. */
function lines(newlines: number, terminated: boolean): number {
return newlines + (terminated ? 0 : 1);
}
interface RuntimeBuffer {
byteLength(content: string, encoding: "utf8"): number;
}
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
const nonAsciiPattern = /[^\x00-\x7f]/;
/** UTF-8 byte length of `content`; a lone surrogate counts as U+FFFD, three bytes. */
export function utf8ByteLength(content: string): number {
if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8");
const firstNonAscii = content.search(nonAsciiPattern);
if (firstNonAscii === -1) return content.length;
let bytes = firstNonAscii;
for (let i = firstNonAscii; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code <= 0x7f) bytes += 1;
else if (code <= 0x7ff) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
i++;
} else bytes += 3;
} else bytes += 3;
}
return bytes;
}
function countNewlines(text: string): number {
let count = 0;
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count++;
return count;
}
/** Each progress commit also buys a pause proportional to what it wrote. */
export const PROGRESS_BYTES_PER_SECOND = 100 * 1024;
/**
* Adaptive progress commits: the first change after an idle period commits at once; each commit then delays the next
* by at least `minIntervalMs` and by its written size at 100 KiB/s. At most one commit is in flight; changes made
* meanwhile coalesce into the next one.
*/
export class Progress {
readonly #write: () => Promise<number>;
readonly #onError: (error: unknown) => void;
readonly #minIntervalMs: number;
#waiters: PromiseWithResolvers<void>[] = [];
#timer: ReturnType<typeof setTimeout> | undefined;
#inFlight: Promise<void> | undefined;
#nextAt = 0;
#dirty = false;
#stopped = false;
constructor(write: () => Promise<number>, onError: (error: unknown) => void, minIntervalMs: number) {
this.#write = write;
this.#onError = onError;
this.#minIntervalMs = minIntervalMs;
}
/** Schedule a commit. */
mark(): void {
this.#dirty = true;
this.#schedule();
}
/** Schedule a commit; the promise settles with the commit that includes this change. */
markAndWait(): Promise<void> {
const waiter = Promise.withResolvers<void>();
this.#waiters.push(waiter);
this.mark();
return waiter.promise;
}
/** Stop committing and wait for the commit in flight; returns the waiters the final commit must settle. */
async stop(): Promise<PromiseWithResolvers<void>[]> {
this.#stopped = true;
clearTimeout(this.#timer);
this.#timer = undefined;
await this.#inFlight;
return this.#waiters.splice(0);
}
#schedule(): void {
if (this.#stopped || this.#timer !== undefined || this.#inFlight !== undefined) return;
const wait = this.#nextAt - Date.now();
if (wait <= 0) this.#flush();
else
this.#timer = setTimeout(() => {
this.#timer = undefined;
this.#flush();
}, wait);
}
#flush(): void {
if (this.#stopped || !this.#dirty) return;
this.#dirty = false;
const waiters = this.#waiters.splice(0);
const started = Date.now();
this.#inFlight = this.#write()
.then(
(bytes) => {
this.#nextAt = started + Math.max(this.#minIntervalMs, (bytes * 1000) / PROGRESS_BYTES_PER_SECOND);
for (const waiter of waiters) waiter.resolve();
},
(error: unknown) => {
this.#nextAt = started + this.#minIntervalMs;
for (const waiter of waiters) waiter.reject(error);
this.#onError(error);
},
)
.finally(() => {
this.#inFlight = undefined;
if (this.#dirty) this.#schedule();
});
}
}

View File

@@ -0,0 +1,39 @@
// One FIFO queue for every observer notification (state events, shape pushes, registry tables, listener
// invocations).
//
// Problem: notifying observers synchronously from inside a write lets a re-entrant write overtake the write being
// published. Example: subscriber A reacts to n = 1 by writing n = 2; subscriber B then sees 2 before 1.
// Solution: a publication first adopts every revision it makes (cells and derivations), then enqueues its
// notifications. Notifications run in enqueue order, so every observer sees revisions in adoption order.
// `holdPublications` defers delivery until a multi-step change (a reload commit) is complete.
const queue: (() => void)[] = [];
let held = 0;
let draining = false;
/** Runs `task` after everything enqueued before it. Tasks must not throw (report instead). */
export function enqueue(task: () => void): void {
queue.push(task);
drain();
}
/** Delivers nothing until `fn` returns, then everything it enqueued, in order. */
export function holdPublications<T>(fn: () => T): T {
held++;
try {
return fn();
} finally {
held--;
drain();
}
}
function drain(): void {
if (draining || held > 0) return;
draining = true;
try {
for (let task = queue.shift(); task !== undefined; task = queue.shift()) task();
} finally {
draining = false;
}
}

View File

@@ -0,0 +1,30 @@
import type { Ctx } from "./types.ts";
export interface Registry<V> {
readonly name: string;
readonly conflict: "error" | "last";
/** Type carrier only; never set. */
readonly value?: V;
}
/** `conflict: "error"` rejects two objects registering one key; `"last"` keeps the later registration. */
export const defineRegistry = <V>(name: string, options: { conflict?: "error" | "last" } = {}): Registry<V> => ({
name,
conflict: options.conflict ?? "error",
});
export type RegistryTable<V> = ReadonlyMap<string, V>;
export interface RegistryView<V> {
/** Table of `ctx.codeVersion`, or of the current code version without ctx. */
table(ctx?: Ctx): RegistryTable<V>;
get(key: string, ctx?: Ctx): V | undefined;
/** Called with the current table immediately and whenever a new table is installed. */
subscribe(listener: (table: RegistryTable<V>, codeVersion: number) => void): () => void;
}
export interface Registration {
readonly registry: Registry<unknown>;
readonly key: string;
readonly value: unknown;
}

View File

@@ -0,0 +1,723 @@
import type { Cell } from "./cell.ts";
import { type AnyContract, bind, coverage } from "./contract.ts";
import { asChordError, ChordError, invalid, isChordError } from "./errors.ts";
import type { CodeVersionState, Facet, HostImpl, PluginRef, Serve } from "./host.ts";
import {
type Factory,
hostLifetime,
makeService,
memberSchemas,
type ObjectImpl,
RefImpl,
type ResourceImpl,
type Run,
type ServeOptions,
type Service,
type StagedListeners,
} from "./object.ts";
import { holdPublications } from "./publish.ts";
import type { RegistryTable } from "./registry.ts";
import { assertValid, deepEqual } from "./schema.ts";
import type { Readable } from "./types.ts";
export interface ReloadOptions {
/** Facet names to remove. */
readonly remove?: readonly string[];
/** Milliseconds the gate waits for grandfathered roots (default 5000). Missed: the reload aborts. */
readonly gateDeadline?: number;
/** Milliseconds to wait for the previous code version to retire first (default 5000). Missed: it is retired anyway. */
readonly retireDeadline?: number;
/** Server: facet modules for child hosts by host-name prefix (longest wins). Every connected host participates. */
readonly remote?: Readonly<Record<string, readonly string[]>>;
/** Server: facet names to remove on child hosts, by the same host-name prefix rules. */
readonly remoteRemove?: Readonly<Record<string, readonly string[]>>;
/** Server: milliseconds to wait for every child's vote (default: gate + retire deadlines + 5000). */
readonly voteDeadline?: number;
}
export interface ReloadReport {
readonly codeVersion: number;
readonly previous: number;
readonly changed: readonly string[];
readonly removed: readonly string[];
/** Addresses of objects whose new factory was installed. */
readonly prepared: readonly string[];
/** Addresses of objects that went through the gate. */
readonly gated: readonly string[];
/**
* Cluster reloads: what each participating child host prepared, by host name (its vote; the child commits it after
* the server). Absent for local reloads.
*/
readonly children?: Readonly<Record<string, ReloadReport>>;
}
interface Candidate {
readonly object: ObjectImpl;
readonly service: Service;
readonly run: Run;
/** Why the object needs the gate (empty: compatible). */
readonly reasons: string[];
/** New exclusive resources and their new dependents, opened at the gate. */
readonly atGate: ResourceImpl[];
/** Old exclusive resources closed at the gate. */
readonly replaced: ResourceImpl[];
readonly migrated: Map<Cell<unknown>, unknown>;
/** Values of the run's derivations over the candidate (migrated) values, validated. */
derived: Map<unknown, unknown>;
/** Listener subscriptions made at the end of prepare, activated by the commit. */
staged: StagedListeners | undefined;
}
/**
* Computes the new derivation functions over the candidate values and validates every prospective value (migrated
* cells and derivations) against every schema that will apply after the commit: the cell's new schema and the new
* implementation's remote state-member schemas. A failure fails prepare; the commit then installs without re-checking.
*/
function dryRun(candidate: Candidate): void {
const { run, migrated, service, object } = candidate;
const exposed = memberSchemas(service, run.implementation);
for (const [cell, value] of migrated) {
for (const schema of exposed.get(cell) ?? [])
assertValid("state", `${object.address}: migrated state ${cell.key}`, schema, value);
}
const values = new Map<unknown, unknown>(migrated);
const derives = [...run.decls.derives].sort(([, a], [, b]) => a.seq - b.seq);
for (const [name, derived] of derives) {
const value = derived.compute((source) => (values.has(source) ? values.get(source) : candidateValue(source)));
for (const schema of exposed.get(derived) ?? [])
assertValid("state", `${object.address}: derivation ${name}`, schema, value);
values.set(derived, value);
}
candidate.derived = values;
}
/**
* A source's value for a dry run. A reference to a provider this reload upgrades too reads nothing before the commit
* (its new contract does not bind the old code): the dry run computes without it, the commit with the real value.
*/
function candidateValue(source: Readable<unknown>): unknown {
try {
return source.value;
} catch (error) {
if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant")) return undefined;
throw error;
}
}
/** A reload error keeps the underlying code (`contract_mismatch`, `invalid`, ...) and says it aborted the reload. */
export function reloadError(error: unknown): ChordError {
const cause = asChordError(error, "reload");
if (/^reload aborted/.test(cause.message.replace(/^[^:]*: /, ""))) return cause;
return new ChordError(cause.code, `reload aborted: ${cause.message}`, {
cause,
reason: cause.reason,
details: cause.details,
});
}
/** Serialized: a second reload while one runs is rejected. */
export async function runReload(
host: HostImpl,
plugins: readonly PluginRef[],
options: ReloadOptions,
): Promise<ReloadReport> {
const reload = await beginReload(host, plugins, options);
try {
await reload.prepare();
return reload.commit();
} finally {
endReload(host);
}
}
/** Takes the host's reload slot and loads the plugin modules. Pair with `endReload`. */
export async function beginReload(
host: HostImpl,
plugins: readonly PluginRef[],
options: ReloadOptions,
codeVersion?: number,
): Promise<Reload> {
if (host.reloading) {
throw new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
}
host.reloading = true;
try {
return new Reload(host, await host.resolve(plugins), options, codeVersion);
} catch (error) {
host.reloadEnded();
throw reloadError(error);
}
}
export function endReload(host: HostImpl): void {
host.reloadEnded();
}
/** Most milliseconds a forced retirement waits for aborted roots to settle before it closes what they used. */
const ABORTED_ROOTS_GRACE = 1000;
/** Resolves true once `ready()` holds (checked whenever retirement-relevant state changes), false after `ms`. */
function until(host: HostImpl, ready: () => boolean, ms: number): Promise<boolean> {
if (ready()) return Promise.resolve(true);
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
stop();
resolve(false);
}, ms);
const stop = host.watchRetirement(() => {
if (!ready()) return;
clearTimeout(timer);
stop();
resolve(true);
});
});
}
/** One reload on one host: `prepare` (load, prepare, gate), then `commit` or `rollback`. */
export class Reload {
readonly host: HostImpl;
readonly facets: readonly Facet[];
readonly options: ReloadOptions;
/** Code version id to commit (issued by the coordinating server); a local reload allocates one. */
readonly codeVersion: number | undefined;
readonly candidates = new Map<ObjectImpl, Candidate>();
readonly abort = new AbortController();
readonly closed: [ObjectImpl, ResourceImpl][] = [];
services = new Map<string, Service>();
changed = new Set<string>();
removed = new Set<string>();
tables = new Map<string, RegistryTable<unknown>>();
gated: Candidate[] = [];
rolledBack = false;
/** Objects of types this reload adds, with their staged listeners: their runtime starts at commit. */
readonly deferred = new Map<ObjectImpl, StagedListeners>();
/** This reload's run of each facet it loads (`FacetApi.signal`): installed at commit, aborted at rollback. */
readonly facetRuns = new Map<string, AbortController>();
constructor(host: HostImpl, facets: readonly Facet[], options: ReloadOptions, codeVersion?: number) {
this.host = host;
this.facets = facets;
this.options = options;
this.codeVersion = codeVersion;
}
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */
async prepare(): Promise<void> {
try {
await this.waitForRetirement();
this.load();
this.checkProviders();
await this.prepareAll();
// From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
// so the candidate set is final once the activations already in flight have been prepared.
this.host.gating = this.changed;
await this.withDeadline(this.prepareAll(), "activations in flight did not finish");
this.checkRefs();
this.tables = this.host.buildTables(this.entries());
await this.gate();
this.#alive();
this.#stage();
} catch (error) {
await this.rollback();
throw reloadError(error);
}
}
/** What a successful prepare will commit (a child sends it with its vote). */
summary(): ReloadReport {
const live = [...this.candidates.values()].filter(({ object }) => object.status === "live");
return {
codeVersion: this.host.pending?.id ?? this.host.current.id,
previous: this.host.current.id,
changed: [...this.changed],
removed: [...this.removed],
prepared: live.map(({ object }) => object.address),
gated: this.gated.map(({ object }) => object.address),
};
}
/** After every await of prepare: a rollback meanwhile (abort from the coordinator) ends it. */
#alive(): void {
if (this.rolledBack) throw new ChordError("aborted", "reload aborted while preparing", { reason: "aborted" });
}
/**
* Subscribes every new listener now, buffering its events, so a failing source fails prepare instead of leaving a
* half-installed code version. Objects running only the new code (types this reload adds) are staged too.
*/
#stage(): void {
for (const candidate of this.candidates.values()) {
if (candidate.object.status === "live")
candidate.staged = candidate.object.stageListeners(candidate.run.decls);
}
for (const object of this.host.objects.values()) {
if (object.status === "live" && object.deferredRuntime)
this.deferred.set(object, object.stageListeners(object.decls));
}
}
/**
* Retires the previous code version before loading the next one (a host keeps two). Waits until its roots ended and,
* on a child host, its parent retired it too (`retire` frame), at most `retireDeadline`. Past the deadline it is
* retired anyway: its remaining roots are aborted first and get a short grace to stop, so they see cancellation
* before the resources only that code version used are closed; their next call is `superseded`.
*/
async waitForRetirement(): Promise<void> {
const host = this.host;
const previous = host.previous;
if (previous === undefined) return;
const deadline = this.options.retireDeadline ?? 5000;
const gone = (): boolean => host.previous !== previous;
if (!(await until(host, () => gone() || host.retirable(), deadline)) && !gone()) {
previous.abort.abort(new ChordError("superseded", `code version ${previous.id} was retired`));
await until(host, () => gone() || previous.roots.size === 0, Math.min(deadline, ABORTED_ROOTS_GRACE));
}
if (!gone()) host.retire();
}
/** Builds the candidate service table: unchanged services plus everything the given facets serve. */
load(): void {
const replaced = new Set([...this.facets.map((facet) => facet.name), ...(this.options.remove ?? [])]);
const current = this.host.current.services;
this.services = new Map([...current].filter(([, service]) => !replaced.has(service.facet)));
for (const facet of this.facets) {
const run = new AbortController();
this.facetRuns.set(facet.name, run);
facet.setup(
this.host.facetApi(
((type: AnyContract, factory: Factory<AnyContract>, options: ServeOptions = {}) =>
this.host.serveInto(this.services, makeService(type, factory, options, facet.name))) as Serve,
run.signal,
),
);
}
for (const [name, service] of this.services) if (current.get(name) !== service) this.changed.add(name);
for (const name of current.keys()) if (!this.services.has(name)) this.removed.add(name);
this.host.pending = {
id: this.host.nextCodeVersionId(this.codeVersion),
services: this.services,
tables: new Map(),
roots: new Map(),
abort: new AbortController(),
};
}
async withDeadline<T>(promise: Promise<T>, what: string): Promise<T> {
const deadline = this.options.gateDeadline ?? 5000;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new ChordError("aborted", `${what} within ${deadline} ms`, { reason: "deadline" })),
deadline,
);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
/** Same contract version must cover the old description (forgotten bump); facets' `uses` must bind. */
checkProviders(): void {
for (const name of this.changed) {
const next = this.services.get(name)!.contract;
const old = this.host.current.services.get(name)?.contract;
if (old === undefined || old.version !== next.version) continue;
const reason = coverage(next.description, old.description);
if (reason !== undefined) {
throw new ChordError(
"contract_mismatch",
`${name}@${next.version} changed incompatibly without a version bump (${reason})`,
);
}
}
const facets = new Map(this.host.facets);
for (const facet of this.facets) facets.set(facet.name, facet);
for (const name of this.options.remove ?? []) facets.delete(name);
for (const facet of facets.values()) {
for (const token of facet.uses ?? []) {
const service = this.services.get(token.name);
if (service !== undefined && this.changed.has(token.name))
bind(service.contract, service.options.accepts, token);
}
}
}
/**
* Prepares every live object of a changed type, including objects that activate meanwhile, to a fixpoint. Objects
* already running the new code (types this reload adds) are skipped.
*/
async prepareAll(): Promise<void> {
for (;;) {
const todo = [...this.host.objects.values()].filter(
(object) =>
this.changed.has(object.typeName) &&
!this.candidates.has(object) &&
object.service !== this.services.get(object.typeName) &&
(object.status === "live" || object.status === "activating"),
);
if (todo.length === 0) return;
await Promise.allSettled(todo.map((object) => object.ready));
this.#alive();
for (const object of todo) {
if (object.status === "live" && object.service !== this.services.get(object.typeName)) {
await this.prepareObject(object);
this.#alive();
}
}
}
}
/** Runs the new factory in recording mode and opens what can be opened without the gate. */
async prepareObject(object: ObjectImpl): Promise<void> {
const service = this.services.get(object.typeName)!;
const run = object.run(service, object.decls);
const candidate: Candidate = {
object,
service,
run,
reasons: [],
atGate: [],
replaced: [],
migrated: new Map(),
derived: new Map(),
staged: undefined,
};
this.candidates.set(object, candidate);
// Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.
for (const [name, cell] of run.decls.cells) {
if (object.decls.cells.get(name) !== cell) continue;
const from = object.decls.cellVersions.get(name) ?? 1;
const to = run.decls.cellVersions.get(name) ?? 1;
if (to < from)
throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`);
if (to > from) candidate.reasons.push(`state ${name} version ${from} -> ${to}`);
else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name)))
throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);
}
const deferred = new Set<ResourceImpl>();
for (const resource of run.created.resources) {
if (resource.exclusive || resource.deps.some((dep) => deferred.has(dep as ResourceImpl)))
deferred.add(resource);
}
for (const resource of deferred) {
candidate.atGate.push(resource);
const old = object.decls.resources.get(resource.key);
if (resource.exclusive && old?.exclusive && old.state === "open") candidate.replaced.push(old);
}
if (deferred.size > 0) candidate.reasons.push("exclusive resource replaced");
await object.open(
run.created.resources.filter((resource) => !deferred.has(resource)),
run.created.refs,
this.abort.signal,
);
await object.waitHydrated(run);
// Cells this reload adds read their first value once the new resources are open (gated objects: at the gate);
// the derivations over them are checked after.
if (candidate.reasons.length === 0) {
await object.initCells(run, this.abort.signal);
dryRun(candidate);
}
}
/**
* Every reference that survives into the new code version must bind to the new provider. Browser references don't block a
* reload: after the commit they get `upgrade_required` instead.
*/
checkRefs(): void {
const dropped = new Set<unknown>();
for (const { object, run } of this.candidates.values()) {
for (const ref of object.decls.refs.values())
if (![...run.decls.refs.values()].includes(ref)) dropped.add(ref);
}
for (const object of this.host.objects.values()) {
const service = this.services.get(object.typeName);
if (service === undefined || !this.changed.has(object.typeName)) continue;
for (const ref of object.refs) {
// The host's bootstrap reference follows this provider at commit; it is not an old-code consumer.
if (ref === this.host.hostObjects.get(object.typeName)) continue;
if (!dropped.has(ref) && ref.owner !== "client")
bind(service.contract, service.options.accepts, ref.contract);
}
}
}
entries(): [ObjectImpl, Run["decls"]][] {
return this.host
.liveDecls(true)
.filter(([object]) => !this.removed.has(object.typeName))
.map(([object, decls]) => [object, this.candidates.get(object)?.run.decls ?? decls]);
}
/**
* Admission cut, wait for grandfathered roots (deadline), replace exclusive resources, migrate into private
* copies. Old tasks of gated objects are stopped at the cut; listener events are queued until commit or abort.
*/
async gate(): Promise<void> {
this.gated = [...this.candidates.values()].filter(
(candidate) => candidate.reasons.length > 0 && candidate.object.status === "live",
);
const drained = this.gated.map(({ object }) => {
const grandfathered = new Set(object.leases.keys());
return new Promise<void>((resolve) => {
object.gate = { grandfathered, queue: [], drained: resolve };
object.stopTasks();
if (grandfathered.size === 0) resolve();
});
});
const deadline = this.options.gateDeadline ?? 5000;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
Promise.all(drained).then(() => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), deadline);
}),
]);
clearTimeout(timer);
if (timedOut) {
const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]);
throw new ChordError(
"aborted",
`gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`,
{
reason: "gate_deadline",
details: { blocking },
},
);
}
this.#alive();
for (const candidate of this.gated) {
const { object } = candidate;
for (const old of candidate.replaced) {
// Owned by the rollback before closing: a failing close must still be restored.
this.closed.push([object, old]);
await old.close(object.openOrder);
}
await object.open(candidate.atGate, [], this.abort.signal);
this.#alive();
await object.initCells(candidate.run, this.abort.signal);
this.#alive();
}
for (const candidate of this.gated) this.migrate(candidate);
}
migrate(candidate: Candidate): void {
const { object, run } = candidate;
for (const [name, cell] of run.decls.cells) {
if (object.decls.cells.get(name) !== cell) continue;
const schema = run.decls.cellSchemas.get(name)!;
const from = object.decls.cellVersions.get(name) ?? 1;
const fn = run.decls.cellMigrations.get(name);
let value: unknown;
if ((run.decls.cellVersions.get(name) ?? 1) > from && fn !== undefined)
value = fn(structuredClone(cell.value), from);
else if (!deepEqual(cell.schema, schema)) value = cell.value;
else continue;
assertValid("state", `${object.address}: migrated state ${name}`, schema, value);
candidate.migrated.set(cell, value);
}
dryRun(candidate);
}
async rollback(): Promise<void> {
if (this.rolledBack) return;
this.rolledBack = true;
this.abort.abort(new ChordError("aborted", "reload aborted"));
for (const run of this.facetRuns.values()) run.abort(new ChordError("aborted", "reload aborted"));
const report = (error: unknown): void => this.host.report(error);
for (const candidate of this.candidates.values()) candidate.staged?.discard();
for (const staged of this.deferred.values()) staged.discard();
for (const { object, run } of this.candidates.values()) {
object.pending.delete(run.decls);
await object.unwind(run).catch(report);
}
// Replaced exclusive resources are restored; one that cannot be is marked unavailable, never silently closed.
for (const [object, resource] of this.closed) {
if (resource.state !== "closed") continue;
await resource.open(new AbortController().signal, object.openOrder).catch((error: unknown) => {
resource.markUnavailable();
report(
new ChordError("unavailable", `${object.address}: resource ${resource.key} could not be restored`, {
cause: error,
}),
);
});
}
for (const { object } of this.gated) {
if (object.gate === undefined) continue;
object.openGate();
object.startTasks();
}
// Objects of types this reload would have added lose their code.
const reason = new ChordError("gone", "reload aborted; type not served");
for (const object of [...this.host.objects.values()]) {
if (!this.host.current.services.has(object.typeName)) await object.dispose(true, reason).catch(report);
}
this.host.pending = undefined;
this.host.gating = undefined;
}
/**
* Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens,
* migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs.
* Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still
* throws, the code version is half-published: the host fails terminally and admits no more work.
*/
commit(beforeStart?: () => void): ReloadReport {
try {
return holdPublications(() => {
const report = this.#publish();
// Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version
// starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child
// first.
beforeStart?.();
for (const start of this.#starts.splice(0)) start();
return report;
});
} catch (error) {
this.host.fail(error);
throw reloadError(error);
}
}
/** Work that may start new-code-version roots, run after installation (and after the coordinator's commit frames). */
readonly #starts: (() => void)[] = [];
#publish(): ReloadReport {
const host = this.host;
const previous = host.current;
const next: CodeVersionState = host.pending!;
host.pending = undefined;
host.codeVersions.set(next.id, next);
host.previous = previous;
host.current = next;
const prepared: string[] = [];
for (const object of [...host.objects.values()]) {
if (this.removed.has(object.typeName)) continue;
const candidate = this.candidates.get(object);
if (candidate === undefined) {
const installed = object.implementations.get(previous.id);
if (installed !== undefined) object.implementations.set(next.id, { ...installed, codeVersion: next.id });
continue;
}
if (object.status !== "live") continue;
this.install(candidate, next.id);
prepared.push(object.address);
}
for (const { object, run, staged } of this.candidates.values()) {
if (object.status === "live") continue;
staged?.discard();
object.pending.delete(run.decls);
object.unwind(run).catch((error: unknown) => host.report(error));
}
// Objects of types this reload adds start their runtime now.
for (const [object, staged] of this.deferred) {
if (object.status !== "live") {
staged.discard();
continue;
}
object.deferredRuntime = false;
staged.activate();
this.#starts.push(() => object.startTasks());
}
host.installTables(next, this.tables, true);
const reason = new ChordError("gone", "type no longer served");
for (const object of [...host.objects.values()]) {
if (this.removed.has(object.typeName))
object.dispose(true, reason).catch((error: unknown) => host.report(error));
}
for (const name of this.removed) host.hostObjects.delete(name);
for (const facet of this.facets) host.facets.set(facet.name, facet);
for (const name of this.options.remove ?? []) host.facets.delete(name);
// The replaced and removed runs' signals abort after the install, like other observers.
for (const [name, run] of this.facetRuns) this.#starts.push(() => host.facetRunChanged(name, run));
for (const name of this.options.remove ?? []) this.#starts.push(() => host.facetRunChanged(name, undefined));
host.gating = undefined;
for (const [name, service] of this.services) {
if (hostLifetime(service) && this.changed.has(name) && host.hostObjects.get(name)?.object?.status !== "live") {
this.#starts.push(() => host.startHostObject(service));
}
// A type whose lifetime became "referenced" lives on only while someone else references its object.
const pinned = host.hostObjects.get(name);
if (!hostLifetime(service) && pinned !== undefined) {
host.hostObjects.delete(name);
this.#starts.push(() => pinned.release());
}
}
return {
codeVersion: next.id,
previous: previous.id,
changed: [...this.changed],
removed: [...this.removed],
prepared,
gated: this.gated.map(({ object }) => object.address),
};
}
install(candidate: Candidate, codeVersion: number): void {
const { object, run, service } = candidate;
const gated = candidate.reasons.length > 0;
object.pending.delete(run.decls);
object.stopTasks();
if (gated) {
// Old derivations would compute over migrated values; the old implementation can no longer run anyway.
const kept = new Set(run.decls.derives.values());
for (const derived of object.decls.derives.values()) if (!kept.has(derived)) derived.detach();
}
object.decls = run.decls;
object.service = service;
// Kept resources close with the new code's `close`; replaced ones keep their own (closed at retirement).
for (const [resource, declaration] of run.decls.adopted) resource.adopt(declaration);
object.implementations.set(codeVersion, {
codeVersion,
service,
implementation: run.implementation,
decls: run.decls,
});
if (gated) {
// Old code can never run over migrated state: drop the previous implementation now, not at retirement.
object.minCodeVersion = codeVersion;
for (const old of [...object.implementations.keys()])
if (old !== codeVersion) this.host.retireImplementation(object, old);
}
// Schemas first, then the values validated against them in prepare.
object.refreshSchemas();
for (const [name, cell] of run.decls.cells) {
const schema = run.decls.cellSchemas.get(name)!;
if (candidate.migrated.has(cell)) cell.reset(candidate.migrated.get(cell), schema);
else cell.schema = schema;
}
const created = new Set(run.created.derives);
for (const derived of run.created.derives) derived.attach(candidate.derived);
for (const derived of run.decls.derives.values()) if (!created.has(derived)) derived.refresh();
candidate.staged?.activate();
this.#starts.push(() => object.startTasks());
object.openGate();
const pinned = this.host.hostObjects.get(object.typeName);
if (pinned?.object === object && pinned.contract.fingerprint !== service.contract.fingerprint) {
const replacement = new RefImpl(object, service.contract, "host");
// Acquire first: releasing the only reference must not dispose/reopen the surviving host object.
object.addRef(replacement);
this.host.hostObjects.set(object.typeName, replacement);
pinned.release();
}
for (const ref of [...object.refs]) {
// Browser references whose contract no longer binds are told to reload their code (they never block a reload).
if (ref.owner === "client") {
try {
bind(service.contract, service.options.accepts, ref.contract);
} catch (error) {
ref.gone(
new ChordError("upgrade_required", `${object.typeName} changed; reload the web facets`, {
cause: error,
}),
);
object.release(ref);
continue;
}
}
ref.shapeChanged();
}
}
}

View File

@@ -0,0 +1,658 @@
import { Value } from "typebox/value";
import { HYDRATION, type Hydration } from "./cell.ts";
import { type AnyContract, takesNoArgs } from "./contract.ts";
import { applyImmutable, type Decoder, decoder } from "./delta/index.ts";
import { ChordError, invalid } from "./errors.ts";
import {
CtxImpl,
notifyStatus,
REF_BASE,
type RefCore,
type RefOwner,
raceSignal,
refsByProxy,
type StatusListener,
} from "./object.ts";
import type { Ctx, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
import { fromWireError, type RootRef, type WireError } from "./wire.ts";
const noop = (): void => {};
/** One state frame for a member, as the binding receives it (payload unpacked). */
export interface StateIn {
readonly as: "snap" | "reset" | "ops" | "error";
readonly value?: unknown;
readonly ops?: readonly unknown[];
readonly error?: WireError;
}
/** What a binding needs from the link it is bound to (implemented by Connection). */
export interface RemoteChannel {
/** Sends a hold for `binding`; `again`: the binding already has values (a re-hold), its unit is not urgent. */
hold(binding: RemoteBinding, again: boolean): number;
release(id: number): void;
call(hold: number, member: string, args: unknown, root: RootRef | undefined, signal: AbortSignal): Promise<unknown>;
/** Starts (`sub`) or stops (`unsub`) the state of a hold. */
wantState(hold: number, wanted: boolean): void;
}
/** What a binding needs from its host. */
export interface RemoteHost {
readonly inFactory: number;
report(error: unknown): void;
root<T>(fn: (ctx: Ctx) => T | Promise<T>, options?: { signal?: AbortSignal }): Promise<T>;
/** The binding has no views left: the host lingers it or closes it. */
bindingIdle(binding: RemoteBinding): void;
/** The binding ended or was closed: the host forgets it. */
forgetBinding(binding: RemoteBinding): void;
}
type Settle = { resolve: () => void; reject: (error: unknown) => void };
function deferred(): { promise: Promise<void> } & Settle {
let resolve = noop;
let reject: (error: unknown) => void = noop;
const promise = new Promise<void>((done, fail) => {
resolve = done;
reject = fail;
});
promise.catch(noop);
return { promise, resolve, reject };
}
/** A member's value with the decoder of its current stream. */
interface MemberState {
value: unknown;
decoder: Decoder;
}
/**
* One hold per host and key (type, normalized input, principal key, contract fingerprint), shared by every reference to
* it: one hold over the link, one stream per state member, calls over the same hold. References are views onto it.
*
* A hold implies the state of its variant (unless no view wants state): the provider answers with a unit, the shape
* followed by a snapshot of each state member the binding lacks. A shape is applied only once all of its snapshots are
* here, so a live binding always has every state member of its variant. After the first hydration the values stay
* readable while the binding re-holds (link loss): status `loading`, last values kept.
*/
export class RemoteBinding {
readonly host: RemoteHost;
readonly key: string;
readonly contract: AnyContract;
readonly at: string;
readonly input: unknown;
/** Forwarded principal (omitted for the host's own principal). */
readonly principal: unknown | undefined;
/** `ended`: gone with `error`, or closed (no views, no linger). */
status: "loading" | "live" | "ended" = "loading";
error: unknown;
/** The applied shape (kept while re-holding). */
shape: Shape | undefined;
/** Every state member of the applied shape has a value (stays true while re-holding). */
hydrated = false;
/** The current hold; undefined while resolving the route or after a link loss. */
hold: { readonly channel: RemoteChannel; readonly id: number } | undefined;
/** What the provider was told: state for this hold (`state: false` / `sub` / `unsub`). */
stateOn = false;
readonly views = new Set<RemoteRef>();
readonly members = new Map<string, MemberState>();
/** Lingering since (no views): set by the host. */
lingerTimer: ReturnType<typeof setTimeout> | undefined;
readonly #listeners = new Map<string, Set<ViewReadable>>();
/** A shape waiting for its snapshots. */
#pending: { shape: Shape; readonly missing: Set<string>; readonly values: Map<string, MemberState> } | undefined;
/** Resolved while a shape is applied for the current hold (calls wait for it). */
#live = deferred();
/** A shape was applied once (a re-hold says `again`). */
#applied = false;
/** Waiting for the first hydration (`whenHydrated`). */
readonly #hydrationWaiters = new Set<Settle>();
constructor(
host: RemoteHost,
fields: { key: string; contract: AnyContract; at: string; input: unknown; principal: unknown | undefined },
) {
this.host = host;
this.key = fields.key;
this.contract = fields.contract;
this.at = fields.at;
this.input = fields.input;
this.principal = fields.principal;
}
get ended(): boolean {
return this.status === "ended";
}
/** Views that want state. */
get stateViews(): number {
let count = 0;
for (const view of this.views) if (view.wantsState) count++;
return count;
}
/** Whether the next hold should carry state: some view wants it, or nobody is left (lingering keeps it). */
get wantsState(): boolean {
return this.views.size === 0 ? this.stateOn : this.stateViews > 0;
}
/** Whether a re-hold is `again` (the binding already has a shape). */
get applied(): boolean {
return this.#applied;
}
// ─── views ───
addView(view: RemoteRef): void {
this.views.add(view);
if (this.lingerTimer !== undefined) {
clearTimeout(this.lingerTimer);
this.lingerTimer = undefined;
}
this.#syncState();
view.update();
}
removeView(view: RemoteRef): void {
if (!this.views.delete(view)) return;
for (const set of this.#listeners.values())
for (const readable of set) if (readable.view === view) set.delete(readable);
if (this.views.size === 0) {
this.host.bindingIdle(this);
return;
}
this.#syncState();
}
/** Tells the provider to start or stop state when the views' wish changed. */
#syncState(): void {
if (this.ended || this.views.size === 0) return;
const wanted = this.stateViews > 0;
if (wanted === this.stateOn) return;
this.stateOn = wanted;
if (!wanted) {
this.hydrated = false;
this.members.clear();
if (this.#pending !== undefined) this.#pending.missing.clear();
}
if (this.hold !== undefined) this.hold.channel.wantState(this.hold.id, wanted);
for (const view of this.views) view.update();
}
listen(name: string, readable: ViewReadable): void {
let set = this.#listeners.get(name);
if (set === undefined) {
set = new Set();
this.#listeners.set(name, set);
}
set.add(readable);
}
unlisten(name: string, readable: ViewReadable): void {
this.#listeners.get(name)?.delete(readable);
}
/** Whether `name` is a state member of the applied variant (as the consumer's contract describes it). */
inVariant(name: string): boolean {
const variant = this.shape?.variant;
return variant !== undefined && this.contract.description.variants[variant]?.[name]?.kind === "state";
}
/** The members' value as views read it: throws `not_ready` before the first hydration, `not_in_variant` outside it. */
read(name: string): unknown {
if (this.status === "ended") throw this.terminalError();
if (!this.hydrated) throw new ChordError("not_ready", `${this.key}.${name} is not hydrated yet`);
if (!this.inVariant(name))
throw new ChordError(
"not_in_variant",
`${this.key}: ${name} is not a state member of variant ${this.shape?.variant}`,
);
return this.members.get(name)?.value;
}
/** Resolves once the binding has its state (rejects if it ends first). */
whenHydrated(): Promise<void> {
if (this.hydrated) return Promise.resolve();
if (this.ended) return Promise.reject(this.terminalError());
return new Promise<void>((resolve, reject) => this.#hydrationWaiters.add({ resolve, reject }));
}
#settleHydration(error?: unknown): void {
const waiters = [...this.#hydrationWaiters];
this.#hydrationWaiters.clear();
for (const waiter of waiters) {
if (error === undefined) waiter.resolve();
else waiter.reject(error);
}
}
readable(name: string): boolean {
return this.status !== "ended" && this.hydrated && this.inVariant(name);
}
terminalError(): unknown {
return this.error ?? new ChordError("released", `reference ${this.key} released`);
}
// ─── driven by the host and the link ───
bound(channel: RemoteChannel, id: number): void {
this.hold = { channel, id };
this.stateOn = this.wantsState;
}
/** A unit begins: `listed` are the members whose snapshots follow. */
shaped(shape: Shape, listed: readonly string[]): void {
if (this.ended) return;
const previous = this.#pending;
const missing = new Set<string>(listed);
// A newer shape before the older one's snapshots (relays may reorder them): wait for both.
if (previous !== undefined) for (const name of previous.missing) missing.add(name);
const wanted = new Set(this.#stateMembers(shape));
for (const name of [...missing]) if (!wanted.has(name) || !this.stateOn) missing.delete(name);
this.#pending = { shape, missing, values: previous?.values ?? new Map() };
if (missing.size === 0) this.#apply();
}
/** A state frame for member `name` of the current hold. */
state(name: string, frame: StateIn): void {
if (this.ended) return;
if (frame.as === "error") {
const error = fromWireError(frame.error!);
// Before the first hydration nobody could read anything: the binding fails instead of waiting forever.
if (!this.hydrated && this.stateOn) {
this.gone(error);
this.close();
} else this.host.report(error);
return;
}
const pending = this.#pending;
if (frame.as === "ops") {
const target = pending?.values.get(name) ?? this.members.get(name);
if (target === undefined) return;
const ops = target.decoder.decode(frame.ops ?? []);
target.value = applyImmutable(target.value, ops);
if (target === this.members.get(name)) this.#deliver(name, { type: "ops", ops, value: target.value });
return;
}
const fresh: MemberState = { value: frame.value, decoder: decoder() };
if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) {
pending.values.set(name, fresh);
pending.missing.delete(name);
if (pending.missing.size === 0) this.#apply();
return;
}
if (!this.inVariant(name)) return;
this.members.set(name, fresh);
this.#deliver(name, { type: "reset", value: fresh.value });
}
/** The provider ended the object (or refused the hold): every view is gone. */
gone(error: unknown): void {
if (this.ended) return;
this.status = "ended";
this.error = error;
this.hold = undefined;
this.#pending = undefined;
this.#live.reject(error);
this.#settleHydration(error);
for (const view of [...this.views]) view.end(error);
this.#listeners.clear();
this.host.forgetBinding(this);
}
/** The link went down but may come back (upstream reconnect): keep the values, wait for the re-hold. */
detached(): void {
if (this.ended) return;
this.hold = undefined;
this.#pending = undefined;
if (this.status === "live") {
this.status = "loading";
this.#live = deferred();
}
for (const view of this.views) view.update();
}
/** No views and no linger: releases the hold and forgets the binding. */
close(): void {
if (this.lingerTimer !== undefined) clearTimeout(this.lingerTimer);
this.lingerTimer = undefined;
if (this.hold !== undefined) this.hold.channel.release(this.hold.id);
this.hold = undefined;
if (this.status !== "ended") {
this.status = "ended";
const error = new ChordError("released", `reference ${this.key} released`);
this.#live.reject(error);
this.#settleHydration(error);
for (const view of [...this.views]) view.end(error);
}
this.#pending = undefined;
this.#listeners.clear();
this.host.forgetBinding(this);
}
/** Calls `name` over the current hold, waiting for it while the binding (re)holds. */
async call(name: string, args: unknown, ctx: Ctx): Promise<unknown> {
for (;;) {
if (this.ended) throw this.terminalError();
if (this.status !== "live" || this.hold === undefined) await raceSignal(this.#live.promise, ctx.signal);
const hold = this.hold;
if (hold === undefined || this.status !== "live") continue;
const variant = this.shape?.variant;
const description = variant === undefined ? undefined : this.contract.description.variants[variant]?.[name];
if (description?.kind !== "method") {
throw new ChordError("not_in_variant", `${this.key}: ${name} is not a method of variant ${variant}`);
}
// Send only what the consumer's own schema knows (the additive rule's premise; see ObjectImpl.call).
const sent = description.args === undefined ? undefined : Value.Clean(description.args, Value.Clone(args));
return hold.channel.call(
hold.id,
name,
sent,
{ rootId: ctx.rootId, codeVersion: ctx.codeVersion },
ctx.signal,
);
}
}
#stateMembers(shape: Shape): string[] {
const members = shape.variant === undefined ? undefined : this.contract.description.variants[shape.variant];
if (members === undefined) return [];
return shape.members.filter((name) => members[name]?.kind === "state");
}
/** Applies the pending shape with its snapshots: members outside the variant go, new values are announced. */
#apply(): void {
const pending = this.#pending;
if (pending === undefined) return;
this.#pending = undefined;
this.shape = pending.shape;
const wanted = new Set(this.#stateMembers(pending.shape));
for (const name of [...this.members.keys()]) if (!wanted.has(name)) this.members.delete(name);
for (const [name, state] of pending.values) if (wanted.has(name)) this.members.set(name, state);
this.hydrated = this.stateOn && [...wanted].every((name) => this.members.has(name));
if (this.hydrated) this.#settleHydration();
this.status = "live";
this.#applied = true;
this.#live.resolve();
for (const view of this.views) view.update();
for (const [name, state] of pending.values) {
if (wanted.has(name)) this.#deliver(name, { type: "reset", value: state.value });
}
for (const view of [...this.views]) view.shapeChanged();
}
#deliver(name: string, event: StateEvent<unknown>): void {
const readables = this.#listeners.get(name);
if (readables === undefined) return;
for (const readable of [...readables]) readable.deliver(event);
}
}
/**
* A state member of a remote reference: the binding's value. Reading throws `not_ready` before the first hydration and
* `not_in_variant` outside the variant. A listener added before the hydration gets its snapshot then.
*/
class ViewReadable implements Readable<unknown> {
readonly view: RemoteRef;
readonly #name: string;
readonly #values = new Set<(value: unknown) => void>();
readonly #events = new Set<(event: StateEvent<unknown>) => void>();
/** Event listeners that have not had their snapshot yet. */
readonly #unseen = new Set<(event: StateEvent<unknown>) => void>();
constructor(view: RemoteRef, name: string) {
this.view = view;
this.#name = name;
}
get value(): unknown {
if (this.view.ended) throw this.view.terminalError();
return this.view.binding.read(this.#name);
}
/** Not hydrated before the binding's first snapshots (a local object forwarding this member waits for them). */
get [HYDRATION](): Hydration | undefined {
const binding = this.view.binding;
if (binding.hydrated) return undefined;
return { hydrated: false, whenHydrated: () => binding.whenHydrated() };
}
subscribe(listener: (value: unknown) => void): () => void {
this.#values.add(listener);
this.#attach();
if (this.#readable()) this.#safe(() => listener(this.view.binding.members.get(this.#name)?.value));
return () => this.#remove(this.#values, listener);
}
events(listener: (event: StateEvent<unknown>) => void): () => void {
this.#events.add(listener);
this.#attach();
if (this.#readable()) {
const value = this.view.binding.members.get(this.#name)?.value;
this.#safe(() => listener({ type: "snap", value }));
} else this.#unseen.add(listener);
return () => {
this.#unseen.delete(listener);
this.#remove(this.#events, listener);
};
}
deliver(event: StateEvent<unknown>): void {
for (const listener of [...this.#events]) {
if (!this.#unseen.delete(listener)) this.#safe(() => listener(event));
else if (event.type !== "ops") this.#safe(() => listener({ type: "snap", value: event.value }));
}
for (const listener of [...this.#values]) this.#safe(() => listener(event.value));
}
stop(): void {
this.#values.clear();
this.#events.clear();
this.#unseen.clear();
this.view.binding.unlisten(this.#name, this);
}
#readable(): boolean {
return !this.view.ended && this.view.binding.readable(this.#name);
}
#attach(): void {
if (!this.view.ended) this.view.binding.listen(this.#name, this);
}
#remove<T>(set: Set<T>, listener: T): void {
set.delete(listener);
if (this.#values.size + this.#events.size === 0) this.view.binding.unlisten(this.#name, this);
}
#safe(fn: () => void): void {
try {
fn();
} catch (error) {
this.view.binding.host.report(error);
}
}
}
/**
* A reference to an object on another host: a view onto the host's shared binding for its key. Each view has its own
* owner, status, observers and `release`; ending by the provider and re-holding belong to the binding.
*/
export class RemoteRef implements RefCore {
readonly object = undefined;
readonly binding: RemoteBinding;
readonly owner: RefOwner;
/** False: a method-only view (`state: false`), ready once live. */
readonly wantsState: boolean;
readonly proxy: object;
readonly ready: Promise<void>;
status: RefStatus = "loading";
error: unknown;
readonly shapeListeners = new Set<(shape: Shape) => void>();
readonly #statusListeners = new Set<StatusListener>();
readonly #readables = new Map<string, ViewReadable>();
readonly #callers = new Map<string, (...given: unknown[]) => Promise<unknown>>();
#settleReady: Settle | undefined;
constructor(binding: RemoteBinding, owner: RefOwner, wantsState: boolean) {
this.binding = binding;
this.owner = owner;
this.wantsState = wantsState;
const ready = deferred();
this.ready = ready.promise;
this.#settleReady = ready;
this.proxy = new Proxy(this, {
get: (target, property) => {
if (REF_BASE.has(property)) {
const value: unknown = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
}
if (typeof property !== "string" || property === "then") return undefined;
return target.member(property);
},
});
refsByProxy.set(this.proxy, this);
}
get contract(): AnyContract {
return this.binding.contract;
}
get address(): string {
return this.binding.key;
}
get ended(): boolean {
return this.status === "released" || this.status === "gone";
}
get variant(): string | undefined {
return this.binding.shape?.variant;
}
get shape(): Shape | undefined {
return this.binding.shape;
}
onShape(listener: (shape: Shape) => void): () => void {
this.shapeListeners.add(listener);
return () => this.shapeListeners.delete(listener);
}
onStatus(listener: StatusListener): () => void {
this.#statusListeners.add(listener);
return () => this.#statusListeners.delete(listener);
}
terminalError(): unknown {
return this.status === "gone" ? this.error : new ChordError("released", `reference ${this.address} released`);
}
release(): void {
if (this.ended) return;
this.status = "released";
this.#notify();
this.#settleReady?.reject(new ChordError("released", `reference ${this.address} released`));
this.#settleReady = undefined;
for (const readable of this.#readables.values()) readable.stop();
this.binding.removeView(this);
}
// ─── driven by the binding ───
/** Follows the binding: live once it has a shape (and, for a state view, its state) over a current hold. */
update(): void {
if (this.ended) return;
const binding = this.binding;
const live = binding.status === "live" && binding.hold !== undefined && (!this.wantsState || binding.hydrated);
const next: RefStatus = live ? "live" : "loading";
if (next === this.status) return;
this.status = next;
this.#notify();
if (live) {
this.#settleReady?.resolve();
this.#settleReady = undefined;
}
}
end(error: unknown): void {
if (this.ended) return;
const released = error instanceof ChordError && error.code === "released";
this.status = released ? "released" : "gone";
if (!released) this.error = error;
this.#notify();
this.#settleReady?.reject(error);
this.#settleReady = undefined;
for (const readable of this.#readables.values()) readable.stop();
}
shapeChanged(): void {
const shape = this.binding.shape;
if (shape === undefined || this.ended) return;
for (const listener of [...this.shapeListeners]) {
try {
listener(shape);
} catch (error) {
this.binding.host.report(error);
}
}
}
#notify(): void {
notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.binding.host.report(error));
}
// ─── members ───
member(name: string): unknown {
const description = Object.values(this.contract.description.variants)
.map((members) => members[name])
.find((member) => member !== undefined);
if (description?.kind === "method") return this.#caller(name);
if (description?.kind === "state") {
let readable = this.#readables.get(name);
if (readable === undefined) {
readable = new ViewReadable(this, name);
this.#readables.set(name, readable);
}
return readable;
}
return undefined;
}
#caller(name: string): (...given: unknown[]) => Promise<unknown> {
let caller = this.#callers.get(name);
if (caller === undefined) {
const noArgs = takesNoArgs(this.contract, name);
caller = (...given) => {
// A method without arguments is called as `ref.name(ctx)`.
const [args, ctx] = (noArgs ? [undefined, given[0]] : given) as [unknown, Ctx | undefined];
const host = this.binding.host;
if (host.inFactory > 0)
throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`);
// The same checks as a local reference's caller (RefImpl.caller and ObjectImpl.call): a ctx from another host
// or one not made by Chord would carry a root this host does not own over its link.
if (ctx !== undefined && !(ctx instanceof CtxImpl)) {
return Promise.reject(invalid("foreign_ctx", "ctx must come from a Chord host"));
}
try {
ctx?.check(host);
} catch (error) {
return Promise.reject(error);
}
if (ctx === undefined || ctx.holder) {
return host.root((root) => this.#call(name, args, root), { signal: ctx?.signal });
}
return this.#call(name, args, ctx);
};
this.#callers.set(name, caller);
}
return caller;
}
#call(name: string, args: unknown, ctx: Ctx): Promise<unknown> {
if (this.ended) return Promise.reject(this.terminalError());
return this.binding.call(name, args, ctx);
}
}

View File

@@ -0,0 +1,219 @@
import type { TSchema } from "typebox";
import { Value } from "typebox/value";
import { type ChordErrorReason, invalid } from "./errors.ts";
/** JSON Schema as data. TypeBox schemas are plain JSON Schema objects. */
type Schema = { readonly [key: string]: unknown };
const asSchema = (schema: unknown): Schema =>
schema !== null && typeof schema === "object" ? (schema as Schema) : schema === false ? { not: {} } : {};
/** Canonical JSON: sorted object keys, `undefined` members dropped. */
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
const keys = Object.keys(value)
.filter((key) => (value as Record<string, unknown>)[key] !== undefined)
.sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
}
/** Non-cryptographic 64-bit hash (two FNV-1a lanes). Contracts are trusted, so collisions are not an attack surface. */
export function hash64(text: string): string {
let a = 0x811c9dc5;
let b = 0x01000193 ^ 0x5bd1e995;
for (let index = 0; index < text.length; index++) {
const code = text.charCodeAt(index);
a = Math.imul(a ^ code, 0x01000193);
b = Math.imul(b ^ code, 0x5bd1e995) ^ (b >>> 13);
}
return (a >>> 0).toString(16).padStart(8, "0") + (b >>> 0).toString(16).padStart(8, "0");
}
export function deepEqual(a: unknown, b: unknown): boolean {
return a === b || canonicalJson(a) === canonicalJson(b);
}
/** Throws `invalid` (with `reason`) and the first schema errors when `value` does not match. */
export function assertValid(reason: ChordErrorReason, what: string, schema: TSchema, value: unknown): void {
if (Value.Check(schema, value)) return;
const errors = [...Value.Errors(schema, value)].slice(0, 3);
const summary = errors.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ");
throw invalid(reason, `${what}: ${summary}`, errors);
}
/** Applies defaults, strips unknown fields, validates. Returns a fresh value. */
export function normalize(schema: TSchema, value: unknown, what: string): unknown {
const normalized = Value.Clean(schema, Value.Default(schema, Value.Clone(value ?? {})));
assertValid("input", what, schema, normalized);
return normalized;
}
/** Canonical identity of a normalized value: fields equal to their schema default are omitted. */
export function canonicalIdentity(schema: TSchema, value: unknown): string {
return canonicalJson(stripDefaults(asSchema(schema), value));
}
function stripDefaults(schema: Schema, value: unknown): unknown {
if (Array.isArray(value)) {
const items = schema.items;
if (Array.isArray(items)) return value.map((item, index) => stripDefaults(asSchema(items[index]), item));
return value.map((item) => stripDefaults(asSchema(items), item));
}
if (value === null || typeof value !== "object") return value;
const properties = asSchema(schema.properties);
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const property = asSchema(properties[key]);
if ("default" in property && deepEqual(property.default, item)) continue;
result[key] = stripDefaults(property, item);
}
return result;
}
const CONSTRAINTS = [
"minLength",
"maxLength",
"pattern",
"format",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
] as const;
const typesOf = (schema: Schema): string[] | undefined =>
typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? (schema.type as string[]) : undefined;
/** Keywords `fits` reasons about. Any other validation keyword in the provider's schema forces exact equality. */
const KNOWN: ReadonlySet<string> = new Set([
...CONSTRAINTS,
"type",
"properties",
"required",
"additionalProperties",
"patternProperties",
"items",
"additionalItems",
"const",
"enum",
"anyOf",
"parameters",
"returnType",
]);
/** Annotations: no effect on validity. */
const ANNOTATIONS: ReadonlySet<string> = new Set([
"title",
"description",
"default",
"examples",
"$id",
"$schema",
"$comment",
"readOnly",
"writeOnly",
"deprecated",
]);
const withoutAnnotations = (schema: Schema): Schema =>
Object.fromEntries(Object.entries(schema).filter(([key]) => !ANNOTATIONS.has(key)));
/**
* Conservative structural subtyping: true only if every value valid under `sub` is valid under `sup`, where object
* fields missing from `sub` but optional in `sup` count as fine (unknown fields are stripped at the provider, see
* `RecordImpl.call`). A provider schema using a keyword this function does not model (`not`, `allOf`, `contains`,
* `$ref`, ...) or an `additionalProperties` schema only fits an identical consumer schema.
*/
export function fits(subSchema: unknown, supSchema: unknown): boolean {
const sub = withoutAnnotations(asSchema(subSchema));
const sup = withoutAnnotations(asSchema(supSchema));
if (Object.keys(sup).length === 0) return true;
if (deepEqual(sub, sup)) return true;
if (Object.keys(sup).some((key) => !KNOWN.has(key))) return false;
if (sup.additionalProperties !== undefined && typeof sup.additionalProperties !== "boolean") return false;
if (Array.isArray(sub.anyOf)) return sub.anyOf.every((branch) => fits(branch, sup));
if (Array.isArray(sup.anyOf)) return sup.anyOf.some((branch) => fits(sub, branch));
if ("const" in sup) return "const" in sub && deepEqual(sub.const, sup.const);
if (Array.isArray(sup.enum)) {
const values = "const" in sub ? [sub.const] : Array.isArray(sub.enum) ? sub.enum : undefined;
return values?.every((item) => (sup.enum as unknown[]).some((allowed) => deepEqual(allowed, item))) ?? false;
}
for (const key of CONSTRAINTS) if (key in sup && !deepEqual(sub[key], sup[key])) return false;
const supTypes = typesOf(sup);
const subTypes = typesOf(sub);
if (supTypes === undefined || subTypes === undefined) return false;
if (!subTypes.every((type) => supTypes.includes(type) || (type === "integer" && supTypes.includes("number")))) {
return false;
}
if ("const" in sub || Array.isArray(sub.enum)) return true;
if (subTypes.includes("object") && !objectFits(sub, sup)) return false;
if (subTypes.includes("array") && !arrayFits(sub, sup)) return false;
if (subTypes.includes("function")) return functionFits(sub, sup);
return true;
}
function objectFits(sub: Schema, sup: Schema): boolean {
const subProperties = asSchema(sub.properties);
const supProperties = asSchema(sup.properties);
const subRequired = new Set((sub.required as string[] | undefined) ?? []);
for (const key of (sup.required as string[] | undefined) ?? []) if (!subRequired.has(key)) return false;
for (const [key, property] of Object.entries(supProperties)) {
if (key in subProperties) {
if (!fits(subProperties[key], property)) return false;
} else if (typeof sub.additionalProperties === "object" && !fits(sub.additionalProperties, property)) {
// The consumer may send this name with any value its `additionalProperties` allows. Open consumer objects
// are fine: unknown names are stripped with the consumer's own schema before the call (`RecordImpl.call`).
return false;
}
}
if (sup.additionalProperties === false) {
if (sub.additionalProperties !== false) return false;
if (Object.keys(subProperties).some((key) => !(key in supProperties))) return false;
}
const supPatterns = asSchema(sup.patternProperties);
if (Object.keys(supPatterns).length > 0) {
// Every value the consumer may put under a matching name must fit, and the consumer must not allow arbitrary
// extra names (it would then allow any value under a matching one).
const subPatterns = asSchema(sub.patternProperties);
if (sub.additionalProperties !== false && Object.keys(subPatterns).length === 0) return false;
for (const [pattern, property] of Object.entries(supPatterns)) {
const matcher = new RegExp(pattern, "u");
for (const [key, subProperty] of Object.entries(subProperties)) {
if (!(key in supProperties) && matcher.test(key) && !fits(subProperty, property)) return false;
}
if (
Object.keys(subPatterns).length > 0 &&
(!(pattern in subPatterns) || !fits(subPatterns[pattern], property))
) {
return false;
}
}
for (const pattern of Object.keys(subPatterns)) if (!(pattern in supPatterns)) return false;
}
return true;
}
function arrayFits(sub: Schema, sup: Schema): boolean {
if (Array.isArray(sup.items)) {
if (!Array.isArray(sub.items) || sub.items.length !== sup.items.length) return false;
return sub.items.every((item, index) => fits(item, (sup.items as unknown[])[index]));
}
if (Array.isArray(sub.items)) return sub.items.every((item) => fits(item, sup.items));
return fits(sub.items, sup.items);
}
function functionFits(sub: Schema, sup: Schema): boolean {
const subParameters = (sub.parameters as unknown[] | undefined) ?? [];
const supParameters = (sup.parameters as unknown[] | undefined) ?? [];
// A function accepting fewer parameters is fine; each shared parameter is contravariant.
if (subParameters.length > supParameters.length) return false;
if (!subParameters.every((parameter, index) => fits(supParameters[index], parameter))) return false;
return fits(sub.returnType, sup.returnType);
}

View File

@@ -1,660 +0,0 @@
import { awaitWithContext, BACKGROUND_CONTEXT } from "../context/index.ts";
import type { Op } from "../delta/index.ts";
import type {
Context,
JsonValue,
RemoteServiceBinding,
RemoteServiceBindingOptions,
RemoteServiceTransport,
Service,
ServiceInstanceAddress,
ServiceInstanceSnapshot,
ServiceMemberSnapshot,
ServiceProviderUpdate,
ServiceSubscription,
} from "../types.ts";
import { RemoteServiceError } from "./errors.ts";
import { ServiceSlot } from "./handle.ts";
import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
import { ReplicatedStateReplica, serviceDeliveryContext } from "./state.ts";
type ErrorReporter = (error: Error) => void;
type ServiceMemberKind = ServiceMemberSnapshot["kind"];
class MemberSlot {
readonly #serviceId: string;
readonly #member: string;
readonly #invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>;
readonly #state: ReplicatedStateReplica;
readonly #isActive: () => boolean;
readonly #assertAccess: () => void;
readonly value: unknown;
#kind: ServiceMemberKind | undefined;
#expectedKind: ServiceMemberKind | undefined;
constructor(
serviceId: string,
member: string,
invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>,
isActive: () => boolean,
assertAccess: () => void,
reportError: ErrorReporter,
) {
this.#serviceId = serviceId;
this.#member = member;
this.#invoke = invoke;
this.#isActive = isActive;
this.#assertAccess = assertAccess;
this.#state = new ReplicatedStateReplica(reportError);
const callable = (): void => {};
this.value = new Proxy(callable, {
apply: (_target, _thisArg, args) => this.#call(args),
get: (_target, property) => {
if (property === "value") {
this.#assertAccess();
this.#expect("state");
return this.#state.value;
}
if (property === "subscribe") return this.#subscribe.bind(this);
if (property === Symbol.toStringTag) return "RemoteServiceMember";
if (property === "then") return undefined;
return undefined;
},
});
}
setDescription(kind: ServiceMemberKind): void {
if (this.#kind !== undefined && this.#kind !== kind) {
throw new Error(`Remote service member ${this.#serviceId}.${this.#member} changed kind`);
}
this.#kind = kind;
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${this.#serviceId}.${this.#member} is ${kind}, not ${this.#expectedKind}`,
);
}
}
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
this.setDescription("state");
this.#state.hydrate(sequence, ops, context);
}
update(sequence: number, ops: readonly Op[], context: Context): void {
this.setDescription("state");
this.#state.update(sequence, ops, context);
}
clear(): void {
this.#state.clear();
}
#subscribe(listener: (value: JsonValue, context: Context) => void): () => void {
this.#assertAccess();
if (typeof listener !== "function") {
throw new TypeError("Replicated state subscription listener must be a function");
}
this.#expect("state");
return this.#state.subscribe(listener);
}
#expect(kind: ServiceMemberKind): void {
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${this.#serviceId}.${this.#member} was used as two different kinds`,
);
}
this.#expectedKind = kind;
if (this.#kind !== undefined && this.#kind !== kind) {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${this.#serviceId}.${this.#member} is ${this.#kind}, not ${kind}`,
);
}
}
#call(args: unknown[]): Promise<JsonValue | undefined> {
this.#assertAccess();
this.#expect("method");
if (!this.#isActive()) {
return Promise.reject(
new RemoteServiceError("service_stale_instance", `Remote service ${this.#serviceId} binding is closed`),
);
}
const context = args.at(-1);
if (!isContext(context)) {
return Promise.reject(
new RemoteServiceError(
"service_invalid_value",
`Remote service method ${this.#serviceId}.${this.#member} requires a trailing Context`,
),
);
}
const businessArgs = args.slice(0, -1) as JsonValue[];
return this.#invoke(businessArgs, context);
}
}
class ServiceFacade {
readonly #serviceId: string;
readonly #address: ServiceInstanceAddress | undefined;
readonly #transport: RemoteServiceTransport;
readonly #reportError: ErrorReporter;
readonly #slots = new Map<string, MemberSlot>();
readonly #descriptions = new Map<string, ServiceMemberKind>();
readonly #isActive: () => boolean;
readonly #assertAccess: () => void;
readonly proxy: object;
constructor(
serviceId: string,
address: ServiceInstanceAddress | undefined,
transport: RemoteServiceTransport,
isActive: () => boolean,
assertAccess: () => void,
reportError: ErrorReporter,
) {
this.#serviceId = serviceId;
this.#address = address;
this.#transport = transport;
this.#isActive = isActive;
this.#assertAccess = assertAccess;
this.#reportError = reportError;
this.proxy = new Proxy(Object.create(null) as object, {
get: (_target, property) => {
if (typeof property !== "string") return undefined;
return this.#slot(property).value;
},
});
}
install(snapshot: ServiceInstanceSnapshot, context: Context): void {
if (!sameAddress(snapshot.instance, this.#address))
throw new Error("Remote service snapshot has the wrong address");
const members = validateMembers(snapshot.members);
for (const name of this.#slots.keys()) {
if (!members.has(name)) {
throw new RemoteServiceError(
"service_member_not_found",
`Unknown remote service member ${this.#serviceId}.${name}`,
);
}
}
this.#descriptions.clear();
for (const [name, member] of members) this.#descriptions.set(name, member.kind);
for (const member of members.values()) {
const slot = this.#slots.get(member.name);
if (member.kind === "state") {
(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.ops, context);
} else {
slot?.setDescription(member.kind);
}
}
}
update(member: string, sequence: number, ops: readonly Op[], context: Context): void {
if (this.#descriptions.get(member) !== "state") {
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
}
this.#slot(member).update(sequence, ops, context);
}
clear(): void {
for (const slot of this.#slots.values()) slot.clear();
}
#slot(member: string): MemberSlot {
let slot = this.#slots.get(member);
if (slot !== undefined) return slot;
slot = new MemberSlot(
this.#serviceId,
member,
(args, context) =>
this.#transport.invoke(
{
serviceId: this.#serviceId,
...(this.#address === undefined ? {} : { instance: this.#address }),
member,
args,
},
context,
),
this.#isActive,
this.#assertAccess,
this.#reportError,
);
const kind = this.#descriptions.get(member);
if (kind !== undefined) slot.setDescription(kind);
this.#slots.set(member, slot);
return slot;
}
}
interface SingletonBinding {
facade: ServiceFacade;
subscription?: ServiceSubscription;
starting?: Promise<void>;
active: boolean;
revision: number;
}
interface KeyedInstance extends InstanceDirectoryEntry {
readonly facade: ServiceFacade;
}
class KeyedBinding<T> {
readonly #service: Service<T>;
readonly #transport: RemoteServiceTransport;
readonly #reportError: ErrorReporter;
readonly #assertAccess: () => void;
readonly #onEmpty: () => void;
readonly #instances: InstanceDirectory<KeyedInstance>;
#subscription: ServiceSubscription | undefined;
#starting: Promise<void> | undefined;
#closed = false;
#bound: boolean;
#revision = 0;
constructor(
service: Service<T>,
transport: RemoteServiceTransport,
reportError: ErrorReporter,
assertAccess: () => void,
onEmpty: () => void,
bound: boolean,
) {
this.#service = service;
this.#transport = transport;
this.#reportError = reportError;
this.#assertAccess = assertAccess;
this.#onEmpty = onEmpty;
this.#bound = bound;
this.#instances = new InstanceDirectory({ ready: false, onError: reportError });
}
observe(handler: (service: T, context: Context) => void | Promise<void>): () => void {
if (this.#closed) throw new Error("Remote keyed service binding is closed");
let stopped = false;
const stop = this.#instances.observe<T>((service, context) => {
const slot = new ServiceSlot(this.#service.id, true);
void slot.bind(service as object);
return handler(
slot.view<T>(() => {
this.#assertAccess();
if (stopped || context.abortSignal?.aborted) {
throw new RemoteServiceError(
"service_stale_instance",
`Remote service ${this.#service.id} observation is closed`,
);
}
}),
context,
);
});
if (this.#bound && this.#starting === undefined) {
const revision = this.#revision;
const starting = this.#start(revision);
this.#starting = starting;
void starting.catch((error: unknown) => {
if (!this.#closed && this.#revision === revision && this.#bound) this.#reportError(toError(error));
});
}
return () => {
if (stopped) return;
stopped = true;
stop();
if (this.#instances.observerCount === 0) this.#onEmpty();
};
}
async rebind(bound: boolean, context: Context): Promise<void> {
if (this.#closed) return;
this.#bound = bound;
const revision = ++this.#revision;
await this.#reset(context, false);
if (this.#closed || this.#revision !== revision || this.#bound !== bound) return;
if (bound && this.#instances.observerCount > 0) {
const starting = this.#start(revision);
this.#starting = starting;
await starting;
}
}
ready(): Promise<void> {
return this.#starting ?? Promise.resolve();
}
async close(context: Context): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#revision += 1;
await this.#reset(context, true);
this.#instances.dispose();
}
async #reset(context: Context, waitForStarting: boolean): Promise<void> {
this.#instances.reset();
const starting = this.#starting;
this.#starting = undefined;
const subscription = this.#subscription;
this.#subscription = undefined;
await Promise.all([
waitForStarting ? starting?.catch(() => {}) : undefined,
subscription === undefined ? undefined : subscription.close(context),
]);
}
async #start(revision: number): Promise<void> {
const subscription = await this.#transport.subscribe(
this.#service.id,
"keyed",
(update, context) => {
if (this.#revision === revision) this.#update(update, context);
},
BACKGROUND_CONTEXT,
);
if (this.#closed || !this.#bound || this.#revision !== revision) {
await subscription.close(BACKGROUND_CONTEXT);
return;
}
this.#subscription = subscription;
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
}
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, serviceDeliveryContext());
subscription.activate();
this.#instances.ready();
}
#update(update: ServiceProviderUpdate, context: Context): void {
if (this.#closed) return;
try {
switch (update.type) {
case "unavailable":
case "replaced":
throw new Error("Keyed service received a singleton lifecycle update");
case "spawned":
this.#spawn(update.instance, context);
break;
case "closed": {
const instance = this.#instances.get(update.instance.key);
if (instance?.generation === update.instance.generation) this.#instances.remove(instance);
break;
}
case "state": {
if (update.instance === undefined) throw new Error("Keyed state update has no instance address");
const instance = this.#instances.get(update.instance.key);
if (instance?.generation !== update.instance.generation) return;
instance.facade.update(update.member, update.sequence, update.ops, context);
break;
}
}
} catch (error) {
this.#reportError(toError(error));
}
}
#spawn(snapshot: ServiceInstanceSnapshot, context: Context): void {
const address = snapshot.instance;
if (address === undefined) throw new Error("Keyed service instance snapshot has no address");
let active = true;
const facade = new ServiceFacade(
this.#service.id,
address,
this.#transport,
() => active && !this.#closed,
this.#assertAccess,
this.#reportError,
);
facade.install(snapshot, context);
this.#instances.replace({
key: address.key,
generation: address.generation,
service: facade.proxy,
facade,
deactivate: () => {
active = false;
facade.clear();
},
});
}
}
export class RemoteServiceBindingImpl implements RemoteServiceBinding {
readonly #transport: RemoteServiceTransport;
readonly #allowlist = new Set<string>();
readonly #reportError: ErrorReporter;
readonly #modes = new Map<string, "singleton" | "keyed">();
readonly #assertAccess: () => void;
readonly #singletons = new Map<string, SingletonBinding>();
readonly #keyed = new Map<string, KeyedBinding<unknown>>();
#bound: boolean;
#readinessRevision = 0;
#bindingTransition = Promise.resolve();
#disposed = false;
constructor(options: RemoteServiceBindingOptions) {
this.#transport = options.transport;
const ids = options.services.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service binding has duplicate service IDs");
for (const id of ids) this.#allowlist.add(id);
this.#reportError = options.onError ?? (() => {});
this.#assertAccess = options.assertAccess ?? (() => {});
this.#bound = options.bound ?? true;
}
use<T>(service: Service<T>): T {
this.#assertRemotable(service);
this.#assertAvailable(service.id, "singleton");
let binding = this.#singletons.get(service.id);
if (binding !== undefined) return binding.facade.proxy as T;
binding = { facade: undefined as unknown as ServiceFacade, active: true, revision: 0 };
binding.facade = new ServiceFacade(
service.id,
undefined,
this.#transport,
() => binding!.active && !this.#disposed && this.#bound,
() => this.#assertHandleAccess(),
this.#reportError,
);
this.#singletons.set(service.id, binding);
this.#readinessRevision += 1;
if (this.#bound) {
const revision = binding.revision;
const starting = this.#startSingleton(service.id, binding, revision);
binding.starting = starting;
void starting.catch((error: unknown) => {
if (binding.active && binding.revision === revision && !this.#disposed && this.#bound) {
this.#reportError(toError(error));
}
});
}
return binding.facade.proxy as T;
}
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
this.#assertRemotable(service);
this.#assertAvailable(service.id, "keyed");
let binding = this.#keyed.get(service.id) as KeyedBinding<T> | undefined;
if (binding === undefined) {
binding = new KeyedBinding(
service,
this.#transport,
this.#reportError,
() => this.#assertHandleAccess(),
() => {
if (this.#keyed.get(service.id) !== binding) return;
this.#keyed.delete(service.id);
this.#readinessRevision += 1;
void binding!.close(BACKGROUND_CONTEXT).catch(this.#reportError);
},
this.#bound,
);
this.#keyed.set(service.id, binding as KeyedBinding<unknown>);
this.#readinessRevision += 1;
}
return binding.observe(handler);
}
async ready(context: Context): Promise<void> {
if (this.#disposed) throw new Error("Remote service binding is disposed");
while (true) {
const revision = this.#readinessRevision;
const starts = [
this.#bindingTransition,
...[...this.#singletons.values()].flatMap((binding) =>
binding.starting === undefined ? [] : [binding.starting],
),
...[...this.#keyed.values()].map((binding) => binding.ready()),
];
await awaitWithContext(
Promise.all(starts).then(() => undefined),
context,
);
if (this.#disposed) throw new Error("Remote service binding is disposed");
if (revision === this.#readinessRevision) return;
}
}
async rebind(bound: boolean, context: Context): Promise<void> {
if (this.#disposed) throw new Error("Remote service binding is disposed");
this.#bound = bound;
this.#readinessRevision += 1;
const transitions: Promise<void>[] = [];
for (const [serviceId, binding] of this.#singletons) {
binding.revision += 1;
binding.facade.clear();
const subscription = binding.subscription;
delete binding.subscription;
const revision = binding.revision;
const starting = (async () => {
await subscription?.close(context);
if (bound) await this.#startSingleton(serviceId, binding, revision);
})();
binding.starting = starting;
transitions.push(starting);
}
for (const binding of this.#keyed.values()) transitions.push(binding.rebind(bound, context));
const completion = (async () => {
const results = await Promise.allSettled(transitions);
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (errors.length > 0)
throw new AggregateError(
errors.map(({ reason }) => reason),
"Failed to rebind services",
);
})();
this.#bindingTransition = completion;
await completion;
}
async dispose(context: Context): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
const closes: Promise<void>[] = [];
for (const binding of this.#singletons.values()) {
binding.active = false;
binding.facade.clear();
if (binding.starting) closes.push(binding.starting.catch(() => {}));
if (binding.subscription) closes.push(Promise.resolve(binding.subscription.close(context)));
}
for (const binding of this.#keyed.values()) closes.push(binding.close(context));
this.#singletons.clear();
this.#keyed.clear();
const results = await Promise.allSettled(closes);
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (errors.length > 0)
throw new AggregateError(
errors.map(({ reason }) => reason),
"Failed to dispose services",
);
}
async #startSingleton(serviceId: string, binding: SingletonBinding, revision: number): Promise<void> {
const subscription = await this.#transport.subscribe(
serviceId,
"singleton",
(update, context) => {
if (!binding.active || binding.revision !== revision) return;
try {
if (update.type === "unavailable") {
binding.facade.clear();
} else if (update.type === "replaced") {
if (update.snapshot.instance !== undefined) {
throw new Error("Singleton replacement has an instance address");
}
binding.facade.install(update.snapshot, context);
} else if (update.type === "state" && update.instance === undefined) {
binding.facade.update(update.member, update.sequence, update.ops, context);
}
} catch (error) {
this.#reportError(toError(error));
}
},
BACKGROUND_CONTEXT,
);
if (!binding.active || this.#disposed || !this.#bound || binding.revision !== revision) {
await subscription.close(BACKGROUND_CONTEXT);
return;
}
binding.subscription = subscription;
const snapshot = subscription.snapshot;
if (snapshot.mode !== "singleton" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {
throw new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);
}
binding.facade.install(snapshot.instances[0]!, serviceDeliveryContext());
subscription.activate();
}
#assertHandleAccess(): void {
if (this.#disposed) throw new Error("Remote service binding is disposed");
this.#assertAccess();
}
#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {
if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
}
#assertAvailable(serviceId: string, mode: "singleton" | "keyed"): void {
if (this.#disposed) throw new Error("Remote service binding is disposed");
if (!this.#allowlist.has(serviceId)) {
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
}
const existing = this.#modes.get(serviceId);
if (existing !== undefined && existing !== mode) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${serviceId} is already used as ${existing}`,
);
}
this.#modes.set(serviceId, mode);
}
}
function validateMembers(members: readonly ServiceMemberSnapshot[]): ReadonlyMap<string, ServiceMemberSnapshot> {
const result = new Map<string, ServiceMemberSnapshot>();
for (const member of members) {
if (member.name.length === 0 || result.has(member.name)) {
throw new Error("Remote service has invalid member descriptions");
}
result.set(member.name, member);
}
return result;
}
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress | undefined): boolean {
if (left === undefined || right === undefined) return left === right;
return left.key === right.key && left.generation === right.generation;
}
function isContext(value: unknown): value is Context {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<Context>;
return typeof candidate.value === "function" && typeof candidate.toString === "function";
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View File

@@ -1,26 +0,0 @@
export const REMOTE_SERVICE_ERROR_CODES = [
"service_not_allowed",
"service_not_found",
"service_mode_mismatch",
"service_member_not_found",
"service_member_mismatch",
"service_instance_not_found",
"service_stale_instance",
"service_invalid_value",
] as const;
export type RemoteServiceErrorCode = (typeof REMOTE_SERVICE_ERROR_CODES)[number];
export function isRemoteServiceErrorCode(value: unknown): value is RemoteServiceErrorCode {
return typeof value === "string" && REMOTE_SERVICE_ERROR_CODES.includes(value as RemoteServiceErrorCode);
}
export class RemoteServiceError extends Error {
readonly code: RemoteServiceErrorCode;
constructor(code: RemoteServiceErrorCode, message: string) {
super(message);
this.name = "RemoteServiceError";
this.code = code;
}
}

View File

@@ -1,113 +0,0 @@
interface ResolvedValue {
readonly value: unknown;
readonly receiver: object;
}
type ValueResolver = () => ResolvedValue;
/** Host-owned mutable target with consumer-owned guarded views. */
export class ServiceSlot {
readonly #serviceId: string;
readonly #wrapObjects: boolean;
#implementation: object | undefined;
constructor(serviceId: string, wrapObjects: boolean) {
this.#serviceId = serviceId;
this.#wrapObjects = wrapObjects;
}
view<T>(assertAccess: () => void): T {
return new ServiceView(this, assertAccess, this.#wrapObjects).proxy as T;
}
bind(implementation: object): void {
this.#implementation = implementation;
}
unbind(): void {
this.#implementation = undefined;
}
resolve(property: PropertyKey, assertAccess: () => void): ResolvedValue {
assertAccess();
const implementation = this.#implementation;
if (implementation === undefined) throw new Error(`Service ${this.#serviceId} is disconnected`);
return {
value: Reflect.get(implementation, property, implementation),
receiver: implementation,
};
}
}
class ServiceView {
readonly #slot: ServiceSlot;
readonly #assertAccess: () => void;
readonly #wrapObjects: boolean;
readonly #members = new Map<PropertyKey, ValueView>();
readonly proxy: object;
constructor(slot: ServiceSlot, assertAccess: () => void, wrapObjects: boolean) {
this.#slot = slot;
this.#assertAccess = assertAccess;
this.#wrapObjects = wrapObjects;
this.proxy = new Proxy(Object.create(null) as object, {
get: (_target, property) => this.#getMember(property),
});
}
#getMember(property: PropertyKey): unknown {
const resolve = (): ResolvedValue => this.#slot.resolve(property, this.#assertAccess);
const current = resolve().value;
if (!isObject(current) || (typeof current !== "function" && !this.#wrapObjects)) return current;
let member = this.#members.get(property);
if (member === undefined) {
member = new ValueView(resolve, typeof current === "function");
this.#members.set(property, member);
}
return member.proxy;
}
}
class ValueView {
readonly #resolve: ValueResolver;
readonly #children = new Map<PropertyKey, ValueView>();
readonly proxy: object;
constructor(resolve: ValueResolver, callable: boolean) {
this.#resolve = resolve;
const target: object = callable ? () => undefined : Object.create(null);
this.proxy = new Proxy(target, {
apply: (_target, _thisArg, args) => this.#invoke(args),
get: (_target, property) => this.#get(property),
});
}
#invoke(args: unknown[]): unknown {
const { value, receiver } = this.#resolve();
if (typeof value !== "function") throw new TypeError("Service member is not callable");
return Reflect.apply(value, receiver, args);
}
#get(property: PropertyKey): unknown {
const resolve = (): ResolvedValue => {
const parent = this.#resolve();
if (!isObject(parent.value)) throw new TypeError("Service member does not have properties");
return {
value: Reflect.get(parent.value, property, parent.value),
receiver: parent.value,
};
};
const current = resolve().value;
if (typeof current !== "function") return current;
let child = this.#children.get(property);
if (child === undefined) {
child = new ValueView(resolve, true);
this.#children.set(property, child);
}
return child.proxy;
}
}
function isObject(value: unknown): value is object {
return (typeof value === "object" && value !== null) || typeof value === "function";
}

View File

@@ -1,147 +0,0 @@
import { BACKGROUND_CONTEXT, withCancel } from "../context/index.ts";
import type { Context } from "../types.ts";
export interface InstanceDirectoryEntry {
readonly key: string;
readonly generation: number;
readonly service: object;
deactivate(): void;
}
interface Observer {
readonly handler: (service: object, context: Context) => void | Promise<void>;
readonly tasks: Map<InstanceDirectoryEntry, { readonly cancel: (reason?: unknown) => void }>;
closed: boolean;
}
/** Owns keyed instance lifetime and the cancellable tasks observing those instances. */
export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
readonly #entries = new Map<string, TEntry>();
readonly #observers = new Set<Observer>();
readonly #reportError: (error: Error) => void;
#ready: boolean;
#disposed = false;
constructor(options: { readonly ready: boolean; readonly onError: (error: Error) => void }) {
this.#ready = options.ready;
this.#reportError = options.onError;
}
get observerCount(): number {
return this.#observers.size;
}
get(key: string): TEntry | undefined {
return this.#entries.get(key);
}
insert(entry: TEntry): void {
this.#assertActive();
if (this.#entries.has(entry.key)) {
throw new Error(`Keyed service already has a live instance with key ${entry.key}`);
}
this.#entries.set(entry.key, entry);
if (this.#ready) this.#startAll(entry);
}
replace(entry: TEntry): void {
this.#assertActive();
const previous = this.#entries.get(entry.key);
if (previous !== undefined) {
if (previous.generation === entry.generation) {
throw new Error("Keyed service repeated a live generation");
}
this.#remove(previous);
}
this.#entries.set(entry.key, entry);
if (this.#ready) this.#startAll(entry);
}
remove(entry: TEntry): void {
if (this.#entries.get(entry.key) !== entry) return;
this.#remove(entry);
}
ready(): void {
this.#assertActive();
if (this.#ready) return;
this.#ready = true;
for (const entry of this.#entries.values()) this.#startAll(entry);
}
reset(): void {
if (this.#disposed) return;
this.#ready = false;
for (const entry of [...this.#entries.values()]) this.#remove(entry);
}
observe<T>(handler: (service: T, context: Context) => void | Promise<void>): () => void {
this.#assertActive();
const observer: Observer = {
handler: handler as Observer["handler"],
tasks: new Map(),
closed: false,
};
this.#observers.add(observer);
if (this.#ready) {
for (const entry of this.#entries.values()) this.#start(observer, entry);
}
return () => {
if (observer.closed) return;
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
this.#observers.delete(observer);
};
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
for (const observer of this.#observers) {
observer.closed = true;
for (const task of observer.tasks.values()) task.cancel();
observer.tasks.clear();
}
this.#observers.clear();
for (const entry of [...this.#entries.values()]) {
entry.deactivate();
}
this.#entries.clear();
}
#remove(entry: TEntry): void {
if (this.#entries.get(entry.key) !== entry) return;
this.#entries.delete(entry.key);
entry.deactivate();
for (const observer of this.#observers) {
observer.tasks.get(entry)?.cancel();
observer.tasks.delete(entry);
}
}
#startAll(entry: TEntry): void {
for (const observer of this.#observers) this.#start(observer, entry);
}
#start(observer: Observer, entry: TEntry): void {
if (observer.closed || observer.tasks.has(entry)) return;
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
observer.tasks.set(entry, { cancel });
try {
void Promise.resolve(observer.handler(entry.service, context)).catch((error: unknown) => {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
});
} catch (error) {
if (!context.abortSignal?.aborted) this.#reportError(toError(error));
}
}
#assertActive(): void {
if (this.#disposed) throw new Error("Keyed service directory is disposed");
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View File

@@ -1,17 +0,0 @@
import type { RemoteServiceTransport } from "../types.ts";
import type { RemoteServiceProvider } from "./provider.ts";
/** Connects a provider to a binding without changing remote service semantics. */
export function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
return {
invoke: (call, context) => provider.invoke(call, context),
subscribe: async (serviceId, mode, listener) => {
const subscription = provider.subscribe(serviceId, mode, listener);
return {
snapshot: subscription.snapshot,
activate: () => subscription.activate(),
close: () => subscription.close(),
};
},
};
}

View File

@@ -1,586 +0,0 @@
import type {
Context,
JsonValue,
RemoteServiceContract,
Service,
ServiceCall,
ServiceCatalogueEntry,
ServiceInstanceAddress,
ServiceInstanceSnapshot,
ServiceMemberSnapshot,
ServiceMode,
ServiceProviderUpdate,
ServiceSubscription,
ServiceSubscriptionSnapshot,
} from "../types.ts";
import { RemoteServiceError } from "./errors.ts";
import { serviceDeliveryContext } from "./state.ts";
import { getReplicatedStateInternals, type ReplicatedStateInternals } from "./state-internals.ts";
import { decodeServiceControlCall } from "./wire.ts";
type RemoteMethod = (...args: unknown[]) => unknown;
type InstanceMember =
| { readonly kind: "method"; readonly method: RemoteMethod }
| { readonly kind: "state"; readonly state: ReplicatedStateInternals };
type ServiceMemberKind = InstanceMember["kind"];
interface ClassifiedRemoteServiceImplementation {
readonly implementation: object;
readonly members: Map<string, InstanceMember>;
}
interface ProviderInstance {
readonly address?: ServiceInstanceAddress;
readonly implementation: object;
readonly members: ReadonlyMap<string, InstanceMember>;
readonly removeMemberListeners: readonly (() => void)[];
active: boolean;
}
interface ProviderSubscriber {
readonly listener: (update: ServiceProviderUpdate, context: Context) => void;
readonly buffer: { readonly update: ServiceProviderUpdate; readonly context: Context }[];
active: boolean;
terminated: boolean;
closed: boolean;
}
interface ServiceProviderDefinition {
readonly service: { readonly id: string; readonly local?: boolean };
readonly mode: ServiceMode;
}
interface ServiceRegistration {
readonly serviceId: string;
readonly mode: ServiceMode;
singleton?: ProviderInstance;
singletonShape?: ReadonlyMap<string, ServiceMemberKind>;
readonly instances: Map<string, ProviderInstance>;
readonly generations: Map<string, number>;
readonly subscribers: Set<ProviderSubscriber>;
}
export type ServiceUpdatePublisher = (
subscriptionId: string,
update: ServiceProviderUpdate,
context: Context,
) => void | Promise<void>;
/** Hosts one provider for one remote consumer and owns that consumer's subscriptions. */
export interface RemoteServiceEndpoint {
invoke(call: ServiceCall, publish: ServiceUpdatePublisher, context: Context): Promise<JsonValue | undefined>;
dispose(): void;
}
export class RemoteServiceProvider {
readonly #catalogue: readonly ServiceCatalogueEntry[];
readonly #registrations = new Map<string, ServiceRegistration>();
#disposed = false;
constructor(entries: readonly (ServiceProviderDefinition | { readonly id: string })[]) {
const definitions = entries.map(
(entry): ServiceProviderDefinition => ("service" in entry ? entry : { service: entry, mode: "singleton" }),
);
for (const { service } of definitions) {
if ("local" in service && service.local === true) {
throw new TypeError(`Local service ${service.id} cannot be published remotely`);
}
}
const ids = definitions.map(({ service }) => service.id);
if (new Set(ids).size !== ids.length) throw new TypeError("Remote service catalogue contains duplicate IDs");
this.#catalogue = Object.freeze(
definitions.map(({ service, mode }) => Object.freeze({ serviceId: service.id, mode })),
);
for (const { service, mode } of definitions) {
this.#registrations.set(service.id, {
serviceId: service.id,
mode,
instances: new Map(),
generations: new Map(),
subscribers: new Set(),
});
}
}
get catalogue(): readonly ServiceCatalogueEntry[] {
return this.#catalogue;
}
provide<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
this.#assertActive();
this.#assertRemotable(service);
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton");
if (registration.singleton !== undefined) {
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${service.id} already has a provider`);
}
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
const shape = serviceMemberShape(classified.members);
this.#assertSingletonShape(registration, shape);
registration.singleton = this.#createInstance(registration, classified, undefined);
registration.singletonShape = shape;
}
/** Disconnect one singleton while preserving active subscriptions and remote facades. */
withdraw<T>(service: Service<T>): void {
this.#assertActive();
this.#assertRemotable(service);
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton");
const previous = registration.singleton;
if (previous === undefined) return;
previous.active = false;
for (const remove of previous.removeMemberListeners) remove();
delete registration.singleton;
this.#emit(registration, { type: "unavailable" });
}
/** Check a singleton replacement without changing the active provider. */
validateReplacement<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
this.#assertActive();
this.#assertRemotable(service);
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton");
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
this.#assertSingletonShape(registration, serviceMemberShape(classified.members));
}
/** Replace one singleton without making its stable remote facade unavailable. */
replace<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
this.#assertActive();
this.#assertRemotable(service);
this.#assertAllowed(service.id);
const registration = this.#registration(service.id, "singleton");
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
const shape = serviceMemberShape(classified.members);
this.#assertSingletonShape(registration, shape);
const replacement = this.#createInstance(registration, classified, undefined);
const previous = registration.singleton;
if (previous !== undefined) {
previous.active = false;
for (const remove of previous.removeMemberListeners) remove();
}
registration.singleton = replacement;
registration.singletonShape = shape;
this.#emit(registration, { type: "replaced", snapshot: this.#snapshotInstance(replacement) });
}
use<T>(service: Service<T>): T {
this.#assertActive();
this.#assertRemotable(service);
this.#assertAllowed(service.id);
const registration = this.#registrations.get(service.id);
if (registration?.mode !== "singleton" || registration.singleton === undefined) {
throw new RemoteServiceError("service_not_found", `Remote service ${service.id} has no local provider`);
}
return registration.singleton.implementation as T;
}
spawn<T>(service: Service<T>, key: string, implementation: NoInfer<RemoteServiceContract<T>>): () => void {
this.#assertActive();
this.#assertRemotable(service);
this.#assertAllowed(service.id);
if (key.length === 0) throw new TypeError("Remote service instance key must not be empty");
const registration = this.#registration(service.id, "keyed");
if (registration.instances.has(key)) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${service.id} already has a live instance with key ${key}`,
);
}
const generation = (registration.generations.get(key) ?? 0) + 1;
registration.generations.set(key, generation);
const address = { key, generation } satisfies ServiceInstanceAddress;
const classified = classifyRemoteServiceImplementation(registration.serviceId, implementation);
const instance = this.#createInstance(registration, classified, address);
registration.instances.set(key, instance);
this.#emit(registration, { type: "spawned", instance: this.#snapshotInstance(instance) });
let closed = false;
return () => {
if (closed) return;
closed = true;
if (registration.instances.get(key) !== instance) return;
instance.active = false;
for (const remove of instance.removeMemberListeners) remove();
registration.instances.delete(key);
this.#emit(registration, { type: "closed", instance: address });
};
}
async invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined> {
this.#assertActive();
this.#assertAllowed(call.serviceId);
const registration = this.#registrations.get(call.serviceId);
if (registration === undefined) {
throw new RemoteServiceError("service_not_found", `Unknown remote service ${call.serviceId}`);
}
const instance = this.#resolveInstance(registration, call.instance);
const member = instance.members.get(call.member);
if (member === undefined) {
throw new RemoteServiceError(
"service_member_not_found",
`Unknown remote service member ${call.serviceId}.${call.member}`,
);
}
if (member.kind !== "method") {
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service member ${call.serviceId}.${call.member} is not a method`,
);
}
const result: unknown = await Reflect.apply(member.method, instance.implementation, [...call.args, context]);
return result as JsonValue | undefined;
}
subscribe(
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void,
): ServiceSubscription {
this.#assertActive();
this.#assertAllowed(serviceId);
const registration = this.#registration(serviceId, mode);
if (registration.mode === "singleton" && registration.singleton === undefined) {
throw new RemoteServiceError("service_not_found", `Remote service ${serviceId} has no provider`);
}
const subscriber: ProviderSubscriber = {
listener,
buffer: [],
active: false,
terminated: false,
closed: false,
};
this.#publishPending(registration);
registration.subscribers.add(subscriber);
const snapshot = this.#snapshot(registration);
return {
snapshot,
activate: () => {
if (subscriber.closed || subscriber.active) return;
subscriber.active = true;
const errors: unknown[] = [];
try {
for (const entry of subscriber.buffer.splice(0)) {
try {
listener(entry.update, entry.context);
} catch (error) {
errors.push(error);
}
}
} finally {
if (subscriber.terminated) subscriber.closed = true;
}
throwCollectedErrors(errors, "Failed to activate remote service subscription");
},
close: () => {
if (subscriber.closed) return;
subscriber.closed = true;
subscriber.buffer.length = 0;
registration.subscribers.delete(subscriber);
},
};
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
const errors: unknown[] = [];
for (const registration of this.#registrations.values()) {
const singleton = registration.singleton;
if (singleton !== undefined) {
singleton.active = false;
for (const remove of singleton.removeMemberListeners) remove();
delete registration.singleton;
try {
this.#emit(registration, { type: "unavailable" });
} catch (error) {
errors.push(error);
}
}
for (const [key, instance] of [...registration.instances]) {
instance.active = false;
for (const remove of instance.removeMemberListeners) remove();
registration.instances.delete(key);
try {
this.#emit(registration, { type: "closed", instance: instance.address! });
} catch (error) {
errors.push(error);
}
}
for (const subscriber of registration.subscribers) {
if (subscriber.active) {
subscriber.closed = true;
subscriber.buffer.length = 0;
} else {
subscriber.terminated = true;
}
}
registration.subscribers.clear();
}
this.#registrations.clear();
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose remote service provider");
}
#registration(serviceId: string, mode: ServiceMode): ServiceRegistration {
const registration = this.#registrations.get(serviceId);
if (registration === undefined) {
throw new RemoteServiceError("service_not_found", `Unknown remote service ${serviceId}`);
}
if (registration.mode !== mode) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${serviceId} is ${registration.mode}, not ${mode}`,
);
}
return registration;
}
#createInstance(
registration: ServiceRegistration,
classified: ClassifiedRemoteServiceImplementation,
address: ServiceInstanceAddress | undefined,
): ProviderInstance {
const removeMemberListeners: (() => void)[] = [];
const instance: ProviderInstance = {
...(address === undefined ? {} : { address }),
implementation: classified.implementation,
members: classified.members,
removeMemberListeners,
active: true,
};
for (const [name, member] of classified.members) {
if (member.kind !== "state") continue;
removeMemberListeners.push(
member.state.subscribe((ops, sequence, context) => {
if (!instance.active) return;
this.#emit(
registration,
{
type: "state",
...(address === undefined ? {} : { instance: address }),
member: name,
sequence,
ops,
},
context,
);
}),
);
}
return instance;
}
#assertSingletonShape(registration: ServiceRegistration, replacement: ReadonlyMap<string, ServiceMemberKind>): void {
const current = registration.singletonShape;
if (current === undefined || sameServiceMemberShape(current, replacement)) return;
throw new RemoteServiceError(
"service_member_mismatch",
`Remote service ${registration.serviceId} replacement must preserve its member shape`,
);
}
#resolveInstance(registration: ServiceRegistration, address: ServiceInstanceAddress | undefined): ProviderInstance {
if (registration.mode === "singleton") {
if (address !== undefined) {
throw new RemoteServiceError(
"service_mode_mismatch",
`Remote service ${registration.serviceId} is singleton`,
);
}
if (registration.singleton === undefined) {
throw new RemoteServiceError(
"service_not_found",
`Remote service ${registration.serviceId} has no provider`,
);
}
return registration.singleton;
}
if (address === undefined) {
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${registration.serviceId} is keyed`);
}
const instance = registration.instances.get(address.key);
if (instance === undefined) {
throw new RemoteServiceError(
"service_instance_not_found",
`Remote service ${registration.serviceId} has no instance ${address.key}`,
);
}
if (instance.address?.generation !== address.generation) {
throw new RemoteServiceError(
"service_stale_instance",
`Remote service ${registration.serviceId} instance ${address.key} is stale`,
);
}
return instance;
}
#publishPending(registration: ServiceRegistration): void {
const context = serviceDeliveryContext();
const instances =
registration.mode === "singleton"
? registration.singleton === undefined
? []
: [registration.singleton]
: registration.instances.values();
for (const instance of instances) {
for (const member of instance.members.values()) {
if (member.kind === "state") member.state.publish(context);
}
}
}
#snapshot(registration: ServiceRegistration): ServiceSubscriptionSnapshot {
const instances =
registration.mode === "singleton"
? registration.singleton
? [this.#snapshotInstance(registration.singleton)]
: []
: [...registration.instances.values()]
.sort((left, right) => left.address!.key.localeCompare(right.address!.key))
.map((instance) => this.#snapshotInstance(instance));
return { serviceId: registration.serviceId, mode: registration.mode, instances };
}
#snapshotInstance(instance: ProviderInstance): ServiceInstanceSnapshot {
const members: ServiceMemberSnapshot[] = [];
for (const [name, member] of instance.members) {
if (member.kind === "method") {
members.push({ name, kind: "method" });
} else {
members.push({
name,
kind: "state",
sequence: member.state.sequence,
ops: [["r", member.state.value as JsonValue]],
});
}
}
return {
...(instance.address === undefined ? {} : { instance: instance.address }),
members,
};
}
#emit(registration: ServiceRegistration, update: ServiceProviderUpdate, context?: Context): void {
if (registration.subscribers.size === 0) return;
const deliveryContext = context ?? serviceDeliveryContext();
const errors: unknown[] = [];
for (const subscriber of registration.subscribers) {
if (subscriber.closed) continue;
const entry = { update, context: deliveryContext };
if (!subscriber.active) {
subscriber.buffer.push(entry);
continue;
}
try {
subscriber.listener(entry.update, entry.context);
} catch (error) {
errors.push(error);
}
}
throwCollectedErrors(errors, `Failed to publish remote service ${registration.serviceId} update`);
}
#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {
if (service.local) throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
}
#assertAllowed(serviceId: string): void {
if (!this.#registrations.has(serviceId)) {
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
}
}
#assertActive(): void {
if (this.#disposed) throw new Error("Remote service provider is disposed");
}
}
export function createRemoteServiceEndpoint(provider: RemoteServiceProvider): RemoteServiceEndpoint {
const subscriptions = new Map<string, ServiceSubscription>();
let disposed = false;
return {
async invoke(call, publish, context) {
if (disposed) throw new Error("Remote service endpoint is disposed");
const control = decodeServiceControlCall(call);
if (control?.type === "catalogue") return provider.catalogue as unknown as JsonValue;
if (control?.type === "subscribe") {
if (subscriptions.has(control.subscriptionId)) {
throw new Error("Service subscription ID is already active");
}
const subscription = provider.subscribe(control.serviceId, control.mode, (update, updateContext) => {
void Promise.resolve(publish(control.subscriptionId, update, updateContext)).catch(() => {});
});
subscriptions.set(control.subscriptionId, subscription);
subscription.activate();
return subscription.snapshot as unknown as JsonValue;
}
if (control?.type === "unsubscribe") {
const subscription = subscriptions.get(control.subscriptionId);
if (subscription === undefined) throw new Error("Service subscription was not found");
subscription.close();
subscriptions.delete(control.subscriptionId);
return undefined;
}
return provider.invoke(call, context);
},
dispose() {
if (disposed) return;
disposed = true;
for (const subscription of subscriptions.values()) subscription.close();
subscriptions.clear();
},
};
}
export function validateRemoteServiceImplementation(serviceId: string, implementation: unknown): void {
void classifyRemoteServiceImplementation(serviceId, implementation);
}
function classifyRemoteServiceImplementation(
serviceId: string,
implementation: unknown,
): ClassifiedRemoteServiceImplementation {
if (typeof implementation !== "object" || implementation === null || Array.isArray(implementation)) {
throw new TypeError(`Remote service ${serviceId} implementation must be an object`);
}
const members = new Map<string, InstanceMember>();
for (const name of Object.keys(implementation).sort()) {
const descriptor = Object.getOwnPropertyDescriptor(implementation, name);
if (descriptor === undefined || !("value" in descriptor)) {
throw new TypeError(`Remote service member ${serviceId}.${name} must be a data property`);
}
if (typeof descriptor.value === "function") {
members.set(name, { kind: "method", method: descriptor.value as RemoteMethod });
continue;
}
const state = getReplicatedStateInternals(descriptor.value);
if (state !== undefined) {
members.set(name, { kind: "state", state });
continue;
}
throw new TypeError(`Remote service member ${serviceId}.${name} is not remotely exposable`);
}
if (members.size === 0) throw new TypeError(`Remote service ${serviceId} has no members`);
return { implementation, members };
}
function throwCollectedErrors(errors: readonly unknown[], message: string): void {
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, message);
}
function serviceMemberShape(members: ReadonlyMap<string, InstanceMember>): ReadonlyMap<string, ServiceMemberKind> {
return new Map([...members].map(([name, member]) => [name, member.kind]));
}
function sameServiceMemberShape(
left: ReadonlyMap<string, ServiceMemberKind>,
right: ReadonlyMap<string, ServiceMemberKind>,
): boolean {
return left.size === right.size && [...left].every(([name, kind]) => right.get(name) === kind);
}

View File

@@ -1,158 +0,0 @@
import { type Decoder, decoder, type Encoder, encoder } from "../delta/index.ts";
import type {
ServiceInstanceAddress,
ServiceInstanceSnapshot,
ServiceProviderUpdate,
ServiceSubscriptionSnapshot,
} from "../types.ts";
import type { WireServiceProviderUpdate, WireServiceSubscriptionSnapshot } from "./wire.ts";
/** Stateful operation encoders for every replicated state in one service subscription. */
export interface ServiceStateEncoder {
encodeSnapshot(snapshot: ServiceSubscriptionSnapshot): WireServiceSubscriptionSnapshot;
encodeUpdate(update: ServiceProviderUpdate): WireServiceProviderUpdate;
}
/** Stateful operation decoders for every replicated state in one service subscription. */
export interface ServiceStateDecoder {
decodeSnapshot(snapshot: WireServiceSubscriptionSnapshot): ServiceSubscriptionSnapshot;
decodeUpdate(update: WireServiceProviderUpdate): ServiceProviderUpdate;
}
interface CodecEntry<C> {
readonly instance: ServiceInstanceAddress | undefined;
readonly codec: C;
}
class StateCodecRegistry<C> {
readonly #create: () => C;
readonly #entries = new Map<string, CodecEntry<C>>();
constructor(create: () => C) {
this.#create = create;
}
reset(): void {
this.#entries.clear();
}
add(instance: ServiceInstanceAddress | undefined, member: string): C {
const key = stateKey(instance, member);
if (this.#entries.has(key)) throw new Error(`Duplicate service state ${describeState(instance, member)}`);
const codec = this.#create();
this.#entries.set(key, { instance, codec });
return codec;
}
get(instance: ServiceInstanceAddress | undefined, member: string): C {
const codec = this.#entries.get(stateKey(instance, member))?.codec;
if (codec === undefined) throw new Error(`Unknown service state ${describeState(instance, member)}`);
return codec;
}
removeInstance(instance: ServiceInstanceAddress): void {
for (const [key, entry] of this.#entries) {
if (sameAddress(entry.instance, instance)) this.#entries.delete(key);
}
}
}
export function createServiceStateEncoder(): ServiceStateEncoder {
const codecs = new StateCodecRegistry<Encoder>(encoder);
return {
encodeSnapshot(snapshot) {
codecs.reset();
return {
...snapshot,
instances: snapshot.instances.map((instance) => encodeInstance(instance, codecs)),
};
},
encodeUpdate(update) {
switch (update.type) {
case "state":
return { ...update, ops: codecs.get(update.instance, update.member).encode(update.ops) };
case "replaced":
codecs.reset();
return { ...update, snapshot: encodeInstance(update.snapshot, codecs) };
case "spawned":
return { ...update, instance: encodeInstance(update.instance, codecs) };
case "unavailable":
codecs.reset();
return update;
case "closed":
codecs.removeInstance(update.instance);
return update;
}
},
};
}
export function createServiceStateDecoder(): ServiceStateDecoder {
const codecs = new StateCodecRegistry<Decoder>(decoder);
return {
decodeSnapshot(snapshot) {
codecs.reset();
return {
...snapshot,
instances: snapshot.instances.map((instance) => decodeInstance(instance, codecs)),
};
},
decodeUpdate(update) {
switch (update.type) {
case "state":
return { ...update, ops: codecs.get(update.instance, update.member).decode(update.ops) };
case "replaced":
codecs.reset();
return { ...update, snapshot: decodeInstance(update.snapshot, codecs) };
case "spawned":
return { ...update, instance: decodeInstance(update.instance, codecs) };
case "unavailable":
codecs.reset();
return update;
case "closed":
codecs.removeInstance(update.instance);
return update;
}
},
};
}
function encodeInstance(
instance: ServiceInstanceSnapshot,
codecs: StateCodecRegistry<Encoder>,
): WireServiceSubscriptionSnapshot["instances"][number] {
return {
...instance,
members: instance.members.map((member) =>
member.kind === "state"
? { ...member, ops: codecs.add(instance.instance, member.name).encode(member.ops) }
: member,
),
};
}
function decodeInstance(
instance: WireServiceSubscriptionSnapshot["instances"][number],
codecs: StateCodecRegistry<Decoder>,
): ServiceInstanceSnapshot {
return {
...instance,
members: instance.members.map((member) =>
member.kind === "state"
? { ...member, ops: codecs.add(instance.instance, member.name).decode(member.ops) }
: member,
),
};
}
function stateKey(instance: ServiceInstanceAddress | undefined, member: string): string {
return JSON.stringify([instance?.key ?? null, instance?.generation ?? null, member]);
}
function sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress): boolean {
return left?.key === right.key && left.generation === right.generation;
}
function describeState(instance: ServiceInstanceAddress | undefined, member: string): string {
return instance === undefined ? member : `${instance.key}@${instance.generation}.${member}`;
}

View File

@@ -1,20 +0,0 @@
import type { Op } from "../delta/index.ts";
import type { Context } from "../types.ts";
export interface ReplicatedStateInternals {
readonly sequence: number;
readonly value: unknown;
publish(context: Context): void;
subscribe(listener: (ops: readonly Op[], sequence: number, context: Context) => void): () => void;
}
const sources = new WeakMap<object, ReplicatedStateInternals>();
export function registerReplicatedStateInternals(value: object, internals: ReplicatedStateInternals): void {
sources.set(value, internals);
}
export function getReplicatedStateInternals(value: unknown): ReplicatedStateInternals | undefined {
if (typeof value !== "object" || value === null) return undefined;
return sources.get(value);
}

View File

@@ -1,139 +0,0 @@
import { BACKGROUND_CONTEXT } from "../context/index.ts";
import { applyImmutable, isBase, type Op, type Tracker, track } from "../delta/index.ts";
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState, ReplicatedStateDelivery } from "../types.ts";
import { registerReplicatedStateInternals } from "./state-internals.ts";
export class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
readonly #sourceListeners = new Set<(ops: readonly Op[], sequence: number, context: Context) => void>();
readonly #tracker: Tracker<T>;
#publishedValue: T;
#sequence = 0;
constructor(initial: T) {
this.#tracker = track(initial);
this.#publishedValue = applyImmutable(undefined, this.#tracker.flush()) as unknown as T;
const thisSource = this;
registerReplicatedStateInternals(this, {
get sequence() {
return thisSource.#sequence;
},
get value() {
return thisSource.#publishedValue;
},
publish: (context) => thisSource.publish(context),
subscribe: (listener) => {
thisSource.#sourceListeners.add(listener);
return () => thisSource.#sourceListeners.delete(listener);
},
});
}
get value(): T {
return this.#publishedValue;
}
get state(): T {
return this.#tracker.state;
}
publish(context: Context): void {
const ops = this.#tracker.flush();
if (ops.length === 0) return;
this.#sequence += 1;
this.#publishedValue = applyImmutable(this.#publishedValue as unknown as JsonValue, ops) as unknown as T;
for (const listener of [...this.#sourceListeners]) listener(ops, this.#sequence, context);
const delivery = { kind: "update", sequence: this.#sequence } as const;
for (const listener of [...this.#listeners]) listener(this.#publishedValue, context, delivery);
}
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
const context = serviceDeliveryContext();
this.publish(context);
this.#listeners.add(listener);
listener(this.#publishedValue, context, { kind: "hydrate", sequence: this.#sequence });
return () => this.#listeners.delete(listener);
}
}
/** A cold read-only state used by service consumers until a complete snapshot arrives. */
export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements ReplicatedState<T> {
readonly #listeners = new Set<(value: T, context: Context, delivery: ReplicatedStateDelivery) => void>();
readonly #reportError: (error: Error) => void;
#value: T | undefined;
#sequence: number | undefined;
constructor(reportError: (error: Error) => void) {
this.#reportError = reportError;
}
get value(): T | undefined {
return this.#value;
}
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void {
this.#listeners.add(listener);
if (this.#value !== undefined) {
this.#deliver(listener, this.#value, serviceDeliveryContext(), {
kind: "hydrate",
sequence: this.#sequence!,
});
}
return () => this.#listeners.delete(listener);
}
hydrate(sequence: number, ops: readonly Op[], context: Context): void {
if (!isBase(ops)) throw new Error("Replicated state snapshot is not a base operation batch");
const value = applyImmutable<T>(undefined, ops);
this.#sequence = sequence;
this.#value = value;
this.#deliverAll(context, { kind: "hydrate", sequence });
}
update(sequence: number, ops: readonly Op[], context: Context): void {
if (this.#sequence === undefined || this.#value === undefined) {
throw new Error("Replicated state received an update before hydration");
}
if (sequence !== this.#sequence + 1) {
this.clear();
throw new Error("Replicated state update sequence has a gap");
}
const value = applyImmutable(this.#value, ops);
this.#sequence = sequence;
this.#value = value;
this.#deliverAll(context, { kind: "update", sequence });
}
clear(): void {
this.#value = undefined;
this.#sequence = undefined;
}
#deliverAll(context: Context, delivery: ReplicatedStateDelivery): void {
if (this.#value === undefined) return;
for (const listener of this.#listeners) this.#deliver(listener, this.#value, context, delivery);
}
#deliver(
listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void,
value: T,
context: Context,
delivery: ReplicatedStateDelivery,
): void {
try {
listener(value, context, delivery);
} catch (error) {
this.#reportError(toError(error));
}
}
}
/** @internal Context for synthetic service deliveries without a caller. */
export function serviceDeliveryContext(): Context {
// TODO: Add delivery-scoped cancellation or metadata if deliveries gain an owned lifecycle.
return BACKGROUND_CONTEXT;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

View File

@@ -1,234 +0,0 @@
import { assertValidOp, assertValidWireOp, type WireOp } from "../delta/index.ts";
import type {
ServiceCall,
ServiceCatalogueEntry,
ServiceInstanceAddress,
ServiceMode,
ServiceProviderUpdate,
ServiceSubscriptionSnapshot,
} from "../types.ts";
export type WireServiceMemberSnapshot =
| { readonly name: string; readonly kind: "method" }
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly ops: readonly WireOp[] };
export type WireServiceInstanceSnapshot = {
readonly instance?: ServiceInstanceAddress;
readonly members: readonly WireServiceMemberSnapshot[];
};
export type WireServiceSubscriptionSnapshot = {
readonly serviceId: string;
readonly mode: ServiceMode;
readonly instances: readonly WireServiceInstanceSnapshot[];
};
export type WireServiceProviderUpdate =
| {
readonly type: "state";
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly sequence: number;
readonly ops: readonly WireOp[];
}
| { readonly type: "unavailable" }
| { readonly type: "replaced"; readonly snapshot: WireServiceInstanceSnapshot }
| { readonly type: "spawned"; readonly instance: WireServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
const SERVICE_CONTROL_ID = "$chord.service";
const SERVICE_CATALOGUE_MEMBER = "catalogue";
const SERVICE_SUBSCRIBE_MEMBER = "subscribe";
const SERVICE_UNSUBSCRIBE_MEMBER = "unsubscribe";
export type ServiceControlCall =
| { readonly type: "catalogue" }
| {
readonly type: "subscribe";
readonly subscriptionId: string;
readonly serviceId: string;
readonly mode: ServiceMode;
}
| { readonly type: "unsubscribe"; readonly subscriptionId: string };
export function createServiceCatalogueCall(): ServiceCall {
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_CATALOGUE_MEMBER, args: [] };
}
export function createServiceSubscribeCall(subscriptionId: string, serviceId: string, mode: ServiceMode): ServiceCall {
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_SUBSCRIBE_MEMBER, args: [subscriptionId, serviceId, mode] };
}
export function createServiceUnsubscribeCall(subscriptionId: string): ServiceCall {
return { serviceId: SERVICE_CONTROL_ID, member: SERVICE_UNSUBSCRIBE_MEMBER, args: [subscriptionId] };
}
export function decodeServiceControlCall(call: ServiceCall): ServiceControlCall | undefined {
if (call.serviceId !== SERVICE_CONTROL_ID || call.instance !== undefined) return undefined;
if (call.member === SERVICE_CATALOGUE_MEMBER && call.args.length === 0) return { type: "catalogue" };
if (
call.member === SERVICE_SUBSCRIBE_MEMBER &&
call.args.length === 3 &&
isId(call.args[0]) &&
isId(call.args[1]) &&
(call.args[2] === "singleton" || call.args[2] === "keyed")
) {
return {
type: "subscribe",
subscriptionId: call.args[0],
serviceId: call.args[1],
mode: call.args[2],
};
}
if (call.member === SERVICE_UNSUBSCRIBE_MEMBER && call.args.length === 1 && isId(call.args[0])) {
return { type: "unsubscribe", subscriptionId: call.args[0] };
}
return undefined;
}
export function parseServiceCall(value: unknown): ServiceCall {
const call = record(value, "service call");
assertKeys(call, ["serviceId", "member", "args"], ["instance"], "service call");
if (!isId(call.serviceId) || !isId(call.member) || !Array.isArray(call.args)) {
throw new TypeError("Invalid service call");
}
if (call.instance !== undefined) assertAddress(call.instance);
return value as ServiceCall;
}
export function parseServiceCatalogue(value: unknown): readonly ServiceCatalogueEntry[] {
if (!Array.isArray(value)) throw new TypeError("Invalid service catalogue");
const ids = new Set<string>();
for (const candidate of value) {
const entry = record(candidate, "service catalogue entry");
assertKeys(entry, ["serviceId", "mode"], [], "service catalogue entry");
if (!isId(entry.serviceId) || !isMode(entry.mode) || ids.has(entry.serviceId)) {
throw new TypeError("Invalid service catalogue");
}
ids.add(entry.serviceId);
}
return value as unknown as readonly ServiceCatalogueEntry[];
}
export function parseServiceSubscriptionSnapshot(value: unknown): ServiceSubscriptionSnapshot {
assertSubscriptionSnapshot(value, assertValidOp);
return value as ServiceSubscriptionSnapshot;
}
export function parseWireServiceSubscriptionSnapshot(value: unknown): WireServiceSubscriptionSnapshot {
assertSubscriptionSnapshot(value, assertValidWireOp);
return value as WireServiceSubscriptionSnapshot;
}
export function parseServiceProviderUpdate(value: unknown): ServiceProviderUpdate {
assertProviderUpdate(value, assertValidOp);
return value as ServiceProviderUpdate;
}
export function parseWireServiceProviderUpdate(value: unknown): WireServiceProviderUpdate {
assertProviderUpdate(value, assertValidWireOp);
return value as WireServiceProviderUpdate;
}
function assertSubscriptionSnapshot(value: unknown, assertOp: (value: unknown) => void): void {
const snapshot = record(value, "service subscription snapshot");
assertKeys(snapshot, ["serviceId", "mode", "instances"], [], "service subscription snapshot");
if (!isId(snapshot.serviceId) || !isMode(snapshot.mode) || !Array.isArray(snapshot.instances)) {
throw new TypeError("Invalid service subscription snapshot");
}
for (const instance of snapshot.instances) assertInstance(instance, assertOp);
}
function assertProviderUpdate(value: unknown, assertOp: (value: unknown) => void): void {
const update = record(value, "service provider update");
switch (update.type) {
case "state":
assertKeys(update, ["type", "member", "sequence", "ops"], ["instance"], "state update");
if (!isId(update.member) || !isInteger(update.sequence, 1) || !Array.isArray(update.ops)) {
throw new TypeError("Invalid service state update");
}
if (update.instance !== undefined) assertAddress(update.instance);
for (const op of update.ops) assertOp(op);
return;
case "unavailable":
assertKeys(update, ["type"], [], "unavailable update");
return;
case "replaced":
assertKeys(update, ["type", "snapshot"], [], "replacement update");
assertInstance(update.snapshot, assertOp);
return;
case "spawned":
assertKeys(update, ["type", "instance"], [], "spawn update");
assertInstance(update.instance, assertOp);
return;
case "closed":
assertKeys(update, ["type", "instance"], [], "close update");
assertAddress(update.instance);
return;
default:
throw new TypeError("Invalid service provider update");
}
}
function assertInstance(value: unknown, assertOp: (value: unknown) => void): void {
const instance = record(value, "service instance snapshot");
assertKeys(instance, ["members"], ["instance"], "service instance snapshot");
if (instance.instance !== undefined) assertAddress(instance.instance);
if (!Array.isArray(instance.members)) throw new TypeError("Invalid service instance snapshot");
for (const candidate of instance.members) {
const member = record(candidate, "service member snapshot");
if (member.kind === "method") {
assertKeys(member, ["name", "kind"], [], "service method snapshot");
if (!isId(member.name)) throw new TypeError("Invalid service method snapshot");
continue;
}
if (member.kind === "state") {
assertKeys(member, ["name", "kind", "sequence", "ops"], [], "service state snapshot");
if (!isId(member.name) || !isInteger(member.sequence, 0) || !Array.isArray(member.ops)) {
throw new TypeError("Invalid service state snapshot");
}
for (const op of member.ops) assertOp(op);
continue;
}
throw new TypeError("Invalid service member snapshot");
}
}
function assertAddress(value: unknown): asserts value is ServiceInstanceAddress {
const address = record(value, "service instance address");
assertKeys(address, ["key", "generation"], [], "service instance address");
if (!isId(address.key) || !isInteger(address.generation, 1)) {
throw new TypeError("Invalid service instance address");
}
}
function record(value: unknown, description: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError(`Invalid ${description}`);
}
return value as Record<string, unknown>;
}
function assertKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[],
description: string,
): void {
const allowed = new Set([...required, ...optional]);
if (required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !allowed.has(key))) {
throw new TypeError(`Invalid ${description}`);
}
}
function isId(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isMode(value: unknown): value is ServiceMode {
return value === "singleton" || value === "keyed";
}
function isInteger(value: unknown, minimum: number): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= minimum;
}

View File

@@ -0,0 +1,423 @@
/**
* Replicated state: an immutable JSON value with ordered, serialized change notifications. Either authoritative and
* mutable (`replicatedState(initial)`) or a publication-only view of an external revision source
* (`replicatedState(source)`).
*/
import { BACKGROUND_CONTEXT, type Context } from "./context/index.ts";
import { type Draft, type Op, type Prepared, type Tracker, track } from "./delta/index.ts";
export interface ReplicatedStateDelivery {
readonly kind: "hydrate" | "update";
readonly sequence: number;
}
export interface ReplicatedState<T> {
/**
* Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an
* in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values.
*/
readonly value: T | undefined;
/**
* Each subscription serializes callbacks, awaiting hydration before updates. Values are immutable and may
* share unchanged revision data. At most 100 deliveries wait behind the running callback; overflow keeps
* only the newest pending value/context/delivery, so update sequences may skip. Failures are reported in
* isolation and delivery continues. Unsubscribe discards pending work without aborting or joining a callback.
*/
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => Promise<void>): () => void;
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;
}
export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> {
readonly value: T;
/**
* Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns.
* Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it.
*/
change(context: Context, mutate: (draft: Draft<T>) => void): void;
/**
* Atomically take immutable ownership of an alias-free strict-JSON replacement.
* The caller must not mutate the transferred root after this call.
*/
replace(context: Context, value: T): void;
}
/** One immutable authoritative revision committed after an attachment snapshot. */
export interface ReplicatedStateSourceFrame<T> {
/** Monotonic source cursor. The first frame after a snapshot must be `snapshot.cursor + 1`. */
readonly cursor: number;
/** The exact immutable value produced by this commit. */
readonly value: T;
/** The exact immutable operation batch that produced `value` from the preceding source revision. */
readonly ops: readonly Op[];
readonly context: Context;
}
export interface ReplicatedStateSourceAttachment<T> {
/** Fixed immutable snapshot captured at the atomic attachment boundary. */
readonly snapshot: { readonly value: T; readonly cursor: number };
/**
* Install the sole listener and synchronously drain every buffered frame in source commit order.
* This method is single-use. After it begins, every new committed frame must also be delivered in order
* until disposal, including commits made reentrantly while a prior frame is being delivered.
*/
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void;
/** Stop delivery and release source resources. Disposal must be idempotent. */
dispose(): void;
}
/**
* An authoritative immutable revision source.
*
* `attach()` must synchronously and atomically capture one snapshot and register the returned attachment to buffer
* every later committed frame. The snapshot must include every commit before that boundary; buffered frames must
* include every commit after it, with no overlap or gap. Snapshot values, frame values, and operation batches are
* immutable and remain valid after delivery. Chord only publishes these references; it never applies or re-diffs them.
*/
export interface ReplicatedStateSource<T> {
attach(): ReplicatedStateSourceAttachment<T>;
}
export interface ReplicatedStateSourceOptions {
/** Receives source-contract and publication-listener failures without throwing them into the source. */
readonly onError?: (error: Error) => void;
}
/** A synchronously hydrated publication-only state backed by one source attachment. */
export interface AttachedReplicatedState<T> extends ReplicatedState<T> {
readonly value: T;
/** Idempotently release the source attachment. The last published value remains readable. */
dispose(): void;
}
/** The void signature also accepts synchronous callbacks that return an ignored value. */
type StateListener<T> = (value: T, context: Context, delivery: ReplicatedStateDelivery) => void;
type StateDelivery<T> = {
readonly value: T;
readonly context: Context;
readonly delivery: ReplicatedStateDelivery;
};
/** One public subscription, independent of producer and other subscriber progress. */
class StateSubscriber<T> {
readonly #listener: StateListener<T>;
readonly #reportError: (error: Error) => void;
readonly #pending: StateDelivery<T>[] = [];
#running = false;
#started = false;
#closed = false;
constructor(listener: StateListener<T>, reportError: (error: Error) => void) {
this.#listener = listener;
this.#reportError = reportError;
}
push(frame: StateDelivery<T>): void {
if (this.#closed) return;
if (this.#pending.length === 100) {
// A cold replica can queue updates reentrantly before this subscriber's first hydration starts.
const hydration = this.#started ? undefined : this.#pending[0];
this.#pending.length = 0;
if (hydration !== undefined) this.#pending.push(hydration);
}
this.#pending.push(frame);
}
drain(): void {
if (this.#running || this.#closed) return;
this.#running = true;
for (let frame = this.#pending.shift(); frame !== undefined; frame = this.#pending.shift()) {
this.#started = true;
try {
const result: unknown = this.#listener(frame.value, frame.context, frame.delivery);
if (isPromiseLike(result)) {
void Promise.resolve(result).then(
() => this.#resume(),
(error: unknown) => {
this.#report(error);
this.#resume();
},
);
return;
}
} catch (error) {
this.#report(error);
}
}
this.#running = false;
}
clear(): void {
this.#pending.length = 0;
}
close(): void {
this.#closed = true;
this.clear();
}
#resume(): void {
this.#running = false;
this.drain();
}
#report(error: unknown): void {
try {
this.#reportError(toError(error));
} catch (reportError) {
reportErrorAsync(toError(reportError));
}
}
}
type Publication<T> = {
readonly value: T;
readonly sequence: number;
readonly context: Context;
};
/** Maintains local publication order independently of how revisions are produced. */
class ReplicatedStatePublisher<T> {
readonly #listeners = new Map<StateSubscriber<T>, number>();
readonly #reportError: (error: Error) => void;
readonly #publications: Publication<T>[] = [];
#value: T;
#sequence = 0;
#delivering = false;
constructor(initial: T, reportError: (error: Error) => void = reportErrorAsync) {
this.#value = initial;
this.#reportError = reportError;
}
get value(): T {
return this.#value;
}
snapshot(): { readonly value: T; readonly sequence: number } {
return { value: this.#value, sequence: this.#sequence };
}
subscribe(listener: StateListener<T>): () => void {
const { value, sequence } = this.snapshot();
const subscriber = new StateSubscriber(listener, this.#reportError);
this.#listeners.set(subscriber, sequence);
// Hydration has no caller; it is delivered with the background context.
subscriber.push({ value, context: BACKGROUND_CONTEXT, delivery: { kind: "hydrate", sequence } });
subscriber.drain();
return () => {
subscriber.close();
this.#listeners.delete(subscriber);
};
}
/** Publish an already-prepared immutable revision. Subscriber failures are reported, never thrown. */
publish(value: T, context: Context): void {
this.#value = value;
this.#sequence += 1;
this.#publications.push({ value, sequence: this.#sequence, context });
if (this.#delivering) return;
this.#delivering = true;
try {
for (
let publication = this.#publications.shift();
publication !== undefined;
publication = this.#publications.shift()
) {
const delivery = { kind: "update", sequence: publication.sequence } as const;
for (const [subscriber, hydratedSequence] of [...this.#listeners]) {
if (publication.sequence <= hydratedSequence) continue;
subscriber.push({ value: publication.value, context: publication.context, delivery });
subscriber.drain();
}
}
} finally {
this.#delivering = false;
}
}
}
class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
readonly #tracker: Tracker<T>;
readonly #publisher: ReplicatedStatePublisher<T>;
#changing = false;
constructor(initial: T) {
this.#tracker = track(initial);
this.#publisher = new ReplicatedStatePublisher(this.#tracker.value);
}
get value(): T {
return this.#tracker.value;
}
change(context: Context, mutate: Parameters<MutableReplicatedState<T>["change"]>[1]): void {
if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback");
this.#changing = true;
let prepared: Prepared<T>;
try {
const change = this.#tracker.beginChange();
try {
const outcome = (mutate as (draft: typeof change.state) => unknown)(change.state);
if (isPromiseLike(outcome)) {
void Promise.resolve(outcome).catch(() => undefined);
throw new TypeError("Replicated state change callbacks must be synchronous");
}
prepared = change.prepare();
} catch (error) {
change.abort();
throw error;
}
} finally {
this.#changing = false;
}
this.#tracker.adopt(prepared);
if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);
}
replace(context: Context, value: T): void {
if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback");
const prepared = this.#tracker.prepareReplace(value);
this.#tracker.adopt(prepared);
if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);
}
subscribe(listener: StateListener<T>): () => void {
return this.#publisher.subscribe(listener);
}
}
class AttachedReplicatedStateImpl<T> implements AttachedReplicatedState<T> {
readonly #publisher: ReplicatedStatePublisher<T>;
readonly #attachment: ReplicatedStateSourceAttachment<T>;
readonly #reportError: (error: Error) => void;
#cursor: number;
#disposed = false;
constructor(attachment: ReplicatedStateSourceAttachment<T>, options: ReplicatedStateSourceOptions) {
const { snapshot } = attachment;
assertCursor(snapshot.cursor, "snapshot");
this.#attachment = attachment;
this.#cursor = snapshot.cursor;
this.#reportError = options.onError ?? reportErrorAsync;
this.#publisher = new ReplicatedStatePublisher(snapshot.value, (error) => this.#report(error));
}
get value(): T {
return this.#publisher.value;
}
subscribe(listener: StateListener<T>): () => void {
return this.#publisher.subscribe(listener);
}
activate(): void {
this.#attachment.activate((frame) => this.#receive(frame));
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#attachment.dispose();
}
#receive(frame: ReplicatedStateSourceFrame<T>): void {
if (this.#disposed) return;
try {
assertCursor(frame.cursor, "frame");
const expected = this.#cursor + 1;
if (frame.cursor !== expected) {
throw new Error(`Replicated state source cursor has a gap: expected ${expected}, received ${frame.cursor}`);
}
this.#cursor = frame.cursor;
this.#publisher.publish(frame.value, frame.context);
} catch (error) {
this.#fail(toError(error));
}
}
#fail(error: Error): void {
if (this.#disposed) return;
this.#disposed = true;
try {
this.#attachment.dispose();
} catch (disposeError) {
this.#report(new AggregateError([error, disposeError], "Replicated state source contract failed"));
return;
}
this.#report(error);
}
#report(error: unknown): void {
try {
this.#reportError(toError(error));
} catch (reportError) {
reportErrorAsync(toError(reportError));
}
}
}
/** Attach a publication-only replicated state to one authoritative immutable source stream. */
function attachReplicatedStateSource<T>(
source: ReplicatedStateSource<T>,
options: ReplicatedStateSourceOptions = {},
): AttachedReplicatedState<T> {
const attachment = source.attach();
try {
const state = new AttachedReplicatedStateImpl(attachment, options);
state.activate();
return state;
} catch (error) {
try {
attachment.dispose();
} catch (disposeError) {
throw new AggregateError([error, disposeError], "Failed to attach replicated state source");
}
throw error;
}
}
function assertCursor(cursor: number, kind: "snapshot" | "frame"): void {
if (!Number.isSafeInteger(cursor))
throw new TypeError(`Replicated state source ${kind} cursor must be a safe integer`);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
((typeof value === "object" && value !== null) || typeof value === "function") &&
typeof (value as { then?: unknown }).then === "function"
);
}
function reportErrorAsync(error: Error): void {
queueMicrotask(() => {
throw error;
});
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function replicatedState<T>(
source: ReplicatedStateSource<T>,
options?: ReplicatedStateSourceOptions,
): AttachedReplicatedState<T>;
/**
* Create authoritative state by taking immutable ownership of an alias-free strict-JSON root.
* The caller must not mutate `initial` after this call.
*/
export function replicatedState<T extends object>(initial: T): MutableReplicatedState<T>;
export function replicatedState(
initialOrSource: object | ReplicatedStateSource<unknown>,
options?: ReplicatedStateSourceOptions,
): MutableReplicatedState<object> | AttachedReplicatedState<unknown> {
if (isReplicatedStateSource(initialOrSource)) return attachReplicatedStateSource(initialOrSource, options);
return new MutableReplicatedStateImpl(initialOrSource);
}
function isReplicatedStateSource(value: object): value is ReplicatedStateSource<unknown> {
return typeof (value as { readonly attach?: unknown }).attach === "function";
}

View File

@@ -1,261 +1,89 @@
import type { AnyContract, InputOf, Ref } from "./contract.ts";
import type { Op } from "./delta/index.ts";
import type { RemoteServiceProvider } from "./services/provider.ts";
export type { RemoteServiceError } from "./services/errors.ts";
export type { RemoteServiceProvider } from "./services/provider.ts";
/** Typed identity for one value carried by a {@link Context}. */
export interface ContextKey<T> {
readonly token: symbol;
/** Type-only marker that prevents keys with different value types from being interchangeable. */
readonly valueType?: (value: T) => T;
}
/** Immutable invocation-scoped values passed explicitly through operations. */
export interface Context {
readonly abortSignal: AbortSignal | undefined;
value<T>(key: ContextKey<T>): T | undefined;
toString(): string;
}
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Strict-JSON representation of an application data type. Unknown payloads become JsonValue. */
export type JsonRepresentation<T> = IsAny<T> extends true
? JsonValue
: unknown extends T
? JsonValue
: T extends null | boolean | number | string
? T
: T extends readonly (infer TItem)[]
? JsonRepresentation<TItem>[]
: T extends object
? { [TKey in keyof T]: JsonRepresentation<T[TKey]> }
: never;
export interface ReplicatedStateDelivery {
readonly kind: "hydrate" | "update";
readonly sequence: number;
}
export interface ReplicatedState<T> {
/** Immutable value, or undefined until hydration. Later updates do not mutate previously returned values. */
readonly value: T | undefined;
/** Listener values are immutable and may structurally share unchanged data with other revisions. */
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;
}
export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> {
readonly value: T;
/** Mutable tracked state. All writes must go through this proxy. */
readonly state: T;
/** Publish the changes made through {@link state} since the previous publication. */
publish(context: Context): void;
}
declare const SERVICE_TYPE: unique symbol;
export type ServiceMode = "singleton" | "keyed";
/** Stable identity for one shared TypeScript service contract. */
export interface Service<T> {
readonly id: string;
/** Process-local services accept unrestricted object contracts and are never published remotely. */
readonly local: boolean;
readonly [SERVICE_TYPE]?: (value: T) => T;
}
type InvalidJsonPart<T> = IsAny<T> extends true
? T
: unknown extends T
? never
: [T] extends [JsonValue]
? [JsonValue] extends [T]
? never
: InvalidJsonStructure<T>
: InvalidJsonStructure<T>;
type InvalidJsonProperty<T> = [Exclude<T, undefined>] extends [never] ? T : InvalidJsonPart<Exclude<T, undefined>>;
type InvalidJsonStructure<T> = T extends null | boolean | number | string
? never
: T extends readonly (infer TItem)[]
? InvalidJsonPart<TItem>
: T extends (...args: never[]) => unknown
? T
: T extends object
? { [TKey in keyof T]-?: InvalidJsonProperty<T[TKey]> }[keyof T]
: T;
type InvalidRemoteMember<T> = T extends ReplicatedState<infer TValue>
? InvalidJsonPart<TValue> extends never
? never
: "state value is not JSON"
: T extends (...args: [...infer TArgs, Context]) => Promise<infer TResult>
? InvalidJsonPart<TArgs[number]> extends never
? TResult extends void
? never
: InvalidJsonPart<TResult> extends never
? never
: "method result is not JSON or void"
: "method argument is not JSON"
: "member is not a remote method or ReplicatedState";
type InvalidRemoteMemberNames<T> = {
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
}[keyof T];
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
export interface ServiceSpawner<T> {
spawn(key: string, implementation: T): () => void;
}
export interface RemoteServices {
use<T>(service: Service<T>): T;
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
/** Wait until every currently acquired service has installed its initial snapshot. */
ready(context: Context): Promise<void>;
dispose(context: Context): Promise<void>;
}
export type ServiceCatalogueEntry = {
readonly serviceId: string;
readonly mode: ServiceMode;
};
export type ServiceInstanceAddress = {
readonly key: string;
readonly generation: number;
};
export type ServiceMemberSnapshot =
| { readonly name: string; readonly kind: "method" }
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly ops: readonly Op[] };
export type ServiceInstanceSnapshot = {
readonly instance?: ServiceInstanceAddress;
readonly members: readonly ServiceMemberSnapshot[];
};
export type ServiceSubscriptionSnapshot = {
readonly serviceId: string;
readonly mode: ServiceMode;
readonly instances: readonly ServiceInstanceSnapshot[];
};
export type ServiceProviderUpdate =
| {
readonly type: "state";
readonly instance?: ServiceInstanceAddress;
readonly member: string;
readonly sequence: number;
readonly ops: readonly Op[];
}
| { readonly type: "unavailable" }
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
export type ServiceCall = {
readonly serviceId: string;
readonly instance?: ServiceInstanceAddress;
readonly member: string;
/** Borrowed immutable values. Chord validates but does not clone them. */
readonly args: readonly JsonValue[];
};
export interface ServiceSubscription {
readonly snapshot: ServiceSubscriptionSnapshot;
activate(): void;
close(context?: Context): void | Promise<void>;
/**
* Options of `get`. `P` is the app's principal type: a JSON-serializable identity of a caller, mapped to an object key
* by `createHost({ principalKey })`.
*/
export interface GetOptions<P = unknown> {
/** Principal the object is created for. Defaults to the host's own principal (`createHost({ self })`). */
readonly principal?: P;
/**
* False: a method-only reference. It receives no state (unless another reference to the same object wants it), and
* is ready once its object is live.
*/
readonly state?: boolean;
}
/**
* Pluggable wire boundary consumed by a remote service binding.
*
* Implementations choose transport, framing, routing, and envelope encoding. Values crossing this
* boundary must remain strict JSON. Chord does not clone values or require a particular application wire protocol;
* adapters own serialization and any isolation copies they require.
*
* The explicit call context of one root (a short Chord call tree). Calls made with it carry its root id and code
* version; `get` references are released when the call that received this ctx settles.
*/
export interface RemoteServiceTransport {
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
subscribe(
serviceId: string,
mode: ServiceMode,
listener: (update: ServiceProviderUpdate, context: Context) => void,
context: Context,
): Promise<ServiceSubscription>;
export interface Ctx<P = unknown> {
readonly signal: AbortSignal;
readonly rootId: string;
readonly codeVersion: number;
/** Who the root acts as: the caller at ingress, the host's own principal inside objects. */
readonly principal: P;
/**
* A reference for this call, released when the call settles. Resolves once it is ready: live, with every state
* member of its variant (unless `state: false`). Rejects with the activation error, `aborted` when the ctx is
* aborted or the call settles first, or `unavailable/ready_timeout` after the host's `readyTimeout`; the reference
* is released then.
*/
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Promise<Ref<C>>;
}
export interface RemoteServiceBindingOptions {
readonly services: readonly { readonly id: string }[];
readonly transport: RemoteServiceTransport;
readonly bound?: boolean;
readonly onError?: (error: Error) => void;
readonly assertAccess?: () => void;
export type StateEvent<T> =
| { readonly type: "snap"; readonly value: T }
| { readonly type: "ops"; readonly ops: readonly Op[]; readonly value: T }
| { readonly type: "reset"; readonly value: T };
/** A readable state source: a cell, a derivation, or a reference's state member. Values are immutable by contract. */
export interface Readable<T> {
readonly value: T;
/** Called with the current value immediately, then with every new value. */
subscribe(listener: (value: T) => void): () => void;
/** Delta stream: a snapshot immediately, then ops, or a reset after a migration or rebind. */
events(listener: (event: StateEvent<T>) => void): () => void;
}
export interface RemoteServiceBinding extends RemoteServices {
rebind(bound: boolean, context: Context): Promise<void>;
/**
* What an implementation may expose as a state member: a cell, a derivation, or a held object's state member (an
* object goes live only once the members it exposes have their values).
*/
export type StateSource<T> = Readable<T>;
declare const EXPOSED: unique symbol;
/** A local member backed by a resource (`self.expose(res.x)`): references read the resource's value once live. */
export interface Exposed<T> {
readonly [EXPOSED]: T;
}
export interface FacetEnvironment {
/** Declare a hard dependency on one singleton service and return its stable handle. */
use<T>(service: Service<T>): T;
/** Declare a hard dependency on a keyed service and observe each live instance. */
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): void;
/** Declare and install this facet's singleton implementation of a service. */
provide<T>(service: Service<T>, implementation: NoInfer<T>): void;
/** Declare ownership of a multi-instance service and return its deferred spawning capability. */
provideMany<T>(service: Service<T>): ServiceSpawner<T>;
/** Create initialized mutable state suitable for exposing through a service implementation. */
replicatedState<T extends object>(initial: T): MutableReplicatedState<T>;
/** Give the facet ownership of a resource cleanup function. */
own(disposal: () => void | Promise<void>): void;
/** Register asynchronous initialization after dependencies are bound and ready. */
onActivate(callback: () => void | Promise<void>): void;
/** Register final facet teardown. */
onDeactivate(callback: () => void | Promise<void>): void;
export type RefStatus = "loading" | "live" | "released" | "gone";
export interface Shape {
readonly variant: string | undefined;
readonly members: readonly string[];
/** Fingerprint of the provider contract. */
readonly contract: string;
}
export interface Facet {
readonly id: string;
setup(env: FacetEnvironment): void;
}
export interface RemoteServiceSource {
/** Whether this currently unavailable source may provisionally own absent requirements. */
readonly acceptsUnavailableServices: boolean;
catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]>;
open(options: {
readonly services: readonly { readonly id: string }[];
assertAccess(): void;
onError(error: Error): void;
}): RemoteServices;
}
export interface FacetOptions {
readonly facets: readonly Facet[];
readonly serviceSources?: readonly RemoteServiceSource[];
readonly onError?: (error: Error) => void;
}
export interface FacetHost {
readonly services: RemoteServiceProvider;
/** Activate and replace facets with matching IDs without disconnecting consumer service handles. */
reload(facets: readonly Facet[]): Promise<void>;
dispose(): Promise<void>;
}
export interface LoadedFacets {
readonly facets: readonly Facet[];
dispose(): Promise<void>;
}
export interface FacetLoader {
load(): Promise<LoadedFacets>;
export interface RefBase<C extends AnyContract> {
/** The consumer contract token this reference was bound with. */
readonly contract: C;
readonly status: RefStatus;
readonly ready: Promise<void>;
readonly variant: string | undefined;
readonly shape: Shape | undefined;
/** Why the reference is gone, if it is. */
readonly error: unknown;
onShape(listener: (shape: Shape) => void): () => void;
/**
* Called after every status change (`loading` → `live`, `live` → `loading` while a link reconnects, `released`,
* `gone` with `error`). A UI watches for `gone` to `get` again.
*/
onStatus(listener: (status: RefStatus, error: unknown) => void): () => void;
release(): void;
}

View File

@@ -0,0 +1,35 @@
import type { AcceptOptions } from "./connection.ts";
import type { Host } from "./host.ts";
import { type WebSocketLike, webSocketLink } from "./link.ts";
/** A WebSocket server (e.g. `ws`'s `WebSocketServer`), structurally: Chord does not depend on `ws`. */
export interface WebSocketServerLike<Request> {
on(event: "connection", listener: (socket: WebSocketLike, request: Request) => void): unknown;
}
/** WebSocket peers are always accepted as clients: no roots, no code versions, no reload votes, not addressable. */
export interface WebSocketAcceptOptions<Request, P = unknown>
extends Omit<AcceptOptions<P>, "principal" | "kind" | "name"> {
/** The authenticator: the principal of the connecting client, or undefined to refuse it. */
principal(request: Request): P | undefined | Promise<P | undefined>;
}
/** Accepts every connection of `server` on `host` as a client link stamped with the authenticated principal. */
export function acceptWebSockets<Request, P = unknown>(
server: WebSocketServerLike<Request>,
host: Host<P>,
options: WebSocketAcceptOptions<Request, P>,
): void {
server.on("connection", (socket, request) => {
// Wrap at once so frames sent while authenticating are queued, not lost. Large payloads go out deflated; clients
// may not send packed frames (a small message could inflate to anything).
const link = webSocketLink(socket, { compress: true, acceptPacked: false });
Promise.resolve(options.principal(request)).then(
(principal) => {
if (principal === undefined) link.close("unauthorized");
else host.accept(link, { ...options, principal, kind: "client" });
},
() => link.close("authentication failed"),
);
});
}

View File

@@ -0,0 +1,181 @@
import { type Static, type TSchema, Type } from "typebox";
import { Value } from "typebox/value";
import { DescriptionSchema } from "./contract.ts";
import { CHORD_ERROR_CODES, ChordError } from "./errors.ts";
// JSON frames between two hosts (or a host and a client). Ids are chosen by the side that creates the hold, call or
// subscription; every frame type has one direction, so ids never collide:
// consumer → provider: hold, release, call, cancel, sub, unsub, resync
// provider → consumer: shape, gone, result, state
// State is addressed by (hold id, member): a hold implies the state of its variant, sent as units (a shape followed by
// a snapshot of each member the consumer lacks; see Connection).
// both: hello, ping, pong, bye, root_end, ack; parent → child: invalidate, prepare, commit, abort, retire;
// child → parent: vote
//
// Each frame is declared once, as the schema every received frame is checked against (`isFrame`); the types are derived
// from it. Payloads (hold inputs, call args, results, snapshots) stay `unknown`: they are validated where they are
// used, against the contract. State ops are checked op by op by the delta decoder.
/**
* The link protocol version, announced in `hello`. Peers of different versions do not talk: the side that notices
* closes the link with reason `protocol-version <its version>`, so the other side learns it too
* (`LinkOptions.onProtocolMismatch`).
*/
export const PROTOCOL_VERSION = 4;
const Id = Type.Integer({ minimum: 1 });
const Name = Type.String({ minLength: 1 });
const Reload = Type.Integer({ minimum: 1 });
/** Arrays a sender may pass as readonly. */
const List = <T extends TSchema>(item: T) => Type.Immutable(Type.Array(item));
const RootRefSchema = Type.Object({ rootId: Name, codeVersion: Type.Integer({ minimum: 1 }) });
export type RootRef = Static<typeof RootRefSchema>;
const WireErrorSchema = Type.Object({
code: Type.Enum(CHORD_ERROR_CODES),
message: Type.String(),
reason: Type.Optional(Type.String()),
});
export type WireError = Static<typeof WireErrorSchema>;
const WireReloadOptionsSchema = Type.Object({
remove: Type.Optional(List(Type.String())),
gateDeadline: Type.Optional(Type.Number()),
retireDeadline: Type.Optional(Type.Number()),
});
export type WireReloadOptions = Static<typeof WireReloadOptionsSchema>;
/** A child's prepared reload, sent with its vote (same fields as `ReloadReport`, without children). */
const WireReloadReportSchema = Type.Object({
codeVersion: Type.Integer(),
previous: Type.Integer(),
changed: List(Type.String()),
removed: List(Type.String()),
prepared: List(Type.String()),
gated: List(Type.String()),
});
export type WireReloadReport = Static<typeof WireReloadReportSchema>;
const FrameSchema = Type.Union([
Type.Object({
t: Type.Literal("hello"),
/** The link protocol version (`PROTOCOL_VERSION`; absent: 1). Peers of another version close the link. */
protocol: Type.Optional(Type.Integer({ minimum: 1 })),
name: Name,
kind: Type.Union([Type.Literal("host"), Type.Literal("client")]),
codeVersion: Type.Integer({ minimum: 0 }),
/** The sender acknowledges what it receives (`ack`). Flow control applies only when both hellos say so. */
acks: Type.Optional(Type.Boolean()),
}),
/** Every frame received so far except `ack` frames, counted from the link's start, once processed. */
Type.Object({ t: Type.Literal("ack"), n: Type.Integer({ minimum: 1 }) }),
Type.Object({
t: Type.Literal("hold"),
id: Id,
/**
* Target host name, resolved by the consumer from the token's `host` and `input`. Relays route on it after
* checking it against both; the target refuses an input its own contract places elsewhere.
*/
at: Name,
/** The consumer's contract token; the provider checks that it covers it. Remote tokens carry `host`. */
token: DescriptionSchema,
/** Normalized with the consumer's contract. */
input: Type.Unknown(),
/** A forwarded principal. Honored only on frames from the parent link. */
principal: Type.Optional(Type.Unknown()),
/**
* The client connection the hold comes from: an opaque id the server assigned to it, forwarded by relays. Honored
* only on frames from the parent link; present means the hold is a client's (not a reload participant).
*/
origin: Type.Optional(Name),
/** False: no state for this hold until `sub` (a method-only binding). */
state: Type.Optional(Type.Literal(false)),
/** The consumer already has values (a re-hold after a link loss): its unit is not urgent. */
again: Type.Optional(Type.Literal(true)),
}),
Type.Object({ t: Type.Literal("release"), id: Id }),
Type.Object({
t: Type.Literal("shape"),
id: Id,
variant: Type.Optional(Type.String()),
members: List(Type.String()),
contract: Type.String(),
/** The state members whose snapshots follow this frame; the consumer applies the shape once they are here. */
state: List(Name),
}),
Type.Object({ t: Type.Literal("gone"), id: Id, error: WireErrorSchema }),
Type.Object({
t: Type.Literal("call"),
id: Id,
hold: Id,
member: Name,
args: Type.Optional(Type.Unknown()),
/** Absent: the receiving host starts a root (browsers never carry roots). */
root: Type.Optional(RootRefSchema),
}),
Type.Object({
t: Type.Literal("result"),
id: Id,
value: Type.Optional(Type.Unknown()),
error: Type.Optional(WireErrorSchema),
}),
Type.Object({ t: Type.Literal("cancel"), id: Id }),
/** Starts the state of a hold opened with `state: false` (answered with a unit). */
Type.Object({ t: Type.Literal("sub"), hold: Id }),
/** Stops the state of a hold. */
Type.Object({ t: Type.Literal("unsub"), hold: Id }),
/** Sends member `m` again as a reset (a relay that had to drop what waited for it). */
Type.Object({ t: Type.Literal("resync"), hold: Id, m: Name }),
Type.Object({
t: Type.Literal("state"),
hold: Id,
m: Name,
/** `snap`/`reset` without `value`: the value is `undefined`. */
as: Type.Union([Type.Literal("snap"), Type.Literal("reset"), Type.Literal("ops"), Type.Literal("error")]),
value: Type.Optional(Type.Unknown()),
/** Encoded with the stream's delta encoder; the decoder validates each op. */
ops: Type.Optional(List(List(Type.Unknown()))),
error: Type.Optional(WireErrorSchema),
}),
Type.Object({ t: Type.Literal("ping"), n: Type.Integer() }),
Type.Object({ t: Type.Literal("pong"), n: Type.Integer() }),
/** Ends the link (consumed by the link itself). `reason: "protocol-version <n>"`: the sender speaks version n. */
Type.Object({ t: Type.Literal("bye"), reason: Type.Optional(Type.String()) }),
/** The root ended at its origin: its leases on objects end (sent on every link the root's calls used). */
Type.Object({ t: Type.Literal("root_end"), rootId: Name }),
Type.Object({ t: Type.Literal("invalidate"), pattern: Type.Record(Type.String(), Type.Unknown()) }),
Type.Object({
t: Type.Literal("prepare"),
reload: Reload,
codeVersion: Type.Integer({ minimum: 1 }),
plugins: List(Type.String()),
options: WireReloadOptionsSchema,
}),
Type.Object({
t: Type.Literal("vote"),
reload: Reload,
error: Type.Optional(WireErrorSchema),
report: Type.Optional(WireReloadReportSchema),
}),
Type.Object({ t: Type.Literal("commit"), reload: Reload }),
/** The parent dropped `codeVersion`: roots of it may still come from there no longer. */
Type.Object({ t: Type.Literal("retire"), codeVersion: Type.Integer({ minimum: 1 }) }),
Type.Object({ t: Type.Literal("abort"), reload: Reload }),
]);
export type Frame = Static<typeof FrameSchema>;
/** A frame that passes validation; anything else is a protocol violation. */
export const isFrame = (value: unknown): value is Frame => Value.Check(FrameSchema, value);
export function toWireError(error: unknown): WireError {
if (error instanceof ChordError) {
return error.reason === undefined
? { code: error.code, message: error.message }
: { code: error.code, message: error.message, reason: error.reason };
}
return { code: "unavailable", message: error instanceof Error ? error.message : String(error) };
}
export const fromWireError = (error: WireError): ChordError =>
new ChordError(error.code, error.message.replace(/^[a-z_]+(\/[a-z_]+)?: /, ""), { reason: error.reason });