mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat: consolidate Chord public API
This commit is contained in:
parent
4151e5754c
commit
6ec57e6e5f
25 changed files with 614 additions and 472 deletions
|
|
@ -1,18 +1,19 @@
|
|||
import { type Context, createContextKey, withContextValue } from "@earendil-works/chord/context";
|
||||
import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
|
||||
export {
|
||||
awaitWithContext,
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type Context,
|
||||
type ContextKey,
|
||||
createContextKey,
|
||||
INTERNAL_CONTEXT_OPERATIONS,
|
||||
TODO_CONTEXT,
|
||||
withAbortSignal,
|
||||
withCancel,
|
||||
withContextValue,
|
||||
withoutAbortSignal,
|
||||
} from "@earendil-works/chord/context";
|
||||
import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
|
||||
export { BACKGROUND_CONTEXT, type Context, type ContextKey, TODO_CONTEXT };
|
||||
export const awaitWithContext = INTERNAL_CONTEXT_OPERATIONS.awaitWithContext;
|
||||
export const createContextKey = INTERNAL_CONTEXT_OPERATIONS.createContextKey;
|
||||
export const withAbortSignal = INTERNAL_CONTEXT_OPERATIONS.withAbortSignal;
|
||||
export const withCancel = INTERNAL_CONTEXT_OPERATIONS.withCancel;
|
||||
export const withContextValue = INTERNAL_CONTEXT_OPERATIONS.withContextValue;
|
||||
export const withoutAbortSignal = INTERNAL_CONTEXT_OPERATIONS.withoutAbortSignal;
|
||||
|
||||
const TELEMETRY_CONTEXT_KEY = createContextKey<TelemetryContext>("pi.telemetryContext");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JsonValue } from "@earendil-works/chord/json";
|
||||
import type { JsonValue } from "@earendil-works/chord/types";
|
||||
import type { AssistantMessage, StopReason, Usage } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage, QueueMode, ThinkingLevel } from "../../types.ts";
|
||||
import type { BranchPreparation } from "../compaction/branch-summarization.ts";
|
||||
|
|
@ -7,7 +7,7 @@ import type { Context } from "../context.ts";
|
|||
import type { AgentHarnessStreamOptions } from "../types.ts";
|
||||
import type { ListElement, ListReadOptions, ListWrite, StoredValue, Value, ValueList, ValueWrite } from "./values.ts";
|
||||
|
||||
export type { JsonValue } from "@earendil-works/chord/json";
|
||||
export type { JsonValue } from "@earendil-works/chord/types";
|
||||
|
||||
export type SettledAssistantMessage = AssistantMessage & {
|
||||
stopReason: Exclude<StopReason, "pending">;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JsonValue } from "@earendil-works/chord/json";
|
||||
import type { JsonValue } from "@earendil-works/chord/types";
|
||||
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
|
||||
import type { AnthropicOptions } from "./api/anthropic-messages.ts";
|
||||
import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";
|
||||
|
|
@ -405,7 +405,7 @@ export interface Usage {
|
|||
|
||||
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
|
||||
|
||||
export type { JsonValue } from "@earendil-works/chord/json";
|
||||
export type { JsonValue } from "@earendil-works/chord/types";
|
||||
|
||||
export interface DeferredHandle {
|
||||
provider: string;
|
||||
|
|
|
|||
|
|
@ -583,12 +583,15 @@ This is a planning aid, not a requirement to create all files immediately.
|
|||
packages/chord/
|
||||
src/
|
||||
index.ts platform-neutral public API
|
||||
context.ts neutral invocation context and cancellation
|
||||
json.ts strict JSON types and validation
|
||||
api.ts public functions
|
||||
types.ts public types, including strict JSON values
|
||||
context/
|
||||
index.ts invocation context internals and compatibility entry
|
||||
errors.ts lifecycle, service, and RPC errors
|
||||
state.ts source and replica state primitives
|
||||
services/
|
||||
types.ts tokens, contracts, modes, snapshots
|
||||
state.ts source and replica state primitives
|
||||
state-internals.ts private replicated-state metadata
|
||||
provider.ts singleton/keyed provider runtime
|
||||
facade.ts stable local and remote facades
|
||||
host-bindings.ts graph-facing service slots
|
||||
|
|
|
|||
|
|
@ -48,8 +48,11 @@ The design has a few connected pieces:
|
|||
|
||||
The current runtime exports service tokens, singleton and keyed providers,
|
||||
remote bindings, replicated state, facet hosts, and facet loaders from
|
||||
`@earendil-works/chord`. Chord-owned identifiers use the `chord.*` namespace and
|
||||
its reserved service prefix is `$chord.*`.
|
||||
`@earendil-works/chord`. All public functions are collected in
|
||||
`@earendil-works/chord/api`, while all public types are collected in
|
||||
`@earendil-works/chord/types`; both are re-exported from the main entry point.
|
||||
Chord-owned identifiers use the `chord.*` namespace and its reserved service
|
||||
prefix is `$chord.*`.
|
||||
|
||||
See [PLANNING.md](PLANNING.md) for the broader RPC, generation-loading, and
|
||||
bundling architecture.
|
||||
|
|
|
|||
|
|
@ -11,15 +11,20 @@
|
|||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./context": {
|
||||
"source": "./src/context.ts",
|
||||
"types": "./dist/context.d.ts",
|
||||
"import": "./dist/context.js"
|
||||
"./api": {
|
||||
"source": "./src/api.ts",
|
||||
"types": "./dist/api.d.ts",
|
||||
"import": "./dist/api.js"
|
||||
},
|
||||
"./json": {
|
||||
"source": "./src/json.ts",
|
||||
"types": "./dist/json.d.ts",
|
||||
"import": "./dist/json.js"
|
||||
"./context": {
|
||||
"source": "./src/context/index.ts",
|
||||
"types": "./dist/context/index.d.ts",
|
||||
"import": "./dist/context/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"source": "./src/types.ts",
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
|
|
|||
172
packages/chord/src/api.ts
Normal file
172
packages/chord/src/api.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import {
|
||||
deriveCancellableContext,
|
||||
deriveContextValue,
|
||||
deriveContextWithAbortSignal,
|
||||
deriveContextWithoutAbortSignal,
|
||||
waitWithContext,
|
||||
} from "./context/index.ts";
|
||||
import { FacetKernel } from "./facets/host.ts";
|
||||
import { disposeLoadedFacets } from "./facets/loader.ts";
|
||||
import { RemoteServiceBindingImpl } from "./services/consumer.ts";
|
||||
import { lookupServiceInstanceKey } from "./services/instances.ts";
|
||||
import type { RemoteServiceProvider } from "./services/provider.ts";
|
||||
import { createDeliveryContext, MutableReplicatedStateImpl } from "./services/state.ts";
|
||||
import type {
|
||||
Context,
|
||||
ContextKey,
|
||||
Facet,
|
||||
FacetHost,
|
||||
FacetLoader,
|
||||
FacetOptions,
|
||||
LoadedFacets,
|
||||
MutableReplicatedState,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceConnection,
|
||||
RemoteServiceContract,
|
||||
Service,
|
||||
} from "./types.ts";
|
||||
|
||||
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 deriveContextValue(key, value, parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return deriveContextWithAbortSignal(signal, context);
|
||||
}
|
||||
|
||||
/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */
|
||||
export function withoutAbortSignal(context: Context): Context {
|
||||
return deriveContextWithoutAbortSignal(context);
|
||||
}
|
||||
|
||||
/** Derive an independently cancellable child context. */
|
||||
export function withCancel(context: Context): {
|
||||
readonly context: Context;
|
||||
readonly cancel: (reason?: unknown) => void;
|
||||
} {
|
||||
return deriveCancellableContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return waitWithContext(promise, context);
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
}
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level remote binding factory behind facet connections. */
|
||||
export function createRemoteServiceBinding(options: RemoteServiceBindingOptions): RemoteServiceBinding {
|
||||
return new RemoteServiceBindingImpl(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the application key for a keyed service proxy, or undefined for a singleton.
|
||||
*
|
||||
* @publicApiReview Consider passing the key directly to keyed observers instead of exposing proxy metadata.
|
||||
*/
|
||||
export function getServiceInstanceKey(service: object): string | undefined {
|
||||
return lookupServiceInstanceKey(service);
|
||||
}
|
||||
|
||||
/** @publicApiReview Consider keeping this loopback transport adapter in testing support. */
|
||||
export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection {
|
||||
return {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, (update) =>
|
||||
listener(update, createDeliveryContext()),
|
||||
);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function replicatedState<T>(initial: T): MutableReplicatedState<T> {
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
}
|
||||
|
||||
/** @publicApiReview Delivery contexts may only be an internal service-replication concern. */
|
||||
export function freshDeliveryContext(): Context {
|
||||
return createDeliveryContext();
|
||||
}
|
||||
|
|
@ -1,21 +1,11 @@
|
|||
/** 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;
|
||||
}
|
||||
import type { Context, ContextKey } from "../types.ts";
|
||||
|
||||
const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
|
||||
export type { Context, ContextKey } from "../types.ts";
|
||||
|
||||
export const ABORT_SIGNAL_CONTEXT_KEY: ContextKey<AbortSignal | undefined> = Object.freeze({
|
||||
token: Symbol("chord.abortSignal"),
|
||||
});
|
||||
|
||||
/** Immutable invocation-scoped values passed explicitly through operations. */
|
||||
export interface Context {
|
||||
readonly abortSignal: AbortSignal | undefined;
|
||||
value<T>(key: ContextKey<T>): T | undefined;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
abstract class BaseContext implements Context {
|
||||
abstract value<T>(key: ContextKey<T>): T | undefined;
|
||||
abstract toString(): string;
|
||||
|
|
@ -42,7 +32,6 @@ class EmptyContext extends BaseContext {
|
|||
}
|
||||
}
|
||||
|
||||
/** One copy-on-write value layered over a parent context. */
|
||||
class ContextValue<T> extends BaseContext {
|
||||
readonly #parent: Context;
|
||||
readonly #key: ContextKey<T>;
|
||||
|
|
@ -65,53 +54,39 @@ class ContextValue<T> extends BaseContext {
|
|||
}
|
||||
}
|
||||
|
||||
/** Root context for work that has no caller. */
|
||||
export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]");
|
||||
|
||||
/** Temporary root context marking a call site that still needs context propagation. */
|
||||
export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]");
|
||||
|
||||
export function createContextKey<T>(description: string): ContextKey<T> {
|
||||
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 {
|
||||
export function deriveContextValue<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 {
|
||||
export function deriveContextWithAbortSignal(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);
|
||||
return deriveContextValue(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);
|
||||
export function deriveContextWithoutAbortSignal(context: Context): Context {
|
||||
return deriveContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context);
|
||||
}
|
||||
|
||||
/** Derive an independently cancellable child context. */
|
||||
export function withCancel(context: Context): {
|
||||
export function deriveCancellableContext(context: Context): {
|
||||
readonly context: Context;
|
||||
readonly cancel: (reason?: unknown) => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
context: withAbortSignal(controller.signal, context),
|
||||
context: deriveContextWithAbortSignal(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> {
|
||||
export function waitWithContext<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));
|
||||
|
|
@ -135,3 +110,13 @@ function abortError(signal: AbortSignal): Error {
|
|||
const reason: unknown = signal.reason;
|
||||
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
|
||||
/** @internal Narrow compatibility surface for the legacy context entry point. */
|
||||
export const INTERNAL_CONTEXT_OPERATIONS = Object.freeze({
|
||||
awaitWithContext: waitWithContext,
|
||||
createContextKey,
|
||||
withAbortSignal: deriveContextWithAbortSignal,
|
||||
withCancel: deriveCancellableContext,
|
||||
withContextValue: deriveContextValue,
|
||||
withoutAbortSignal: deriveContextWithoutAbortSignal,
|
||||
});
|
||||
|
|
@ -1,10 +1,20 @@
|
|||
import { BACKGROUND_CONTEXT, type Context } from "../context.ts";
|
||||
import type { RemoteServiceContract, RemoteServices, Service, ServiceMode } from "../services/contracts.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { ServiceHandle } from "../services/handle.ts";
|
||||
import { InstanceDirectory, type InstanceDirectoryEntry } from "../services/instances.ts";
|
||||
import { RemoteServiceProvider, validateRemoteServiceImplementation } from "../services/provider.ts";
|
||||
import { replicatedState } from "../state.ts";
|
||||
import type { Facet, FacetConnection, FacetEnvironment, FacetHost, FacetOptions, ServiceSpawner } from "./types.ts";
|
||||
import { MutableReplicatedStateImpl } from "../services/state.ts";
|
||||
import type {
|
||||
Context,
|
||||
Facet,
|
||||
FacetConnection,
|
||||
FacetEnvironment,
|
||||
FacetOptions,
|
||||
RemoteServiceContract,
|
||||
RemoteServices,
|
||||
Service,
|
||||
ServiceMode,
|
||||
ServiceSpawner,
|
||||
} from "../types.ts";
|
||||
|
||||
interface FacetServiceReference {
|
||||
readonly serviceId: string;
|
||||
|
|
@ -315,19 +325,8 @@ type FacetProvision =
|
|||
connectRemote(provider: RemoteServiceProvider): void;
|
||||
};
|
||||
|
||||
/** 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(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Private lifecycle and dependency kernel behind the atomic host entry point. */
|
||||
class FacetKernel {
|
||||
export class FacetKernel {
|
||||
readonly #initialFacets: readonly Facet[];
|
||||
readonly #connections: readonly FacetConnection[];
|
||||
readonly #onError: (error: Error) => void;
|
||||
|
|
@ -588,7 +587,7 @@ class FacetKernel {
|
|||
},
|
||||
replicatedState: <T>(initial: T) => {
|
||||
lifecycle.assertRunning("create replicated state");
|
||||
return replicatedState(initial);
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
},
|
||||
own: (disposal) => lifecycle.own(disposal),
|
||||
onActivate: (callback) => lifecycle.onActivate(callback),
|
||||
|
|
|
|||
|
|
@ -1,52 +1,6 @@
|
|||
import type { Facet } from "./types.ts";
|
||||
import type { LoadedFacets } from "../types.ts";
|
||||
|
||||
export interface LoadedFacets {
|
||||
readonly facets: readonly Facet[];
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FacetLoader {
|
||||
load(): Promise<LoadedFacets>;
|
||||
}
|
||||
|
||||
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");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function disposeLoadedFacets(loaded: readonly LoadedFacets[]): Promise<unknown[]> {
|
||||
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] : []));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import type { Context } from "../context.ts";
|
||||
import type { RemoteServices, Service } from "../services/contracts.ts";
|
||||
import type { ServiceCatalogueEntry } from "../services/protocol.ts";
|
||||
import type { RemoteServiceProvider } from "../services/provider.ts";
|
||||
import type { MutableReplicatedState } from "../state.ts";
|
||||
|
||||
export interface ServiceSpawner<T> {
|
||||
spawn(key: string, implementation: T): () => 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>(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 function defineFacet(facet: Facet): Facet {
|
||||
return facet;
|
||||
}
|
||||
|
||||
export interface FacetConnection {
|
||||
/** Whether this currently unavailable route 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 connections?: readonly FacetConnection[];
|
||||
readonly onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface FacetHost {
|
||||
readonly services: RemoteServiceProvider;
|
||||
/** Replace active facets with matching IDs while preserving consumer service handles. */
|
||||
reload(facets: readonly Facet[]): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
|
@ -3,42 +3,55 @@
|
|||
*
|
||||
* Its public API is under development.
|
||||
*/
|
||||
export * from "./context.ts";
|
||||
export { createFacetHost } from "./facets/host.ts";
|
||||
export {
|
||||
awaitWithContext,
|
||||
combineFacetLoaders,
|
||||
createStaticFacetLoader,
|
||||
type FacetLoader,
|
||||
type LoadedFacets,
|
||||
} from "./facets/loader.ts";
|
||||
export {
|
||||
defineFacet,
|
||||
type Facet,
|
||||
type FacetConnection,
|
||||
type FacetEnvironment,
|
||||
type FacetHost,
|
||||
type FacetOptions,
|
||||
type ServiceSpawner,
|
||||
} from "./facets/types.ts";
|
||||
export type { JsonValue } from "./json.ts";
|
||||
export {
|
||||
createContextKey,
|
||||
createFacetHost,
|
||||
createLoopbackServiceConnection,
|
||||
createRemoteServiceBinding,
|
||||
type RemoteServiceBinding,
|
||||
type RemoteServiceBindingOptions,
|
||||
} from "./services/consumer.ts";
|
||||
export { defineService, type RemoteServices, type Service, type ServiceMode } from "./services/contracts.ts";
|
||||
export { RemoteServiceError, type RemoteServiceErrorCode } from "./services/errors.ts";
|
||||
export { getServiceInstanceKey } from "./services/metadata.ts";
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
defineService,
|
||||
freshDeliveryContext,
|
||||
getServiceInstanceKey,
|
||||
replicatedState,
|
||||
withAbortSignal,
|
||||
withCancel,
|
||||
withContextValue,
|
||||
withoutAbortSignal,
|
||||
} from "./api.ts";
|
||||
export { BACKGROUND_CONTEXT, TODO_CONTEXT } from "./context/index.ts";
|
||||
export { RemoteServiceError } from "./services/errors.ts";
|
||||
export { RemoteServiceProvider } from "./services/provider.ts";
|
||||
export type {
|
||||
Context,
|
||||
ContextKey,
|
||||
Facet,
|
||||
FacetConnection,
|
||||
FacetEnvironment,
|
||||
FacetHost,
|
||||
FacetLoader,
|
||||
FacetOptions,
|
||||
JsonValue,
|
||||
LoadedFacets,
|
||||
MutableReplicatedState,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceConnection,
|
||||
RemoteServiceContract,
|
||||
RemoteServiceErrorCode,
|
||||
RemoteServices,
|
||||
ReplicatedState,
|
||||
Service,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSpawner,
|
||||
ServiceSubscription,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "./services/protocol.ts";
|
||||
export { createLoopbackServiceConnection, RemoteServiceProvider } from "./services/provider.ts";
|
||||
export { freshDeliveryContext, type MutableReplicatedState, type ReplicatedState, replicatedState } from "./state.ts";
|
||||
} from "./types.ts";
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
|
@ -1,29 +1,20 @@
|
|||
import { awaitWithContext, BACKGROUND_CONTEXT, type Context } from "../context.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import { freshDeliveryContext, ReplicatedStateReplica } from "../state.ts";
|
||||
import type { RemoteServices, Service } from "./contracts.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
|
||||
import { BACKGROUND_CONTEXT, waitWithContext } from "../context/index.ts";
|
||||
import type {
|
||||
Context,
|
||||
JsonValue,
|
||||
RemoteServiceBinding,
|
||||
RemoteServiceBindingOptions,
|
||||
RemoteServiceConnection,
|
||||
Service,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
} from "./protocol.ts";
|
||||
|
||||
export interface RemoteServiceBindingOptions {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
readonly connection: RemoteServiceConnection;
|
||||
readonly bound?: boolean;
|
||||
readonly onError?: (error: Error) => void;
|
||||
readonly assertAccess?: () => void;
|
||||
}
|
||||
|
||||
export interface RemoteServiceBinding extends RemoteServices {
|
||||
rebind(bound: boolean, context: Context): Promise<void>;
|
||||
}
|
||||
} from "../types.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { InstanceDirectory, type InstanceDirectoryEntry } from "./instances.ts";
|
||||
import { createDeliveryContext, ReplicatedStateReplica } from "./state.ts";
|
||||
|
||||
type ErrorReporter = (error: Error) => void;
|
||||
type ServiceMemberKind = ServiceMemberSnapshot["kind"];
|
||||
|
|
@ -355,7 +346,7 @@ class KeyedBinding<T> {
|
|||
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, freshDeliveryContext());
|
||||
for (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, createDeliveryContext());
|
||||
this.#instances.ready();
|
||||
subscription.activate();
|
||||
}
|
||||
|
|
@ -414,7 +405,7 @@ class KeyedBinding<T> {
|
|||
}
|
||||
}
|
||||
|
||||
class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
||||
export class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
||||
readonly #connection: RemoteServiceConnection;
|
||||
readonly #allowlist = new Set<string>();
|
||||
readonly #reportError: ErrorReporter;
|
||||
|
|
@ -509,7 +500,7 @@ class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
|||
),
|
||||
...[...this.#keyed.values()].map((binding) => binding.ready()),
|
||||
];
|
||||
await awaitWithContext(
|
||||
await waitWithContext(
|
||||
Promise.all(starts).then(() => undefined),
|
||||
context,
|
||||
);
|
||||
|
|
@ -604,7 +595,7 @@ class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
|||
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]!, freshDeliveryContext());
|
||||
binding.facade.install(snapshot.instances[0]!, createDeliveryContext());
|
||||
subscription.activate();
|
||||
}
|
||||
|
||||
|
|
@ -628,10 +619,6 @@ class RemoteServiceBindingImpl implements RemoteServiceBinding {
|
|||
}
|
||||
}
|
||||
|
||||
export function createRemoteServiceBinding(options: RemoteServiceBindingOptions): RemoteServiceBinding {
|
||||
return new RemoteServiceBindingImpl(options);
|
||||
}
|
||||
|
||||
function validateMembers(members: readonly ServiceMemberSnapshot[]): ReadonlyMap<string, ServiceMemberSnapshot> {
|
||||
const result = new Map<string, ServiceMemberSnapshot>();
|
||||
for (const member of members) {
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import type { Context } from "../context.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import type { MutableReplicatedState, ReplicatedState } from "../state.ts";
|
||||
|
||||
const SERVICE_TYPE = Symbol("chord.service.type");
|
||||
|
||||
export type ServiceMode = "singleton" | "keyed";
|
||||
|
||||
type InvalidJsonPart<T> = [T] extends [JsonValue]
|
||||
? [JsonValue] extends [T]
|
||||
? never
|
||||
: InvalidJsonStructure<T>
|
||||
: InvalidJsonStructure<T>;
|
||||
|
||||
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]-?: InvalidJsonPart<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;
|
||||
|
||||
/** Stable identity for one shared TypeScript service contract. */
|
||||
export interface Service<T> {
|
||||
// TODO: check if this should be part of Chord.
|
||||
readonly id: string;
|
||||
/** Process-local services accept unrestricted object contracts and are never published remotely. */
|
||||
readonly local: boolean;
|
||||
readonly [SERVICE_TYPE]?: (value: T) => T;
|
||||
}
|
||||
|
||||
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 this 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 interface RemoteServices {
|
||||
use<T>(service: Service<T>): T;
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
|
||||
/** Activate this service set and wait for every acquired service to become ready. */
|
||||
activate(context: Context): Promise<void>;
|
||||
/** Wait until every currently acquired service has installed its initial snapshot. */
|
||||
ready(context: Context): Promise<void>;
|
||||
/** Install a host lifecycle guard for proxy member access. */
|
||||
setAccessGuard(assertAccess: () => void): void;
|
||||
dispose(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export type { MutableReplicatedState, ReplicatedState };
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
export type RemoteServiceErrorCode =
|
||||
| "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";
|
||||
import type { RemoteServiceErrorCode } from "../types.ts";
|
||||
|
||||
export class RemoteServiceError extends Error {
|
||||
readonly code: RemoteServiceErrorCode;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { BACKGROUND_CONTEXT, type Context, withCancel } from "../context.ts";
|
||||
import { registerServiceInstance } from "./metadata.ts";
|
||||
import { BACKGROUND_CONTEXT, deriveCancellableContext } from "../context/index.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
const instanceKeys = new WeakMap<object, string>();
|
||||
|
||||
export function lookupServiceInstanceKey(service: object): string | undefined {
|
||||
return instanceKeys.get(service);
|
||||
}
|
||||
|
||||
export interface InstanceDirectoryEntry {
|
||||
readonly key: string;
|
||||
|
|
@ -128,7 +134,7 @@ export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
|
|||
|
||||
#start(observer: Observer, entry: TEntry): void {
|
||||
if (observer.closed || observer.tasks.has(entry)) return;
|
||||
const { context, cancel } = withCancel(BACKGROUND_CONTEXT);
|
||||
const { context, cancel } = deriveCancellableContext(BACKGROUND_CONTEXT);
|
||||
observer.tasks.set(entry, { cancel });
|
||||
try {
|
||||
void Promise.resolve(observer.handler(entry.service, context)).catch((error: unknown) => {
|
||||
|
|
@ -144,6 +150,10 @@ export class InstanceDirectory<TEntry extends InstanceDirectoryEntry> {
|
|||
}
|
||||
}
|
||||
|
||||
function registerServiceInstance(service: object, key: string): void {
|
||||
instanceKeys.set(service, key);
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
const instanceKeys = new WeakMap<object, string>();
|
||||
|
||||
export function registerServiceInstance(service: object, key: string): void {
|
||||
instanceKeys.set(service, key);
|
||||
}
|
||||
|
||||
/** Return the application key for a keyed service proxy, or undefined for a singleton. */
|
||||
export function getServiceInstanceKey(service: object): string | undefined {
|
||||
return instanceKeys.get(service);
|
||||
}
|
||||
|
|
@ -1,73 +1,11 @@
|
|||
import type { Context } from "../context.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import type { ServiceMode } from "./contracts.ts";
|
||||
|
||||
// TODO: check if this should be part of Chord.
|
||||
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 value: JsonValue };
|
||||
|
||||
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 value: JsonValue;
|
||||
}
|
||||
| { 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 RemoteServiceConnection {
|
||||
invoke(call: ServiceCall, context: Context): Promise<JsonValue | undefined>;
|
||||
subscribe(
|
||||
serviceId: string,
|
||||
mode: ServiceMode,
|
||||
listener: (update: ServiceProviderUpdate, context: Context) => void,
|
||||
context: Context,
|
||||
): Promise<ServiceSubscription>;
|
||||
}
|
||||
export type {
|
||||
RemoteServiceConnection,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "../types.ts";
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import type { Context } from "../context.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import { freshDeliveryContext } from "../state.ts";
|
||||
import { getReplicatedStateInternals, type ReplicatedStateInternals } from "../state-internals.ts";
|
||||
import type { RemoteServiceContract, Service, ServiceMode } from "./contracts.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import type {
|
||||
RemoteServiceConnection,
|
||||
Context,
|
||||
JsonValue,
|
||||
RemoteServiceContract,
|
||||
Service,
|
||||
ServiceCall,
|
||||
ServiceCatalogueEntry,
|
||||
ServiceInstanceAddress,
|
||||
ServiceInstanceSnapshot,
|
||||
ServiceMemberSnapshot,
|
||||
ServiceMode,
|
||||
ServiceProviderUpdate,
|
||||
ServiceSubscription,
|
||||
ServiceSubscriptionSnapshot,
|
||||
} from "./protocol.ts";
|
||||
} from "../types.ts";
|
||||
import { RemoteServiceError } from "./errors.ts";
|
||||
import { createDeliveryContext } from "./state.ts";
|
||||
import { getReplicatedStateInternals, type ReplicatedStateInternals } from "./state-internals.ts";
|
||||
|
||||
type RemoteMethod = (...args: unknown[]) => unknown;
|
||||
|
||||
|
|
@ -363,7 +364,7 @@ export class RemoteServiceProvider {
|
|||
|
||||
#emit(registration: ServiceRegistration, update: ServiceProviderUpdate, context?: Context): void {
|
||||
if (registration.subscribers.size === 0) return;
|
||||
const deliveryContext = context ?? freshDeliveryContext();
|
||||
const deliveryContext = context ?? createDeliveryContext();
|
||||
for (const subscriber of registration.subscribers) {
|
||||
if (subscriber.closed) continue;
|
||||
const entry = { update, context: deliveryContext };
|
||||
|
|
@ -418,17 +419,3 @@ function classifyRemoteServiceImplementation(
|
|||
if (members.size === 0) throw new TypeError(`Remote service ${serviceId} has no members`);
|
||||
return { implementation, members };
|
||||
}
|
||||
|
||||
export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection {
|
||||
return {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, (update) => listener(update, freshDeliveryContext()));
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Context } from "./context.ts";
|
||||
import type { Context } from "../types.ts";
|
||||
|
||||
export interface ReplicatedStateInternals {
|
||||
readonly sequence: number;
|
||||
|
|
@ -1,21 +1,8 @@
|
|||
import { BACKGROUND_CONTEXT, type Context, withCancel } from "./context.ts";
|
||||
import type { JsonValue } from "./json.ts";
|
||||
import { BACKGROUND_CONTEXT, deriveCancellableContext } from "../context/index.ts";
|
||||
import type { Context, JsonValue, MutableReplicatedState, ReplicatedState } from "../types.ts";
|
||||
import { registerReplicatedStateInternals } from "./state-internals.ts";
|
||||
|
||||
export interface ReplicatedState<T> {
|
||||
/** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */
|
||||
readonly value: T | undefined;
|
||||
/** Listener values are borrowed and must not be mutated or retained. */
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MutableReplicatedState<T> extends ReplicatedState<T> {
|
||||
readonly value: T;
|
||||
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
|
||||
set(value: T, context: Context): void;
|
||||
}
|
||||
|
||||
class MutableReplicatedStateImpl<T> implements MutableReplicatedState<T> {
|
||||
export class MutableReplicatedStateImpl<T> implements MutableReplicatedState<T> {
|
||||
readonly #listeners = new Set<(value: T, context: Context) => void>();
|
||||
readonly #sourceListeners = new Set<(value: T, sequence: number, context: Context) => void>();
|
||||
#value: T;
|
||||
|
|
@ -54,7 +41,7 @@ class MutableReplicatedStateImpl<T> implements MutableReplicatedState<T> {
|
|||
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
listener(this.value, freshDeliveryContext());
|
||||
listener(this.value, createDeliveryContext());
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +63,7 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
|
|||
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
if (this.#value !== undefined) this.#deliver(listener, this.#value, freshDeliveryContext());
|
||||
if (this.#value !== undefined) this.#deliver(listener, this.#value, createDeliveryContext());
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
|
|
@ -114,12 +101,8 @@ export class ReplicatedStateReplica<T extends JsonValue = JsonValue> implements
|
|||
}
|
||||
}
|
||||
|
||||
export function replicatedState<T>(initial: T): MutableReplicatedState<T> {
|
||||
return new MutableReplicatedStateImpl(initial);
|
||||
}
|
||||
|
||||
export function freshDeliveryContext(): Context {
|
||||
return withCancel(BACKGROUND_CONTEXT).context;
|
||||
export function createDeliveryContext(): Context {
|
||||
return deriveCancellableContext(BACKGROUND_CONTEXT).context;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
261
packages/chord/src/types.ts
Normal file
261
packages/chord/src/types.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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 };
|
||||
|
||||
export interface ReplicatedState<T> {
|
||||
/** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */
|
||||
readonly value: T | undefined;
|
||||
/** Listener values are borrowed and must not be mutated or retained. */
|
||||
subscribe(listener: (value: T, context: Context) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MutableReplicatedState<T> extends ReplicatedState<T> {
|
||||
readonly value: T;
|
||||
/** Transfers the JSON value to the state; the caller must not subsequently mutate it. */
|
||||
set(value: T, 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> {
|
||||
/** @publicApiReview Decide whether Chord should expose and own service identifiers. */
|
||||
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> = [T] extends [JsonValue]
|
||||
? [JsonValue] extends [T]
|
||||
? never
|
||||
: InvalidJsonStructure<T>
|
||||
: InvalidJsonStructure<T>;
|
||||
|
||||
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]-?: InvalidJsonPart<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];
|
||||
|
||||
/** @publicApiReview Consider keeping this contract-validation helper internal. */
|
||||
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
|
||||
|
||||
export interface ServiceSpawner<T> {
|
||||
spawn(key: string, implementation: T): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @publicApiReview This host-facing binding interface may be better hidden behind
|
||||
* higher-level facet connection APIs.
|
||||
*/
|
||||
export interface RemoteServices {
|
||||
use<T>(service: Service<T>): T;
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void;
|
||||
/** Activate this service set and wait for every acquired service to become ready. */
|
||||
activate(context: Context): Promise<void>;
|
||||
/** Wait until every currently acquired service has installed its initial snapshot. */
|
||||
ready(context: Context): Promise<void>;
|
||||
/** Install a host lifecycle guard for proxy member access. */
|
||||
setAccessGuard(assertAccess: () => void): void;
|
||||
dispose(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
export type ServiceCatalogueEntry = {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
};
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
export type ServiceInstanceAddress = {
|
||||
readonly key: string;
|
||||
readonly generation: number;
|
||||
};
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
export type ServiceMemberSnapshot =
|
||||
| { readonly name: string; readonly kind: "method" }
|
||||
| { readonly name: string; readonly kind: "state"; readonly sequence: number; readonly value: JsonValue };
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
export type ServiceInstanceSnapshot = {
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly members: readonly ServiceMemberSnapshot[];
|
||||
};
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
export type ServiceSubscriptionSnapshot = {
|
||||
readonly serviceId: string;
|
||||
readonly mode: ServiceMode;
|
||||
readonly instances: readonly ServiceInstanceSnapshot[];
|
||||
};
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
export type ServiceProviderUpdate =
|
||||
| {
|
||||
readonly type: "state";
|
||||
readonly instance?: ServiceInstanceAddress;
|
||||
readonly member: string;
|
||||
readonly sequence: number;
|
||||
readonly value: JsonValue;
|
||||
}
|
||||
| { readonly type: "unavailable" }
|
||||
| { readonly type: "replaced"; readonly snapshot: ServiceInstanceSnapshot }
|
||||
| { readonly type: "spawned"; readonly instance: ServiceInstanceSnapshot }
|
||||
| { readonly type: "closed"; readonly instance: ServiceInstanceAddress };
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
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[];
|
||||
};
|
||||
|
||||
/** @publicApiReview Consider hiding this low-level transport type behind the connection API. */
|
||||
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.
|
||||
*
|
||||
* @publicApiReview Consider replacing this low-level transport boundary with a higher-level adapter API.
|
||||
*/
|
||||
export interface RemoteServiceConnection {
|
||||
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 connection: RemoteServiceConnection;
|
||||
readonly bound?: boolean;
|
||||
readonly onError?: (error: Error) => void;
|
||||
readonly assertAccess?: () => void;
|
||||
}
|
||||
|
||||
export interface RemoteServiceBinding extends RemoteServices {
|
||||
rebind(bound: boolean, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export type RemoteServiceErrorCode =
|
||||
| "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";
|
||||
|
||||
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>(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 FacetConnection {
|
||||
/** Whether this currently unavailable route 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 connections?: readonly FacetConnection[];
|
||||
readonly onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface FacetHost {
|
||||
readonly services: RemoteServiceProvider;
|
||||
/** Replace active facets with matching IDs while preserving 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>;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { JsonValue } from "@earendil-works/chord/json";
|
||||
import type { JsonValue } from "@earendil-works/chord/types";
|
||||
import Type from "typebox";
|
||||
|
||||
/** Static JSON contract. Concrete serializers are responsible for rejecting unsupported runtime values. */
|
||||
export const JsonValueSchema = Type.Unsafe<JsonValue>(Type.Unknown());
|
||||
|
||||
export type { JsonValue } from "@earendil-works/chord/json";
|
||||
export type { JsonValue } from "@earendil-works/chord/types";
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@earendil-works/chord": ["./packages/chord/src/index.ts"],
|
||||
"@earendil-works/chord/context": ["./packages/chord/src/context.ts"],
|
||||
"@earendil-works/chord/json": ["./packages/chord/src/json.ts"],
|
||||
"@earendil-works/chord/api": ["./packages/chord/src/api.ts"],
|
||||
"@earendil-works/chord/context": ["./packages/chord/src/context/index.ts"],
|
||||
"@earendil-works/chord/types": ["./packages/chord/src/types.ts"],
|
||||
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
|
||||
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
|
||||
"@earendil-works/pi-ai": ["./packages/ai/src/index.ts"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue