From 6ec57e6e5f852f2ba4eba738b085012e4cbf06d8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 29 Aug 2026 18:56:26 +0200 Subject: [PATCH] feat: consolidate Chord public API --- packages/agent/src/harness/context.ts | 21 +- packages/agent/src/harness/session/types.ts | 4 +- packages/ai/src/types.ts | 4 +- packages/chord/PLANNING.md | 9 +- packages/chord/README.md | 7 +- packages/chord/package.json | 21 +- packages/chord/src/api.ts | 172 ++++++++++++ .../src/{context.ts => context/index.ts} | 61 ++-- packages/chord/src/facets/host.ts | 33 ++- packages/chord/src/facets/loader.ts | 50 +--- packages/chord/src/facets/types.ts | 61 ---- packages/chord/src/index.ts | 65 +++-- packages/chord/src/json.ts | 1 - packages/chord/src/services/consumer.ts | 41 +-- packages/chord/src/services/contracts.ts | 80 ------ packages/chord/src/services/errors.ts | 10 +- packages/chord/src/services/instances.ts | 16 +- packages/chord/src/services/metadata.ts | 10 - packages/chord/src/services/protocol.ts | 84 +----- packages/chord/src/services/provider.ts | 33 +-- .../src/{ => services}/state-internals.ts | 2 +- packages/chord/src/{ => services}/state.ts | 31 +-- packages/chord/src/types.ts | 261 ++++++++++++++++++ packages/protocol/src/json-value.ts | 4 +- tsconfig.json | 5 +- 25 files changed, 614 insertions(+), 472 deletions(-) create mode 100644 packages/chord/src/api.ts rename packages/chord/src/{context.ts => context/index.ts} (55%) delete mode 100644 packages/chord/src/facets/types.ts delete mode 100644 packages/chord/src/json.ts delete mode 100644 packages/chord/src/services/contracts.ts delete mode 100644 packages/chord/src/services/metadata.ts rename packages/chord/src/{ => services}/state-internals.ts (93%) rename packages/chord/src/{ => services}/state.ts (74%) create mode 100644 packages/chord/src/types.ts diff --git a/packages/agent/src/harness/context.ts b/packages/agent/src/harness/context.ts index 2fb5f13df..9b633c305 100644 --- a/packages/agent/src/harness/context.ts +++ b/packages/agent/src/harness/context.ts @@ -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("pi.telemetryContext"); diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 5fd0dc801..9abb77c3d 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -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; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 9c97325a9..5b56a5611 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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; diff --git a/packages/chord/PLANNING.md b/packages/chord/PLANNING.md index f429bf078..9e3359a8b 100644 --- a/packages/chord/PLANNING.md +++ b/packages/chord/PLANNING.md @@ -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 diff --git a/packages/chord/README.md b/packages/chord/README.md index bd1d63c39..e8deebd36 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -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. diff --git a/packages/chord/package.json b/packages/chord/package.json index 81f82a2c8..e4f37cce5 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -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" }, diff --git a/packages/chord/src/api.ts b/packages/chord/src/api.ts new file mode 100644 index 000000000..57c5115ca --- /dev/null +++ b/packages/chord/src/api.ts @@ -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(description: string): ContextKey { + return Object.freeze({ token: Symbol(description) }); +} + +/** Derive a context containing one additional or replaced value. */ +export function withContextValue(key: ContextKey, 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(promise: Promise, context: Context): Promise { + return waitWithContext(promise, context); +} + +/** Create an active host for one complete set of facets. */ +export async function createFacetHost(options: FacetOptions): Promise { + 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(id: string, options: { readonly local: true }): Service; +export function defineService( + id: string, + ...options: [RemoteServiceContract] extends [never] + ? readonly [options: never] + : readonly [options?: { readonly local?: false }] +): Service; +export function defineService(id: string, options?: { readonly local?: boolean }): Service { + 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(initial: T): MutableReplicatedState { + return new MutableReplicatedStateImpl(initial); +} + +/** @publicApiReview Delivery contexts may only be an internal service-replication concern. */ +export function freshDeliveryContext(): Context { + return createDeliveryContext(); +} diff --git a/packages/chord/src/context.ts b/packages/chord/src/context/index.ts similarity index 55% rename from packages/chord/src/context.ts rename to packages/chord/src/context/index.ts index 99688ba7b..fcc991ac8 100644 --- a/packages/chord/src/context.ts +++ b/packages/chord/src/context/index.ts @@ -1,21 +1,11 @@ -/** Typed identity for one value carried by a {@link Context}. */ -export interface ContextKey { - 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 = Object.freeze({ +export type { Context, ContextKey } from "../types.ts"; + +export const ABORT_SIGNAL_CONTEXT_KEY: ContextKey = Object.freeze({ token: Symbol("chord.abortSignal"), }); -/** Immutable invocation-scoped values passed explicitly through operations. */ -export interface Context { - readonly abortSignal: AbortSignal | undefined; - value(key: ContextKey): T | undefined; - toString(): string; -} - abstract class BaseContext implements Context { abstract value(key: ContextKey): 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 extends BaseContext { readonly #parent: Context; readonly #key: ContextKey; @@ -65,53 +54,39 @@ class ContextValue 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(description: string): ContextKey { +function createContextKey(description: string): ContextKey { return Object.freeze({ token: Symbol(description) }); } -/** Derive a context containing one additional or replaced value. */ -export function withContextValue(key: ContextKey, value: T, parent: Context): Context { +export function deriveContextValue(key: ContextKey, 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(promise: Promise, context: Context): Promise { +export function waitWithContext(promise: Promise, context: Context): Promise { 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, +}); diff --git a/packages/chord/src/facets/host.ts b/packages/chord/src/facets/host.ts index 49f333b2a..0955e5e2b 100644 --- a/packages/chord/src/facets/host.ts +++ b/packages/chord/src/facets/host.ts @@ -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 { - 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: (initial: T) => { lifecycle.assertRunning("create replicated state"); - return replicatedState(initial); + return new MutableReplicatedStateImpl(initial); }, own: (disposal) => lifecycle.own(disposal), onActivate: (callback) => lifecycle.onActivate(callback), diff --git a/packages/chord/src/facets/loader.ts b/packages/chord/src/facets/loader.ts index 055db1c8b..0e4374abc 100644 --- a/packages/chord/src/facets/loader.ts +++ b/packages/chord/src/facets/loader.ts @@ -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; -} - -export interface FacetLoader { - load(): Promise; -} - -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 { +export async function disposeLoadedFacets(loaded: readonly LoadedFacets[]): Promise { const results = await Promise.allSettled(loaded.map((entry) => entry.dispose())); return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); } diff --git a/packages/chord/src/facets/types.ts b/packages/chord/src/facets/types.ts deleted file mode 100644 index 108c0154c..000000000 --- a/packages/chord/src/facets/types.ts +++ /dev/null @@ -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 { - spawn(key: string, implementation: T): () => void; -} - -export interface FacetEnvironment { - /** Declare a hard dependency on one singleton service and return its stable handle. */ - use(service: Service): T; - /** Declare a hard dependency on a keyed service and observe each live instance. */ - observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; - /** Declare and install this facet's singleton implementation of a service. */ - provide(service: Service, implementation: NoInfer): void; - /** Declare ownership of a multi-instance service and return its deferred spawning capability. */ - provideMany(service: Service): ServiceSpawner; - /** Create initialized mutable state suitable for exposing through a service implementation. */ - replicatedState(initial: T): MutableReplicatedState; - /** Give the facet ownership of a resource cleanup function. */ - own(disposal: () => void | Promise): void; - /** Register asynchronous initialization after dependencies are bound and ready. */ - onActivate(callback: () => void | Promise): void; - /** Register final facet teardown. */ - onDeactivate(callback: () => void | Promise): 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; - 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; - dispose(): Promise; -} diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 29a22c1d1..df28f4ee1 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -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"; diff --git a/packages/chord/src/json.ts b/packages/chord/src/json.ts deleted file mode 100644 index e7aaded37..000000000 --- a/packages/chord/src/json.ts +++ /dev/null @@ -1 +0,0 @@ -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; diff --git a/packages/chord/src/services/consumer.ts b/packages/chord/src/services/consumer.ts index f21796c7d..f349e3833 100644 --- a/packages/chord/src/services/consumer.ts +++ b/packages/chord/src/services/consumer.ts @@ -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; -} +} 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 { 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 { } } -class RemoteServiceBindingImpl implements RemoteServiceBinding { +export class RemoteServiceBindingImpl implements RemoteServiceBinding { readonly #connection: RemoteServiceConnection; readonly #allowlist = new Set(); 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 { const result = new Map(); for (const member of members) { diff --git a/packages/chord/src/services/contracts.ts b/packages/chord/src/services/contracts.ts deleted file mode 100644 index 083aa7ce8..000000000 --- a/packages/chord/src/services/contracts.ts +++ /dev/null @@ -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] extends [JsonValue] - ? [JsonValue] extends [T] - ? never - : InvalidJsonStructure - : InvalidJsonStructure; - -type InvalidJsonStructure = T extends null | boolean | number | string - ? never - : T extends readonly (infer TItem)[] - ? InvalidJsonPart - : T extends (...args: never[]) => unknown - ? T - : T extends object - ? { [TKey in keyof T]-?: InvalidJsonPart }[keyof T] - : T; - -type InvalidRemoteMember = T extends ReplicatedState - ? InvalidJsonPart extends never - ? never - : "state value is not JSON" - : T extends (...args: [...infer TArgs, Context]) => Promise - ? InvalidJsonPart extends never - ? TResult extends void - ? never - : InvalidJsonPart 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 = { - [TKey in keyof T]-?: InvalidRemoteMember extends never ? never : TKey; -}[keyof T]; - -export type RemoteServiceContract = InvalidRemoteMemberNames extends never ? T : never; - -/** Stable identity for one shared TypeScript service contract. */ -export interface Service { - // 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(id: string, options: { readonly local: true }): Service; -export function defineService( - id: string, - ...options: [RemoteServiceContract] extends [never] - ? readonly [options: never] - : readonly [options?: { readonly local?: false }] -): Service; -export function defineService(id: string, options?: { readonly local?: boolean }): Service { - 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(service: Service): T; - observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; - /** Activate this service set and wait for every acquired service to become ready. */ - activate(context: Context): Promise; - /** Wait until every currently acquired service has installed its initial snapshot. */ - ready(context: Context): Promise; - /** Install a host lifecycle guard for proxy member access. */ - setAccessGuard(assertAccess: () => void): void; - dispose(context: Context): Promise; -} - -export type { MutableReplicatedState, ReplicatedState }; diff --git a/packages/chord/src/services/errors.ts b/packages/chord/src/services/errors.ts index 3827fe87e..56016020e 100644 --- a/packages/chord/src/services/errors.ts +++ b/packages/chord/src/services/errors.ts @@ -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; diff --git a/packages/chord/src/services/instances.ts b/packages/chord/src/services/instances.ts index 8fd81e63f..41909f5c7 100644 --- a/packages/chord/src/services/instances.ts +++ b/packages/chord/src/services/instances.ts @@ -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(); + +export function lookupServiceInstanceKey(service: object): string | undefined { + return instanceKeys.get(service); +} export interface InstanceDirectoryEntry { readonly key: string; @@ -128,7 +134,7 @@ export class InstanceDirectory { #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 { } } +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)); } diff --git a/packages/chord/src/services/metadata.ts b/packages/chord/src/services/metadata.ts deleted file mode 100644 index d34c71aa4..000000000 --- a/packages/chord/src/services/metadata.ts +++ /dev/null @@ -1,10 +0,0 @@ -const instanceKeys = new WeakMap(); - -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); -} diff --git a/packages/chord/src/services/protocol.ts b/packages/chord/src/services/protocol.ts index 19d6a1a83..ae0be77db 100644 --- a/packages/chord/src/services/protocol.ts +++ b/packages/chord/src/services/protocol.ts @@ -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; -} - -/** - * 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; - subscribe( - serviceId: string, - mode: ServiceMode, - listener: (update: ServiceProviderUpdate, context: Context) => void, - context: Context, - ): Promise; -} +export type { + RemoteServiceConnection, + ServiceCall, + ServiceCatalogueEntry, + ServiceInstanceAddress, + ServiceInstanceSnapshot, + ServiceMemberSnapshot, + ServiceProviderUpdate, + ServiceSubscription, + ServiceSubscriptionSnapshot, +} from "../types.ts"; diff --git a/packages/chord/src/services/provider.ts b/packages/chord/src/services/provider.ts index 4a25f41de..3da0cc46f 100644 --- a/packages/chord/src/services/provider.ts +++ b/packages/chord/src/services/provider.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(), - }; - }, - }; -} diff --git a/packages/chord/src/state-internals.ts b/packages/chord/src/services/state-internals.ts similarity index 93% rename from packages/chord/src/state-internals.ts rename to packages/chord/src/services/state-internals.ts index dbe19a284..dccc2fe11 100644 --- a/packages/chord/src/state-internals.ts +++ b/packages/chord/src/services/state-internals.ts @@ -1,4 +1,4 @@ -import type { Context } from "./context.ts"; +import type { Context } from "../types.ts"; export interface ReplicatedStateInternals { readonly sequence: number; diff --git a/packages/chord/src/state.ts b/packages/chord/src/services/state.ts similarity index 74% rename from packages/chord/src/state.ts rename to packages/chord/src/services/state.ts index 43d77832e..d3ff4ed57 100644 --- a/packages/chord/src/state.ts +++ b/packages/chord/src/services/state.ts @@ -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 { - /** 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 extends ReplicatedState { - 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 implements MutableReplicatedState { +export class MutableReplicatedStateImpl implements MutableReplicatedState { 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 implements MutableReplicatedState { 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 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 implements } } -export function replicatedState(initial: T): MutableReplicatedState { - 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 { diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts new file mode 100644 index 000000000..77fc588c5 --- /dev/null +++ b/packages/chord/src/types.ts @@ -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 { + 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(key: ContextKey): T | undefined; + toString(): string; +} + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export interface ReplicatedState { + /** 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 extends ReplicatedState { + 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 { + /** @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] extends [JsonValue] + ? [JsonValue] extends [T] + ? never + : InvalidJsonStructure + : InvalidJsonStructure; + +type InvalidJsonStructure = T extends null | boolean | number | string + ? never + : T extends readonly (infer TItem)[] + ? InvalidJsonPart + : T extends (...args: never[]) => unknown + ? T + : T extends object + ? { [TKey in keyof T]-?: InvalidJsonPart }[keyof T] + : T; + +type InvalidRemoteMember = T extends ReplicatedState + ? InvalidJsonPart extends never + ? never + : "state value is not JSON" + : T extends (...args: [...infer TArgs, Context]) => Promise + ? InvalidJsonPart extends never + ? TResult extends void + ? never + : InvalidJsonPart 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 = { + [TKey in keyof T]-?: InvalidRemoteMember extends never ? never : TKey; +}[keyof T]; + +/** @publicApiReview Consider keeping this contract-validation helper internal. */ +export type RemoteServiceContract = InvalidRemoteMemberNames extends never ? T : never; + +export interface ServiceSpawner { + 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(service: Service): T; + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; + /** Activate this service set and wait for every acquired service to become ready. */ + activate(context: Context): Promise; + /** Wait until every currently acquired service has installed its initial snapshot. */ + ready(context: Context): Promise; + /** Install a host lifecycle guard for proxy member access. */ + setAccessGuard(assertAccess: () => void): void; + dispose(context: Context): Promise; +} + +/** @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; +} + +/** + * 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; + subscribe( + serviceId: string, + mode: ServiceMode, + listener: (update: ServiceProviderUpdate, context: Context) => void, + context: Context, + ): Promise; +} + +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; +} + +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(service: Service): T; + /** Declare a hard dependency on a keyed service and observe each live instance. */ + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; + /** Declare and install this facet's singleton implementation of a service. */ + provide(service: Service, implementation: NoInfer): void; + /** Declare ownership of a multi-instance service and return its deferred spawning capability. */ + provideMany(service: Service): ServiceSpawner; + /** Create initialized mutable state suitable for exposing through a service implementation. */ + replicatedState(initial: T): MutableReplicatedState; + /** Give the facet ownership of a resource cleanup function. */ + own(disposal: () => void | Promise): void; + /** Register asynchronous initialization after dependencies are bound and ready. */ + onActivate(callback: () => void | Promise): void; + /** Register final facet teardown. */ + onDeactivate(callback: () => void | Promise): 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; + 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; + dispose(): Promise; +} + +export interface LoadedFacets { + readonly facets: readonly Facet[]; + dispose(): Promise; +} + +export interface FacetLoader { + load(): Promise; +} diff --git a/packages/protocol/src/json-value.ts b/packages/protocol/src/json-value.ts index c20c7520d..8215809ab 100644 --- a/packages/protocol/src/json-value.ts +++ b/packages/protocol/src/json-value.ts @@ -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(Type.Unknown()); -export type { JsonValue } from "@earendil-works/chord/json"; +export type { JsonValue } from "@earendil-works/chord/types"; diff --git a/tsconfig.json b/tsconfig.json index b736a3e6e..62b44ddaf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],