chore(vendor): import pi 0.85.1
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: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
90
third_party/pi/packages/chord/src/api.ts
vendored
Normal file
90
third_party/pi/packages/chord/src/api.ts
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
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
Normal file
9
third_party/pi/packages/chord/src/bundler.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
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";
|
||||
121
third_party/pi/packages/chord/src/context/index.ts
vendored
Normal file
121
third_party/pi/packages/chord/src/context/index.ts
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Context, ContextKey } from "../types.ts";
|
||||
|
||||
const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
|
||||
token: Symbol("chord.abortSignal"),
|
||||
});
|
||||
|
||||
abstract class BaseContext implements Context {
|
||||
abstract value<T>(key: ContextKey<T>): T | undefined;
|
||||
abstract toString(): string;
|
||||
|
||||
get abortSignal(): AbortSignal | undefined {
|
||||
return this.value(ABORT_SIGNAL_CONTEXT_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyContext extends BaseContext {
|
||||
readonly #name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
super();
|
||||
this.#name = name;
|
||||
}
|
||||
|
||||
value<T>(_key: ContextKey<T>): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.#name;
|
||||
}
|
||||
}
|
||||
|
||||
class ContextValue<T> extends BaseContext {
|
||||
readonly #parent: Context;
|
||||
readonly #key: ContextKey<T>;
|
||||
readonly #value: T;
|
||||
|
||||
constructor(parent: Context, key: ContextKey<T>, value: T) {
|
||||
super();
|
||||
this.#parent = parent;
|
||||
this.#key = key;
|
||||
this.#value = value;
|
||||
}
|
||||
|
||||
value<Value>(key: ContextKey<Value>): Value | undefined {
|
||||
if (key.token === this.#key.token) return this.#value as unknown as Value;
|
||||
return this.#parent.value(key);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`;
|
||||
}
|
||||
}
|
||||
|
||||
export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");
|
||||
export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");
|
||||
|
||||
export function createContextKey<T>(description: string): ContextKey<T> {
|
||||
return Object.freeze({ token: Symbol(description) });
|
||||
}
|
||||
|
||||
/** Derive a context containing one additional or replaced value. */
|
||||
export function withContextValue<T>(key: ContextKey<T>, value: T, parent: Context): Context {
|
||||
return new ContextValue(parent, key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a context cancelled by either the parent signal or the supplied signal.
|
||||
* The parent context remains unchanged.
|
||||
*/
|
||||
export function withAbortSignal(signal: AbortSignal, context: Context): Context {
|
||||
const parentSignal = context.abortSignal;
|
||||
const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]);
|
||||
return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context);
|
||||
}
|
||||
|
||||
/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */
|
||||
export function withoutAbortSignal(context: Context): Context {
|
||||
return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);
|
||||
}
|
||||
|
||||
/** Derive an independently cancellable child context. */
|
||||
export function withCancel(context: Context): {
|
||||
readonly context: Context;
|
||||
readonly cancel: (reason?: unknown) => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
context: withAbortSignal(controller.signal, context),
|
||||
cancel: (reason?: unknown) => controller.abort(reason),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a promise until it settles or the invocation is cancelled.
|
||||
* Cancellation rejects only this waiter; it does not cancel the underlying promise.
|
||||
*/
|
||||
export function awaitWithContext<T>(promise: Promise<T>, context: Context): Promise<T> {
|
||||
const signal = context.abortSignal;
|
||||
if (signal === undefined) return promise;
|
||||
if (signal.aborted) return Promise.reject(abortError(signal));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => reject(abortError(signal));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
void promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const reason: unknown = signal.reason;
|
||||
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
319
third_party/pi/packages/chord/src/delta/README.md
vendored
Normal file
319
third_party/pi/packages/chord/src/delta/README.md
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
# Chord Delta
|
||||
|
||||
Chord Delta synchronizes JSON values from an authoritative producer to an
|
||||
ordered replica. It is available 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()`.
|
||||
|
||||
```ts
|
||||
import { apply, track } from "@earendil-works/chord/delta";
|
||||
|
||||
const tracker = track({ output: "", entries: [] as string[] });
|
||||
let replica = apply(undefined, tracker.flush());
|
||||
|
||||
tracker.state.output += "done\n";
|
||||
tracker.state.entries.push("result");
|
||||
replica = apply(replica, tracker.flush());
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
## Sending or storing changes
|
||||
|
||||
`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()`:
|
||||
|
||||
```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);
|
||||
};
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
["operation", "message", "content", 0, "text"]
|
||||
```
|
||||
|
||||
### Decoded `Op`
|
||||
|
||||
`track().flush()` returns these tuples, and `apply()` accepts them:
|
||||
|
||||
| Tuple | Meaning |
|
||||
| --- | --- |
|
||||
| `["r", value]` | Replace the complete value. |
|
||||
| `["s", path, value]` | Set a property or array element. |
|
||||
| `["d", path]` | Delete an object property. |
|
||||
| `["a", path, text]` | Append to a string. |
|
||||
| `["t", path, count]` | Remove UTF-16 code units from a string's front. |
|
||||
| `["p", path, index, remove, items]` | Splice an array. |
|
||||
|
||||
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.
|
||||
|
||||
### Encoded `WireOp`
|
||||
|
||||
A `PathRef` is either an inline path or a non-negative numeric path ID.
|
||||
`WireOp` supports the following tuples:
|
||||
|
||||
| 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. |
|
||||
|
||||
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.
|
||||
1267
third_party/pi/packages/chord/src/delta/index.ts
vendored
Normal file
1267
third_party/pi/packages/chord/src/delta/index.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
906
third_party/pi/packages/chord/src/facets/host.ts
vendored
Normal file
906
third_party/pi/packages/chord/src/facets/host.ts
vendored
Normal file
@@ -0,0 +1,906 @@
|
||||
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";
|
||||
}
|
||||
6
third_party/pi/packages/chord/src/facets/loader.ts
vendored
Normal file
6
third_party/pi/packages/chord/src/facets/loader.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
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] : []));
|
||||
}
|
||||
79
third_party/pi/packages/chord/src/index.ts
vendored
Normal file
79
third_party/pi/packages/chord/src/index.ts
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Chord is a standalone application-composition runtime for agentic applications.
|
||||
*/
|
||||
export {
|
||||
combineFacetLoaders,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
defineService,
|
||||
replicatedState,
|
||||
} from "./api.ts";
|
||||
export { isJsonValue } 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";
|
||||
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";
|
||||
50
third_party/pi/packages/chord/src/json.ts
vendored
Normal file
50
third_party/pi/packages/chord/src/json.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { JsonValue } from "./types.ts";
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
function check(value: unknown, ancestors: Set<object>, depth: number): boolean {
|
||||
if (depth > 512) return false;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) {
|
||||
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;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (
|
||||
descriptor === undefined ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor) ||
|
||||
!check(descriptor.value, ancestors, depth + 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) return false;
|
||||
if (Reflect.ownKeys(value).some((key) => typeof key !== "string")) return false;
|
||||
if (ancestors.has(value)) return false;
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
24
third_party/pi/packages/chord/src/node.ts
vendored
Normal file
24
third_party/pi/packages/chord/src/node.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
export type {
|
||||
FacetBundleArtifactLoaderOptions,
|
||||
FacetBundleExternalResolver,
|
||||
FacetBundleLoaderOptions,
|
||||
} from "./node/bundle-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";
|
||||
416
third_party/pi/packages/chord/src/node/bundle-loader.ts
vendored
Normal file
416
third_party/pi/packages/chord/src/node/bundle-loader.ts
vendored
Normal file
@@ -0,0 +1,416 @@
|
||||
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
Normal file
222
third_party/pi/packages/chord/src/node/bundle.ts
vendored
Normal file
@@ -0,0 +1,222 @@
|
||||
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);
|
||||
}
|
||||
39
third_party/pi/packages/chord/src/node/manifest.ts
vendored
Normal file
39
third_party/pi/packages/chord/src/node/manifest.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
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
Normal file
232
third_party/pi/packages/chord/src/node/package.ts
vendored
Normal file
@@ -0,0 +1,232 @@
|
||||
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);
|
||||
}
|
||||
660
third_party/pi/packages/chord/src/services/consumer.ts
vendored
Normal file
660
third_party/pi/packages/chord/src/services/consumer.ts
vendored
Normal file
@@ -0,0 +1,660 @@
|
||||
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));
|
||||
}
|
||||
26
third_party/pi/packages/chord/src/services/errors.ts
vendored
Normal file
26
third_party/pi/packages/chord/src/services/errors.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
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
Normal file
113
third_party/pi/packages/chord/src/services/handle.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
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";
|
||||
}
|
||||
147
third_party/pi/packages/chord/src/services/instances.ts
vendored
Normal file
147
third_party/pi/packages/chord/src/services/instances.ts
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
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));
|
||||
}
|
||||
17
third_party/pi/packages/chord/src/services/loopback.ts
vendored
Normal file
17
third_party/pi/packages/chord/src/services/loopback.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
586
third_party/pi/packages/chord/src/services/provider.ts
vendored
Normal file
586
third_party/pi/packages/chord/src/services/provider.ts
vendored
Normal file
@@ -0,0 +1,586 @@
|
||||
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);
|
||||
}
|
||||
158
third_party/pi/packages/chord/src/services/state-codec.ts
vendored
Normal file
158
third_party/pi/packages/chord/src/services/state-codec.ts
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
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}`;
|
||||
}
|
||||
20
third_party/pi/packages/chord/src/services/state-internals.ts
vendored
Normal file
20
third_party/pi/packages/chord/src/services/state-internals.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
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
Normal file
139
third_party/pi/packages/chord/src/services/state.ts
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
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
Normal file
234
third_party/pi/packages/chord/src/services/wire.ts
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
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;
|
||||
}
|
||||
261
third_party/pi/packages/chord/src/types.ts
vendored
Normal file
261
third_party/pi/packages/chord/src/types.ts
vendored
Normal file
@@ -0,0 +1,261 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
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 RemoteServiceBindingOptions {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
readonly transport: RemoteServiceTransport;
|
||||
readonly bound?: boolean;
|
||||
readonly onError?: (error: Error) => void;
|
||||
readonly assertAccess?: () => void;
|
||||
}
|
||||
|
||||
export interface RemoteServiceBinding extends RemoteServices {
|
||||
rebind(bound: boolean, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
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 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user