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:
90
third_party/pi/packages/chord/src/api.ts
vendored
90
third_party/pi/packages/chord/src/api.ts
vendored
@@ -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);
|
||||
}
|
||||
9
third_party/pi/packages/chord/src/bundler.ts
vendored
9
third_party/pi/packages/chord/src/bundler.ts
vendored
@@ -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";
|
||||
402
third_party/pi/packages/chord/src/cell.ts
vendored
Normal file
402
third_party/pi/packages/chord/src/cell.ts
vendored
Normal 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`);
|
||||
251
third_party/pi/packages/chord/src/cluster.ts
vendored
Normal file
251
third_party/pi/packages/chord/src/cluster.ts
vendored
Normal 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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
1327
third_party/pi/packages/chord/src/connection.ts
vendored
Normal file
1327
third_party/pi/packages/chord/src/connection.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
|
||||
440
third_party/pi/packages/chord/src/contract.ts
vendored
Normal file
440
third_party/pi/packages/chord/src/contract.ts
vendored
Normal 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;
|
||||
38
third_party/pi/packages/chord/src/declare.ts
vendored
Normal file
38
third_party/pi/packages/chord/src/declare.ts
vendored
Normal 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;
|
||||
448
third_party/pi/packages/chord/src/delta/README.md
vendored
448
third_party/pi/packages/chord/src/delta/README.md
vendored
@@ -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.
|
||||
|
||||
128
third_party/pi/packages/chord/src/delta/apply-immutable-trusted.ts
vendored
Normal file
128
third_party/pi/packages/chord/src/delta/apply-immutable-trusted.ts
vendored
Normal 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";
|
||||
}
|
||||
523
third_party/pi/packages/chord/src/delta/diff.ts
vendored
Normal file
523
third_party/pi/packages/chord/src/delta/diff.ts
vendored
Normal 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;
|
||||
}
|
||||
10
third_party/pi/packages/chord/src/delta/draft.ts
vendored
Normal file
10
third_party/pi/packages/chord/src/delta/draft.ts
vendored
Normal 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;
|
||||
803
third_party/pi/packages/chord/src/delta/index.ts
vendored
803
third_party/pi/packages/chord/src/delta/index.ts
vendored
@@ -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;
|
||||
|
||||
75
third_party/pi/packages/chord/src/delta/revision-validator.ts
vendored
Normal file
75
third_party/pi/packages/chord/src/delta/revision-validator.ts
vendored
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
2239
third_party/pi/packages/chord/src/delta/tracker.ts
vendored
Normal file
2239
third_party/pi/packages/chord/src/delta/tracker.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
76
third_party/pi/packages/chord/src/errors.ts
vendored
Normal file
76
third_party/pi/packages/chord/src/errors.ts
vendored
Normal 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);
|
||||
}
|
||||
906
third_party/pi/packages/chord/src/facets/host.ts
vendored
906
third_party/pi/packages/chord/src/facets/host.ts
vendored
@@ -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";
|
||||
}
|
||||
@@ -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
1303
third_party/pi/packages/chord/src/host.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
153
third_party/pi/packages/chord/src/index.ts
vendored
153
third_party/pi/packages/chord/src/index.ts
vendored
@@ -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";
|
||||
|
||||
97
third_party/pi/packages/chord/src/json.ts
vendored
97
third_party/pi/packages/chord/src/json.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
415
third_party/pi/packages/chord/src/link.ts
vendored
Normal file
415
third_party/pi/packages/chord/src/link.ts
vendored
Normal 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);
|
||||
67
third_party/pi/packages/chord/src/loader.ts
vendored
Normal file
67
third_party/pi/packages/chord/src/loader.ts
vendored
Normal 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;
|
||||
};
|
||||
443
third_party/pi/packages/chord/src/manager.ts
vendored
Normal file
443
third_party/pi/packages/chord/src/manager.ts
vendored
Normal 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),
|
||||
};
|
||||
}
|
||||
38
third_party/pi/packages/chord/src/node.ts
vendored
38
third_party/pi/packages/chord/src/node.ts
vendored
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
222
third_party/pi/packages/chord/src/node/bundle.ts
vendored
222
third_party/pi/packages/chord/src/node/bundle.ts
vendored
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
232
third_party/pi/packages/chord/src/node/package.ts
vendored
232
third_party/pi/packages/chord/src/node/package.ts
vendored
@@ -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);
|
||||
}
|
||||
1932
third_party/pi/packages/chord/src/object.ts
vendored
Normal file
1932
third_party/pi/packages/chord/src/object.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
383
third_party/pi/packages/chord/src/output.ts
vendored
Normal file
383
third_party/pi/packages/chord/src/output.ts
vendored
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
39
third_party/pi/packages/chord/src/publish.ts
vendored
Normal file
39
third_party/pi/packages/chord/src/publish.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
30
third_party/pi/packages/chord/src/registry.ts
vendored
Normal file
30
third_party/pi/packages/chord/src/registry.ts
vendored
Normal 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;
|
||||
}
|
||||
723
third_party/pi/packages/chord/src/reload.ts
vendored
Normal file
723
third_party/pi/packages/chord/src/reload.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
658
third_party/pi/packages/chord/src/remote.ts
vendored
Normal file
658
third_party/pi/packages/chord/src/remote.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
219
third_party/pi/packages/chord/src/schema.ts
vendored
Normal file
219
third_party/pi/packages/chord/src/schema.ts
vendored
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
139
third_party/pi/packages/chord/src/services/state.ts
vendored
139
third_party/pi/packages/chord/src/services/state.ts
vendored
@@ -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));
|
||||
}
|
||||
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
@@ -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;
|
||||
}
|
||||
423
third_party/pi/packages/chord/src/state.ts
vendored
Normal file
423
third_party/pi/packages/chord/src/state.ts
vendored
Normal 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";
|
||||
}
|
||||
320
third_party/pi/packages/chord/src/types.ts
vendored
320
third_party/pi/packages/chord/src/types.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
35
third_party/pi/packages/chord/src/websocket.ts
vendored
Normal file
35
third_party/pi/packages/chord/src/websocket.ts
vendored
Normal 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"),
|
||||
);
|
||||
});
|
||||
}
|
||||
181
third_party/pi/packages/chord/src/wire.ts
vendored
Normal file
181
third_party/pi/packages/chord/src/wire.ts
vendored
Normal 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 });
|
||||
Reference in New Issue
Block a user