import { type Context, createRemoteServiceBinding, type MutableReplicatedState, type RemoteServiceBinding, type RemoteServiceSource, type RemoteServices, type RemoteServiceTransport, type ReplicatedState, replicatedState, type Service, type ServiceCatalogueEntry, } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { type Client, createClientServiceTransport } from "@earendil-works/pi-client"; import type { SessionTarget } from "@earendil-works/pi-protocol"; export type ServerConnectionState = | { status: "connecting"; attempt: number } | { status: "connected"; since: string } | { status: "disconnected"; since: string; reason: string; retryAt: string | null }; export type SessionAttachmentState = | { status: "detached" } | { status: "attaching" | "attached" | "degraded"; sessionId: string }; export interface ServerServiceSource extends RemoteServiceSource { readonly connection: ReplicatedState; dispose(context: Context): Promise; } export interface SessionServiceSource extends RemoteServiceSource { readonly attachment: ReplicatedState; /** Wait for the exact current attachment generation to finish hydrating. */ whenAttached(sessionId: string, context: Context): Promise; /** Wait for every binding to finish releasing the previous attachment. */ whenDetached(context: Context): Promise; dispose(context: Context): Promise; } export interface ServiceSourceOptions { readonly onError?: (error: Error) => void; } class RoutedServiceBinding implements RemoteServices { readonly #services: RemoteServiceBinding; readonly #getBound: () => boolean; readonly #onActivate: (context: Context) => Promise; readonly #remove: () => void; #activated = false; #activationComplete = false; constructor(options: { readonly services: readonly { readonly id: string }[]; readonly transport: RemoteServiceTransport; readonly getBound: () => boolean; readonly assertAccess: () => void; readonly onError: (error: Error) => void; readonly onActivate?: (context: Context) => Promise; readonly remove: () => void; }) { this.#services = createRemoteServiceBinding({ services: options.services, transport: options.transport, bound: false, assertAccess: options.assertAccess, onError: options.onError, }); this.#getBound = options.getBound; this.#onActivate = options.onActivate ?? (() => Promise.resolve()); this.#remove = options.remove; } use(service: Service): T { return this.#services.use(service); } observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void { return this.#services.observe(service, handler); } async ready(context: Context): Promise { if (!this.#activated) { this.#activated = true; await this.#services.rebind(this.#getBound(), context); } await this.serviceReady(context); if (!this.#activationComplete) { await this.#onActivate(context); this.#activationComplete = true; } } serviceReady(context: Context): Promise { return this.#services.ready(context); } updateBound(bound: boolean, context: Context): Promise { return this.#activated ? this.#services.rebind(bound, context) : Promise.resolve(); } async dispose(context: Context): Promise { this.#remove(); await this.#services.dispose(context); } } class ServerServiceSourceImpl implements ServerServiceSource { readonly acceptsUnavailableServices = false; readonly connection: ReplicatedState; readonly #client: Client; readonly #transport: RemoteServiceTransport; readonly #bindings = new Set(); readonly #removeConnectionListener: () => void; readonly #onError: (error: Error) => void; #transition = Promise.resolve(); #connectionAttempt: number; #disposed = false; constructor(client: Client, options: ServiceSourceOptions) { this.#client = client; this.#transport = createClientServiceTransport(client, () => ({ serverId: client.serverId })); this.#onError = options.onError ?? (() => {}); this.#connectionAttempt = client.connectionState === "connecting" ? 1 : 0; const connectionState = replicatedState( toServerConnectionState(client, this.#connectionAttempt), ); this.connection = connectionState; this.#removeConnectionListener = client.onConnectionStateChange(({ state, error }) => { if (state === "connecting") this.#connectionAttempt += 1; publishReplacement( connectionState, toServerConnectionState(client, this.#connectionAttempt, error), BACKGROUND_CONTEXT, ); this.#transition = this.#transition .then(async () => { const results = await Promise.allSettled( [...this.#bindings].map((binding) => binding.updateBound(state === "connected", BACKGROUND_CONTEXT)), ); const failures = results.filter( (result): result is PromiseRejectedResult => result.status === "rejected", ); if (failures.length > 0) throw new AggregateError(failures.map(({ reason }) => reason)); }) .catch((transitionError: unknown) => this.#onError(toError(transitionError))); }); } catalogue(context: Context) { return this.#client.serviceCatalogue({ serverId: this.#client.serverId }, context.abortSignal); } open(options: { readonly services: readonly { readonly id: string }[]; assertAccess(): void; onError(error: Error): void; }): RemoteServices { if (this.#disposed) throw new Error("Server service source is disposed"); let binding!: RoutedServiceBinding; binding = new RoutedServiceBinding({ services: options.services, transport: this.#transport, getBound: () => this.#client.connected, assertAccess: options.assertAccess, onError: options.onError, remove: () => this.#bindings.delete(binding), }); this.#bindings.add(binding); return binding; } async dispose(context: Context): Promise { if (this.#disposed) return; this.#disposed = true; this.#removeConnectionListener(); await this.#transition; const bindings = [...this.#bindings]; this.#bindings.clear(); const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context))); throwFailures(results, "Failed to dispose server service source"); } } class SessionServiceSourceImpl implements SessionServiceSource { readonly attachment: ReplicatedState; readonly #client: Client; readonly #transport: RemoteServiceTransport; readonly #attachmentState: MutableReplicatedState; readonly #bindings = new Set(); readonly #removeAttachmentListener: () => void; readonly #onError: (error: Error) => void; readonly #transitions = new Set>(); #catalogue: readonly ServiceCatalogueEntry[] | undefined; #attachmentRevision = 0; #disposed = false; get acceptsUnavailableServices(): boolean { return this.#client.attachment === undefined && this.#catalogue === undefined; } constructor(client: Client, options: ServiceSourceOptions) { this.#client = client; this.#transport = createClientServiceTransport(client, () => client.attachment); this.#onError = options.onError ?? (() => {}); this.#attachmentState = replicatedState( client.attachment === undefined ? { status: "detached" } : { status: "attaching", sessionId: client.attachment.sessionId }, ); this.attachment = this.#attachmentState; this.#removeAttachmentListener = client.onAttachmentChange((attachment) => { const revision = ++this.#attachmentRevision; if (attachment !== undefined) { publishReplacement( this.#attachmentState, { status: "attaching", sessionId: attachment.sessionId }, BACKGROUND_CONTEXT, ); void this.#client.serviceCatalogue(attachment).then( (catalogue) => { if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) { this.#catalogue = catalogue; } }, (error: unknown) => this.#onError(toError(error)), ); } const transition = this.#rebind(attachment !== undefined, BACKGROUND_CONTEXT); this.#transitions.add(transition); void transition.then( () => { this.#transitions.delete(transition); if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment)) return; publishReplacement( this.#attachmentState, attachment === undefined ? { status: "detached" } : { status: "attached", sessionId: attachment.sessionId }, BACKGROUND_CONTEXT, ); }, (error: unknown) => { this.#transitions.delete(transition); if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment)) return; publishReplacement( this.#attachmentState, attachment === undefined ? { status: "detached" } : { status: "degraded", sessionId: attachment.sessionId }, BACKGROUND_CONTEXT, ); this.#onError(toError(error)); }, ); }); } async catalogue(context: Context): Promise { const target = this.#client.attachment; if (target === undefined) return this.#catalogue ?? []; const catalogue = await this.#client.serviceCatalogue(target, context.abortSignal); this.#catalogue = catalogue; return catalogue; } open(options: { readonly services: readonly { readonly id: string }[]; assertAccess(): void; onError(error: Error): void; }): RemoteServices { if (this.#disposed) throw new Error("Session service source is disposed"); let binding!: RoutedServiceBinding; binding = new RoutedServiceBinding({ services: options.services, transport: this.#transport, getBound: () => this.#client.attachment !== undefined, onActivate: async (context) => { const attachment = this.#client.attachment; if (attachment === undefined) await this.#whenDetached(this.#attachmentRevision, context); else await this.#whenAttached(attachment, this.#attachmentRevision, context); }, assertAccess: options.assertAccess, onError: options.onError, remove: () => this.#bindings.delete(binding), }); this.#bindings.add(binding); return binding; } async whenAttached(sessionId: string, context: Context): Promise { const attachment = this.#client.attachment; if (attachment === undefined || attachment.sessionId !== sessionId) { throw new Error(`Session ${sessionId} is not the current attachment`); } await this.#whenAttached(attachment, this.#attachmentRevision, context); } async whenDetached(context: Context): Promise { if (this.#client.attachment !== undefined) throw new Error("A Session is still attached"); await this.#whenDetached(this.#attachmentRevision, context); } async dispose(context: Context): Promise { if (this.#disposed) return; this.#disposed = true; this.#removeAttachmentListener(); await Promise.allSettled(this.#transitions); const bindings = [...this.#bindings]; this.#bindings.clear(); const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context))); throwFailures(results, "Failed to dispose Session service source"); } async #rebind(bound: boolean, context: Context): Promise { const results = await Promise.allSettled( [...this.#bindings].map((binding) => binding.updateBound(bound, context)), ); throwFailures(results, "Failed to rebind Session services"); } async #whenAttached(attachment: SessionTarget, revision: number, context: Context): Promise { try { await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context))); } catch (error) { if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) { publishReplacement(this.#attachmentState, { status: "degraded", sessionId: attachment.sessionId }, context); } throw error; } if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment)) { throw new Error(`Session ${attachment.sessionId} was replaced while attaching`); } const state = this.#attachmentState.value; if (state.status !== "attached" || state.sessionId !== attachment.sessionId) { publishReplacement(this.#attachmentState, { status: "attached", sessionId: attachment.sessionId }, context); } } async #whenDetached(revision: number, context: Context): Promise { await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context))); if (this.#attachmentRevision !== revision || this.#client.attachment !== undefined) { throw new Error("The Session attachment changed while detaching"); } if (this.#attachmentState.value.status !== "detached") { publishReplacement(this.#attachmentState, { status: "detached" }, context); } } } /** Create the server-scoped remote service source for one presentation client. */ export function createServerServiceSource(client: Client, options: ServiceSourceOptions = {}): ServerServiceSource { return new ServerServiceSourceImpl(client, options); } /** Create the selected-Session remote service source for one presentation client. */ export function createSessionServiceSource(client: Client, options: ServiceSourceOptions = {}): SessionServiceSource { return new SessionServiceSourceImpl(client, options); } function publishReplacement(state: MutableReplicatedState, value: T, context: Context): void { const target = state.state as Record; const replacement = value as Record; for (const key of Object.keys(target)) { if (!Object.hasOwn(replacement, key)) delete target[key]; } Object.assign(target, replacement); state.publish(context); } function toServerConnectionState(client: Client, attempt: number, error?: Error): ServerConnectionState { const since = new Date().toISOString(); switch (client.connectionState) { case "connecting": return { status: "connecting", attempt }; case "connected": return { status: "connected", since }; case "disconnected": return { status: "disconnected", since, reason: error?.message ?? "Client is disconnected", retryAt: null, }; } } function sameAttachment(left: SessionTarget | undefined, right: SessionTarget | undefined): boolean { if (left === undefined || right === undefined) return left === right; return ( left.serverId === right.serverId && left.sessionId === right.sessionId && left.attachmentId === right.attachmentId ); } function throwFailures(results: readonly PromiseSettledResult[], message: string): void { const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, message); } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); }