@earendil-works/pi-client 0.84.4 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +37 -28
- package/dist/client.d.ts +20 -15
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +248 -245
- package/dist/client.js.map +1 -1
- package/dist/connection.d.ts +4 -3
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +13 -9
- package/dist/connection.js.map +1 -1
- package/dist/errors.d.ts +6 -15
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -28
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +14 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/unix.d.ts +14 -1
- package/dist/unix.d.ts.map +1 -1
- package/dist/unix.js +138 -8
- package/dist/unix.js.map +1 -1
- package/package.json +3 -2
- package/dist/session-handle.d.ts +0 -54
- package/dist/session-handle.d.ts.map +0 -1
- package/dist/session-handle.js +0 -51
- package/dist/session-handle.js.map +0 -1
- package/dist/state.d.ts +0 -22
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js +0 -145
- package/dist/state.js.map +0 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,63 +1,72 @@
|
|
|
1
1
|
# @earendil-works/pi-client
|
|
2
2
|
|
|
3
|
-
Transport-neutral client for
|
|
3
|
+
Transport-neutral client for the experimental Pi service protocol.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import {
|
|
6
|
+
import { Client, type ByteTransportFactory } from "@earendil-works/pi-client";
|
|
7
7
|
|
|
8
8
|
const transportFactory: ByteTransportFactory = async (handlers) => {
|
|
9
9
|
// Connect using WebSocket, Unix socket, or another ordered byte transport.
|
|
10
10
|
return {
|
|
11
11
|
async send(chunk) {
|
|
12
|
-
// Deliver
|
|
12
|
+
// Deliver bytes in invocation order and honor backpressure.
|
|
13
13
|
},
|
|
14
14
|
close() {},
|
|
15
15
|
};
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const client =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
await
|
|
23
|
-
|
|
18
|
+
const client = await Client.connect({
|
|
19
|
+
serverId: "01234567-89ab-4def-8123-456789abcdef",
|
|
20
|
+
transportFactory,
|
|
21
|
+
});
|
|
22
|
+
const result = await client.request(
|
|
23
|
+
{ serverId: client.hello.serverId },
|
|
24
|
+
{ serviceId: "example.service", member: "read", args: [] },
|
|
25
|
+
);
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`PiClient` does not reconnect automatically. Call `reconnect()` after disconnection. One connection can attach several sessions. Requests are correlated by ID. Server snapshots and successful response snapshots are authoritative, while progress events do not mutate snapshot state optimistically. Read cached session metadata from `client.snapshot?.sessions`; call `listSessions()` to request refreshed durable metadata from the server. Runtime state is available after acquiring a session.
|
|
28
|
+
The client verifies that the physical endpoint reports the expected logical `serverId`. Server-wide requests carry that ID, and every Session request carries the full live target `{ serverId, sessionId, attachmentId }`. The combined durable address prevents cross-server or cross-session misrouting; the server-generated attachment ID rejects delayed frames after switching or reattaching.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Typed server and Session APIs are provided by Chord service bindings owned by the application. `createClientServiceTransport()` adapts a lazily resolved server or Session route to a Chord transport; `request()` and `subscribeService()` remain its low-level primitives. The client uses Chord's service-control parsers and per-subscription state decoder; `pi-protocol` only validates the routed envelope and strict-JSON boundary. A service subscription returns a complete provider snapshot; the binding installs it and then calls `start()` to release updates buffered during hydration. `Client` applies ordered out-of-band attachment changes but deliberately does not construct typed service proxies or interpret application contracts.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Application observation APIs such as the coding agent's `Transcript` are ordinary Chord services. The client does not interpret their snapshots or updates.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
On disconnect or disposal, pending requests reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach through the application's management service again, and explicitly repeat only operations known to be safe.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own Session and worker lifecycle outside the public client protocol.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Call transport handlers as follows:
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
- `handlers.onData(chunk)` for inbound bytes;
|
|
41
|
+
- `handlers.onClose()` for an orderly terminal close;
|
|
42
|
+
- `handlers.onError(error)` for transport failures.
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
A transport factory creates a fresh authenticated connection for each attempt. Requests are correlated by ID, and server failures are exposed as `ServerError`.
|
|
43
45
|
|
|
44
46
|
## Unix-domain sockets
|
|
45
47
|
|
|
46
|
-
Node.js and Bun consumers can use the
|
|
48
|
+
Node.js and Bun consumers can use the separate Unix transport:
|
|
47
49
|
|
|
48
50
|
```ts
|
|
49
|
-
import {
|
|
51
|
+
import { Client } from "@earendil-works/pi-client";
|
|
50
52
|
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
|
|
51
53
|
|
|
52
|
-
const client = new
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}),
|
|
54
|
+
const client = new Client({
|
|
55
|
+
serverId: "01234567-89ab-4def-8123-456789abcdef",
|
|
56
|
+
transportFactory: createUnixTransportFactory({ path: "/tmp/pi.sock" }),
|
|
56
57
|
});
|
|
57
|
-
|
|
58
58
|
await client.connect();
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Unix discovery scans an explicit physical-route directory, derives each expected server ID from its filename, and verifies it through the existing handshake:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { discoverUnixServers } from "@earendil-works/pi-client/unix";
|
|
65
|
+
|
|
66
|
+
const routes = await discoverUnixServers({ directory: "/run/user/1000/pi" });
|
|
67
|
+
// [{ serverId: "...", path: "/run/user/1000/pi/<serverId>.sock" }]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Malformed entries, non-sockets, stale or unresponsive endpoints, and server-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery. Pass `timeoutMs` to override the default probe timeout.
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
`ClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { type
|
|
3
|
-
import type { ConnectionState, ConnectionStateChange,
|
|
4
|
-
|
|
1
|
+
import { type JsonValue, type RemoteServiceTransport, type ServiceCall, type ServiceCatalogueEntry, type ServiceMode, type ServiceProviderUpdate } from "@earendil-works/chord";
|
|
2
|
+
import { type RpcTarget, type ServerHello, type SessionTarget } from "@earendil-works/pi-protocol";
|
|
3
|
+
import type { AttachmentChangeListener, ClientOptions, ConnectionState, ConnectionStateChange, ServiceSubscription, Unsubscribe } from "./types.ts";
|
|
4
|
+
type ServiceResult = JsonValue | undefined;
|
|
5
|
+
export declare class Client {
|
|
5
6
|
#private;
|
|
6
|
-
constructor(options:
|
|
7
|
+
constructor(options: ClientOptions);
|
|
7
8
|
get disposed(): boolean;
|
|
8
9
|
get connectionState(): ConnectionState;
|
|
9
10
|
get connected(): boolean;
|
|
10
|
-
get
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
get serverId(): string;
|
|
12
|
+
get hello(): ServerHello | undefined;
|
|
13
|
+
get attachment(): SessionTarget | undefined;
|
|
14
|
+
static connect(options: ClientOptions): Promise<Client>;
|
|
15
|
+
connect(): Promise<ServerHello>;
|
|
16
|
+
reconnect(): Promise<ServerHello>;
|
|
14
17
|
disconnect(reason?: string): void;
|
|
15
|
-
subscribe(listener: (snapshot: ServerSnapshot) => void): Unsubscribe;
|
|
16
|
-
onEvent(listener: (event: ServerEvent) => void): Unsubscribe;
|
|
17
18
|
onConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
onAttachmentChange(listener: AttachmentChangeListener): Unsubscribe;
|
|
20
|
+
/** Invoke one low-level protocol call against an explicit routed target. */
|
|
21
|
+
request(target: RpcTarget, call: ServiceCall, signal?: AbortSignal): Promise<ServiceResult>;
|
|
22
|
+
serviceCatalogue(target: RpcTarget, signal?: AbortSignal): Promise<readonly ServiceCatalogueEntry[]>;
|
|
23
|
+
subscribeService(target: RpcTarget, serviceId: string, mode: ServiceMode, listener: (update: ServiceProviderUpdate) => void | Promise<void>, signal?: AbortSignal): Promise<ServiceSubscription>;
|
|
22
24
|
dispose(): Promise<void>;
|
|
23
25
|
[Symbol.asyncDispose](): Promise<void>;
|
|
24
26
|
}
|
|
27
|
+
/** Adapts a lazily resolved routed client target to a Chord service transport. */
|
|
28
|
+
export declare function createClientServiceTransport(client: Client, getTarget: () => RpcTarget | undefined): RemoteServiceTransport;
|
|
29
|
+
export {};
|
|
25
30
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAQN,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,MAAM,6BAA6B,CAAC;AAWrC,OAAO,EACN,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EAIpB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EACX,eAAe,EACf,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,EACf,WAAW,EACX,MAAM,YAAY,CAAC;AAcpB,qBAAa,QAAQ;;IAiBpB,YAAY,OAAO,EAAE,eAAe,EAUnC;IAED,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,IAAI,eAAe,IAAI,eAAe,CAErC;IAED,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,QAAQ,IAAI,cAAc,GAAG,SAAS,CAEzC;IAED,OAAa,OAAO,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,CAAC,CAShE;IAED,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC,CAIjC;IAED,SAAS,IAAI,OAAO,CAAC,cAAc,CAAC,CAEnC;IAED,UAAU,CAAC,MAAM,SAAwB,GAAG,IAAI,CAE/C;IAED,SAAS,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,GAAG,WAAW,CAGnE;IAED,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,GAAG,WAAW,CAG3D;IAED,uBAAuB,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,GAAG,WAAW,CAItF;IAEK,YAAY,IAAI,OAAO,CAAC,SAAS,eAAe,EAAE,CAAC,CAExD;IAEK,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,eAAe,CAAC,CAIhF;IAEK,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAE/D;IAEK,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA0BhG;IAqKD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAWvB;IAED,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAErC;CA2ED","sourcesContent":["import {\n\ttype Command,\n\ttype CommandResult,\n\ttype EventEnvelope,\n\tencodeClientMessage,\n\tProtocolValidationError,\n\ttype ResponseEnvelope,\n\ttype ResultForCommand,\n\ttype ServerEvent,\n\ttype ServerSnapshot,\n\ttype SessionMetadata,\n} from \"@earendil-works/pi-protocol\";\nimport { Connection } from \"./connection.ts\";\nimport {\n\tPiClientDisposedError,\n\tPiDisconnectedError,\n\tPiServerError,\n\tPiSessionDetachedError,\n\tPiSessionOwnershipError,\n\ttoError,\n} from \"./errors.ts\";\nimport { createPromiseResolvers } from \"./promise.ts\";\nimport {\n\ttype AcquireSessionOptions,\n\ttype PiSessionHandle,\n\tSessionHandle,\n\ttype SessionHandleCallbacks,\n\ttype SessionLeaseMode,\n} from \"./session-handle.ts\";\nimport { ClientState } from \"./state.ts\";\nimport type {\n\tConnectionState,\n\tConnectionStateChange,\n\tCreateSessionOptions,\n\tPiClientOptions,\n\tUnsubscribe,\n} from \"./types.ts\";\n\ntype SessionLeaseState = \"active\" | \"releasing\" | \"released\" | \"invalidated\";\n\ninterface SessionLeaseToken {\n\treadonly mode: SessionLeaseMode;\n}\n\ninterface PendingRequest {\n\tcommand: Command;\n\tresolve(result: CommandResult): void;\n\treject(error: Error): void;\n}\n\nexport class PiClient {\n\treadonly #options: PiClientOptions;\n\treadonly #connection: Connection;\n\treadonly #state: ClientState;\n\treadonly #pendingRequests = new Map<string, PendingRequest>();\n\treadonly #sessionLeaseCounts = new Map<string, number>();\n\treadonly #exclusiveSessionLeases = new Map<string, SessionLeaseToken>();\n\treadonly #sessionLeaseGenerations = new Map<string, number>();\n\treadonly #sessionAttachments = new Map<string, Promise<void>>();\n\treadonly #sessionDetachments = new Map<string, Promise<void>>();\n\treadonly #sessionCleanupRequired = new Set<string>();\n\treadonly #sessionReconciliations = new Map<string, Promise<void>>();\n\treadonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>();\n\t#requestSequence = 0;\n\t#disposed = false;\n\t#disposePromise: Promise<void> | undefined;\n\n\tconstructor(options: PiClientOptions) {\n\t\tthis.#options = options;\n\t\tthis.#state = new ClientState(options.onListenerError);\n\t\tthis.#connection = new Connection({\n\t\t\ttransportFactory: options.transportFactory,\n\t\t\tmaxFrameLength: options.maxFrameLength,\n\t\t\tonHandshake: (snapshot) => this.#state.applyServerSnapshot(snapshot),\n\t\t\tonMessage: (message) => this.#handleMessage(message),\n\t\t\tonStateChange: (change) => this.#handleConnectionStateChange(change),\n\t\t});\n\t}\n\n\tget disposed(): boolean {\n\t\treturn this.#disposed;\n\t}\n\n\tget connectionState(): ConnectionState {\n\t\treturn this.#connection.state;\n\t}\n\n\tget connected(): boolean {\n\t\treturn this.#connection.state === \"connected\";\n\t}\n\n\tget snapshot(): ServerSnapshot | undefined {\n\t\treturn this.#state.snapshot;\n\t}\n\n\tstatic async connect(options: PiClientOptions): Promise<PiClient> {\n\t\tconst client = new PiClient(options);\n\t\ttry {\n\t\t\tawait client.connect();\n\t\t\treturn client;\n\t\t} catch (error) {\n\t\t\tawait client.dispose();\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tconnect(): Promise<ServerSnapshot> {\n\t\tif (this.#disposed) return Promise.reject(new PiClientDisposedError());\n\t\tif (this.#connection.state === \"disconnected\") this.#state.reset();\n\t\treturn this.#connection.connect();\n\t}\n\n\treconnect(): Promise<ServerSnapshot> {\n\t\treturn this.connect();\n\t}\n\n\tdisconnect(reason = \"Client disconnected\"): void {\n\t\tthis.#connection.disconnect(reason);\n\t}\n\n\tsubscribe(listener: (snapshot: ServerSnapshot) => void): Unsubscribe {\n\t\tthis.#assertNotDisposed();\n\t\treturn this.#state.subscribe(listener);\n\t}\n\n\tonEvent(listener: (event: ServerEvent) => void): Unsubscribe {\n\t\tthis.#assertNotDisposed();\n\t\treturn this.#state.onEvent(listener);\n\t}\n\n\tonConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe {\n\t\tthis.#assertNotDisposed();\n\t\tthis.#connectionStateListeners.add(listener);\n\t\treturn () => this.#connectionStateListeners.delete(listener);\n\t}\n\n\tasync listSessions(): Promise<readonly SessionMetadata[]> {\n\t\treturn (await this.#request({ command: \"list\" })).sessions;\n\t}\n\n\tasync createSession(options: CreateSessionOptions = {}): Promise<PiSessionHandle> {\n\t\tconst result = await this.#request({ command: \"create\", ...options });\n\t\tconst token = this.#reserveSessionLease(result.session.id, \"exclusive\");\n\t\treturn this.#createSessionLease(result.session.id, token);\n\t}\n\n\tasync attachSession(sessionId: string): Promise<PiSessionHandle> {\n\t\treturn this.acquireSession(sessionId, { mode: \"shared\" });\n\t}\n\n\tasync acquireSession(sessionId: string, options: AcquireSessionOptions): Promise<PiSessionHandle> {\n\t\tthis.#assertNotDisposed();\n\t\tconst token = this.#reserveSessionLease(sessionId, options.mode);\n\t\ttry {\n\t\t\tconst detachment = this.#sessionDetachments.get(sessionId);\n\t\t\tif (detachment) await detachment.catch(() => {});\n\t\t\tconst reconciled = this.#sessionCleanupRequired.has(sessionId)\n\t\t\t\t? await this.#reconcileSessionCleanup(sessionId)\n\t\t\t\t: false;\n\t\t\tif (reconciled || !this.#state.isSessionAttached(sessionId)) {\n\t\t\t\tlet attachment = this.#sessionAttachments.get(sessionId);\n\t\t\t\tif (!attachment) {\n\t\t\t\t\tattachment = this.#attachSession(sessionId);\n\t\t\t\t\tthis.#sessionAttachments.set(sessionId, attachment);\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tawait attachment;\n\t\t\t\t} finally {\n\t\t\t\t\tif (this.#sessionAttachments.get(sessionId) === attachment) this.#sessionAttachments.delete(sessionId);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn this.#createSessionLease(sessionId, token);\n\t\t} catch (error) {\n\t\t\tthis.#releaseSessionLease(sessionId, token);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tasync #attachSession(sessionId: string): Promise<void> {\n\t\tconst previous = this.#state.forgetSessionSnapshot(sessionId);\n\t\ttry {\n\t\t\tawait this.#request({ command: \"attach\", sessionId });\n\t\t} catch (error) {\n\t\t\tif (previous) this.#state.restoreSessionSnapshot(previous);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t#request<const TCommand extends Command>(command: TCommand): Promise<ResultForCommand<TCommand>> {\n\t\tif (this.#disposed) return Promise.reject(new PiClientDisposedError());\n\t\tif (!this.connected) return Promise.reject(new PiDisconnectedError());\n\t\tconst id = `request-${++this.#requestSequence}`;\n\t\tconst { promise, resolve, reject } = createPromiseResolvers<CommandResult>();\n\t\tthis.#pendingRequests.set(id, { command, resolve, reject });\n\t\tlet frame: Uint8Array;\n\t\ttry {\n\t\t\tframe = encodeClientMessage(\n\t\t\t\t{ type: \"request\", id, request: command },\n\t\t\t\t{ maxFrameLength: this.#connection.maxFrameLength },\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tthis.#takePendingRequest(id)?.reject(toError(error));\n\t\t\treturn promise as Promise<ResultForCommand<TCommand>>;\n\t\t}\n\t\tthis.#connection.send(frame);\n\t\treturn promise as Promise<ResultForCommand<TCommand>>;\n\t}\n\n\t#createSessionLease(sessionId: string, token: SessionLeaseToken): PiSessionHandle {\n\t\tconst generation = this.#sessionLeaseGenerations.get(sessionId) ?? 0;\n\t\tthis.#sessionLeaseGenerations.set(sessionId, generation);\n\t\tlet state: SessionLeaseState = \"active\";\n\t\tlet releasePromise: Promise<void> | undefined;\n\t\tconst refreshState = () => {\n\t\t\tif (\n\t\t\t\t(state === \"active\" || state === \"releasing\") &&\n\t\t\t\tthis.#sessionLeaseGenerations.get(sessionId) !== generation\n\t\t\t) {\n\t\t\t\tstate = \"invalidated\";\n\t\t\t}\n\t\t};\n\t\tconst isActive = () => {\n\t\t\trefreshState();\n\t\t\treturn state === \"active\" && this.#state.isSessionAttached(sessionId);\n\t\t};\n\t\tconst assertActive = () => {\n\t\t\tthis.#assertNotDisposed();\n\t\t\tif (!this.connected) throw new PiDisconnectedError();\n\t\t\tif (!isActive()) throw new PiSessionDetachedError(sessionId);\n\t\t};\n\t\tconst release = (relinquishOnFailure: boolean): Promise<void> => {\n\t\t\trefreshState();\n\t\t\tif (state === \"released\" || state === \"invalidated\") return Promise.resolve();\n\t\t\tif (releasePromise) return releasePromise;\n\t\t\tassertActive();\n\t\t\tstate = \"releasing\";\n\t\t\treleasePromise = (async () => {\n\t\t\t\tconst count = this.#sessionLeaseCounts.get(sessionId) ?? 0;\n\t\t\t\tif (count <= 1) {\n\t\t\t\t\tconst detachment = this.#request({ command: \"detach\", sessionId }).then(() => undefined);\n\t\t\t\t\tthis.#sessionDetachments.set(sessionId, detachment);\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait detachment;\n\t\t\t\t\t\tthis.#releaseSessionLease(sessionId, token);\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tif (this.#sessionDetachments.get(sessionId) === detachment) {\n\t\t\t\t\t\t\tthis.#sessionDetachments.delete(sessionId);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tthis.#releaseSessionLease(sessionId, token);\n\t\t\t\t}\n\t\t\t\tstate = \"released\";\n\t\t\t})().catch((error: unknown) => {\n\t\t\t\trefreshState();\n\t\t\t\tif (state === \"invalidated\") return;\n\t\t\t\tif (relinquishOnFailure) {\n\t\t\t\t\tthis.#releaseSessionLease(sessionId, token);\n\t\t\t\t\tthis.#sessionCleanupRequired.add(sessionId);\n\t\t\t\t\tstate = \"released\";\n\t\t\t\t} else {\n\t\t\t\t\tstate = \"active\";\n\t\t\t\t\treleasePromise = undefined;\n\t\t\t\t}\n\t\t\t\tthrow error;\n\t\t\t});\n\t\t\treturn releasePromise;\n\t\t};\n\t\tconst callbacks: SessionHandleCallbacks = {\n\t\t\tisAttached: isActive,\n\t\t\tgetSnapshot: () => (isActive() ? this.#state.getSessionSnapshot(sessionId) : undefined),\n\t\t\tsubscribe: (listener) => {\n\t\t\t\tassertActive();\n\t\t\t\treturn this.#state.subscribeSession(sessionId, (snapshot) => {\n\t\t\t\t\tif (isActive()) listener(snapshot);\n\t\t\t\t});\n\t\t\t},\n\t\t\tonEvent: (listener) => {\n\t\t\t\tassertActive();\n\t\t\t\treturn this.#state.onSessionEvent(sessionId, (event) => {\n\t\t\t\t\tif (isActive() || event.type === \"session_removed\") listener(event);\n\t\t\t\t});\n\t\t\t},\n\t\t\tdetach: () => release(false),\n\t\t\tdispose: () => release(true),\n\t\t\trequest: (command) => {\n\t\t\t\tassertActive();\n\t\t\t\treturn this.#request(command);\n\t\t\t},\n\t\t};\n\t\treturn new SessionHandle(sessionId, callbacks);\n\t}\n\n\t#handleMessage(message: ResponseEnvelope | EventEnvelope): void {\n\t\tif (message.type === \"event\") {\n\t\t\tif (message.event.type === \"session_removed\") this.#invalidateSessionLeases(message.event.sessionId);\n\t\t\tthis.#state.applyEvent(message.event);\n\t\t\treturn;\n\t\t}\n\t\tconst pending = this.#takePendingRequest(message.id);\n\t\tif (!pending) {\n\t\t\tthis.#connection.fail(new ProtocolValidationError(\"Response has no matching request\"));\n\t\t\treturn;\n\t\t}\n\t\tif (!message.ok) {\n\t\t\tpending.reject(new PiServerError(message.error));\n\t\t\treturn;\n\t\t}\n\t\tif (message.result.command !== pending.command.command) {\n\t\t\tconst error = new ProtocolValidationError(\n\t\t\t\t`Response command ${message.result.command} does not match ${pending.command.command}`,\n\t\t\t);\n\t\t\tpending.reject(error);\n\t\t\tthis.#connection.fail(error);\n\t\t\treturn;\n\t\t}\n\t\tthis.#state.applyResult(message.result);\n\t\tpending.resolve(message.result);\n\t}\n\n\t#handleConnectionStateChange(change: ConnectionStateChange): void {\n\t\tif (change.state === \"disconnected\") {\n\t\t\tthis.#state.clearAttachments();\n\t\t\tthis.#invalidateAllSessionLeases();\n\t\t\tthis.#rejectPendingRequests(change.error ?? new PiDisconnectedError());\n\t\t}\n\t\tthis.#notifyConnectionStateListeners(change);\n\t}\n\n\t#takePendingRequest(id: string): PendingRequest | undefined {\n\t\tconst request = this.#pendingRequests.get(id);\n\t\tif (request) this.#pendingRequests.delete(id);\n\t\treturn request;\n\t}\n\n\t#rejectPendingRequests(error: Error): void {\n\t\tconst requests = [...this.#pendingRequests.values()];\n\t\tthis.#pendingRequests.clear();\n\t\tfor (const request of requests) request.reject(error);\n\t}\n\n\tdispose(): Promise<void> {\n\t\tif (this.#disposePromise) return this.#disposePromise;\n\t\tthis.#disposed = true;\n\t\tthis.#disposePromise = Promise.resolve();\n\t\tconst error = new PiClientDisposedError();\n\t\tthis.#rejectPendingRequests(error);\n\t\tthis.#connection.disconnect(error);\n\t\tthis.#state.dispose();\n\t\tthis.#invalidateAllSessionLeases();\n\t\tthis.#connectionStateListeners.clear();\n\t\treturn this.#disposePromise;\n\t}\n\n\t[Symbol.asyncDispose](): Promise<void> {\n\t\treturn this.dispose();\n\t}\n\n\t#assertNotDisposed(): void {\n\t\tif (this.#disposed) throw new PiClientDisposedError();\n\t}\n\n\tasync #reconcileSessionCleanup(sessionId: string): Promise<boolean> {\n\t\tif (!this.#sessionCleanupRequired.has(sessionId)) return false;\n\t\tlet reconciliation = this.#sessionReconciliations.get(sessionId);\n\t\tif (!reconciliation) {\n\t\t\treconciliation = this.#request({ command: \"detach\", sessionId })\n\t\t\t\t.then(() => undefined)\n\t\t\t\t.then(() => {\n\t\t\t\t\tthis.#sessionCleanupRequired.delete(sessionId);\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tthis.#sessionReconciliations.delete(sessionId);\n\t\t\t\t});\n\t\t\tthis.#sessionReconciliations.set(sessionId, reconciliation);\n\t\t}\n\t\tawait reconciliation;\n\t\treturn true;\n\t}\n\n\t#reserveSessionLease(sessionId: string, mode: SessionLeaseMode): SessionLeaseToken {\n\t\tconst count = this.#sessionLeaseCounts.get(sessionId) ?? 0;\n\t\tif (mode === \"exclusive\" && count > 0) {\n\t\t\tthrow new PiSessionOwnershipError(sessionId, `Session ${sessionId} already has an active lease`);\n\t\t}\n\t\tif (mode === \"shared\" && this.#exclusiveSessionLeases.has(sessionId)) {\n\t\t\tthrow new PiSessionOwnershipError(sessionId, `Session ${sessionId} has an exclusive lease`);\n\t\t}\n\t\tconst token: SessionLeaseToken = { mode };\n\t\tthis.#sessionLeaseCounts.set(sessionId, count + 1);\n\t\tif (mode === \"exclusive\") this.#exclusiveSessionLeases.set(sessionId, token);\n\t\treturn token;\n\t}\n\n\t#releaseSessionLease(sessionId: string, token: SessionLeaseToken): void {\n\t\tconst count = this.#sessionLeaseCounts.get(sessionId) ?? 0;\n\t\tif (count <= 1) this.#sessionLeaseCounts.delete(sessionId);\n\t\telse this.#sessionLeaseCounts.set(sessionId, count - 1);\n\t\tif (this.#exclusiveSessionLeases.get(sessionId) === token) this.#exclusiveSessionLeases.delete(sessionId);\n\t}\n\n\t#invalidateSessionLeases(sessionId: string): void {\n\t\tthis.#sessionLeaseCounts.delete(sessionId);\n\t\tthis.#exclusiveSessionLeases.delete(sessionId);\n\t\tthis.#sessionCleanupRequired.delete(sessionId);\n\t\tthis.#sessionLeaseGenerations.set(sessionId, (this.#sessionLeaseGenerations.get(sessionId) ?? 0) + 1);\n\t}\n\n\t#invalidateAllSessionLeases(): void {\n\t\tfor (const sessionId of this.#sessionLeaseCounts.keys()) this.#invalidateSessionLeases(sessionId);\n\t\tthis.#sessionCleanupRequired.clear();\n\t}\n\n\t#notifyConnectionStateListeners(change: ConnectionStateChange): void {\n\t\tfor (const listener of this.#connectionStateListeners) {\n\t\t\ttry {\n\t\t\t\tlistener(change);\n\t\t\t} catch (error) {\n\t\t\t\tthis.#reportListenerError(error);\n\t\t\t}\n\t\t}\n\t}\n\n\t#reportListenerError(error: unknown): void {\n\t\tif (!this.#options.onListenerError) return;\n\t\ttry {\n\t\t\tthis.#options.onListenerError(toError(error));\n\t\t} catch {\n\t\t\t// Diagnostics cannot affect protocol or transport state.\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAKN,KAAK,SAAS,EAKd,KAAK,sBAAsB,EAC3B,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC1B,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAG1B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAMN,KAAK,SAAS,EACd,KAAK,WAAW,EAEhB,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AAIrC,OAAO,KAAK,EACX,wBAAwB,EACxB,aAAa,EACb,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,WAAW,EACX,MAAM,YAAY,CAAC;AAEpB,KAAK,aAAa,GAAG,SAAS,GAAG,SAAS,CAAC;AAmB3C,qBAAa,MAAM;;IAclB,YAAY,OAAO,EAAE,aAAa,EAejC;IAED,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,IAAI,eAAe,IAAI,eAAe,CAErC;IAED,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,IAAI,KAAK,IAAI,WAAW,GAAG,SAAS,CAEnC;IAED,IAAI,UAAU,IAAI,aAAa,GAAG,SAAS,CAE1C;IAED,OAAa,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAS5D;IAED,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC,CAI9B;IAED,SAAS,IAAI,OAAO,CAAC,WAAW,CAAC,CAEhC;IAED,UAAU,CAAC,MAAM,SAAwB,GAAG,IAAI,CAE/C;IAED,uBAAuB,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,GAAG,WAAW,CAItF;IAED,kBAAkB,CAAC,QAAQ,EAAE,wBAAwB,GAAG,WAAW,CAIlE;IAED,4EAA4E;IAC5E,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAE1F;IAEK,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,qBAAqB,EAAE,CAAC,CAWzG;IAEK,gBAAgB,CACrB,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EACjE,MAAM,CAAC,EAAE,WAAW,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA0D9B;IA+ID,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAavB;IAED,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAErC;CAiDD;AAED,kFAAkF;AAClF,wBAAgB,4BAA4B,CAC3C,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,SAAS,GAAG,SAAS,GACpC,sBAAsB,CAuBxB","sourcesContent":["import {\n\tcreateServiceCatalogueCall,\n\tcreateServiceStateDecoder,\n\tcreateServiceSubscribeCall,\n\tcreateServiceUnsubscribeCall,\n\ttype JsonValue,\n\tparseServiceCall,\n\tparseServiceCatalogue,\n\tparseWireServiceProviderUpdate,\n\tparseWireServiceSubscriptionSnapshot,\n\ttype RemoteServiceTransport,\n\ttype ServiceCall,\n\ttype ServiceCatalogueEntry,\n\ttype ServiceMode,\n\ttype ServiceProviderUpdate,\n\ttype ServiceStateDecoder,\n\ttype ServiceSubscriptionSnapshot,\n} from \"@earendil-works/chord\";\nimport { BACKGROUND_CONTEXT } from \"@earendil-works/chord/context\";\nimport {\n\ttype AttachmentEnvelope,\n\tencodeClientMessage,\n\tisServerId,\n\tProtocolValidationError,\n\ttype ResponseEnvelope,\n\ttype RpcTarget,\n\ttype ServerHello,\n\ttype ServiceEventEnvelope,\n\ttype SessionTarget,\n} from \"@earendil-works/pi-protocol\";\nimport { Connection } from \"./connection.ts\";\nimport { ClientDisposedError, DisconnectedError, ServerError, toError } from \"./errors.ts\";\nimport { createPromiseResolvers } from \"./promise.ts\";\nimport type {\n\tAttachmentChangeListener,\n\tClientOptions,\n\tConnectionState,\n\tConnectionStateChange,\n\tServiceSubscription,\n\tUnsubscribe,\n} from \"./types.ts\";\n\ntype ServiceResult = JsonValue | undefined;\n\ninterface PendingRequest {\n\tresolve(result: ServiceResult): void;\n\treject(error: Error): void;\n\tcleanup(): void;\n}\n\ninterface ActiveServiceListener {\n\treadonly target: RpcTarget;\n\treadonly listener: (update: ServiceProviderUpdate) => void | Promise<void>;\n\treadonly decoder: ServiceStateDecoder;\n\treadonly queuedWireUpdates: JsonValue[];\n\treadonly queued: ServiceProviderUpdate[];\n\tdeliveryTail: Promise<void>;\n\thydrated: boolean;\n\tready: boolean;\n}\n\nexport class Client {\n\treadonly #options: ClientOptions;\n\treadonly #connection: Connection;\n\treadonly #pendingRequests = new Map<string, PendingRequest>();\n\treadonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>();\n\treadonly #attachmentListeners = new Set<AttachmentChangeListener>();\n\treadonly #serviceListeners = new Map<string, ActiveServiceListener>();\n\t#requestSequence = 0;\n\t#serviceSubscriptionSequence = 0;\n\t#hello: ServerHello | undefined;\n\t#attachment: SessionTarget | undefined;\n\t#disposed = false;\n\t#disposePromise: Promise<void> | undefined;\n\n\tconstructor(options: ClientOptions) {\n\t\tif (!isServerId(options.serverId)) {\n\t\t\tthrow new TypeError(\"serverId must be a canonical lowercase UUIDv4\");\n\t\t}\n\t\tthis.#options = options;\n\t\tthis.#connection = new Connection({\n\t\t\ttransportFactory: options.transportFactory,\n\t\t\tserverId: options.serverId,\n\t\t\tmaxFrameLength: options.maxFrameLength,\n\t\t\tonHandshake: (hello) => {\n\t\t\t\tthis.#hello = hello;\n\t\t\t},\n\t\t\tonMessage: (message) => this.#handleMessage(message),\n\t\t\tonStateChange: (change) => this.#handleConnectionStateChange(change),\n\t\t});\n\t}\n\n\tget disposed(): boolean {\n\t\treturn this.#disposed;\n\t}\n\n\tget connectionState(): ConnectionState {\n\t\treturn this.#connection.state;\n\t}\n\n\tget connected(): boolean {\n\t\treturn this.#connection.state === \"connected\";\n\t}\n\n\tget serverId(): string {\n\t\treturn this.#options.serverId;\n\t}\n\n\tget hello(): ServerHello | undefined {\n\t\treturn this.#hello;\n\t}\n\n\tget attachment(): SessionTarget | undefined {\n\t\treturn this.#attachment;\n\t}\n\n\tstatic async connect(options: ClientOptions): Promise<Client> {\n\t\tconst client = new Client(options);\n\t\ttry {\n\t\t\tawait client.connect();\n\t\t\treturn client;\n\t\t} catch (error) {\n\t\t\tawait client.dispose();\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tconnect(): Promise<ServerHello> {\n\t\tif (this.#disposed) return Promise.reject(new ClientDisposedError());\n\t\tthis.#hello = undefined;\n\t\treturn this.#connection.connect();\n\t}\n\n\treconnect(): Promise<ServerHello> {\n\t\treturn this.connect();\n\t}\n\n\tdisconnect(reason = \"Client disconnected\"): void {\n\t\tthis.#connection.disconnect(reason);\n\t}\n\n\tonConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe {\n\t\tthis.#assertNotDisposed();\n\t\tthis.#connectionStateListeners.add(listener);\n\t\treturn () => this.#connectionStateListeners.delete(listener);\n\t}\n\n\tonAttachmentChange(listener: AttachmentChangeListener): Unsubscribe {\n\t\tthis.#assertNotDisposed();\n\t\tthis.#attachmentListeners.add(listener);\n\t\treturn () => this.#attachmentListeners.delete(listener);\n\t}\n\n\t/** Invoke one low-level protocol call against an explicit routed target. */\n\trequest(target: RpcTarget, call: ServiceCall, signal?: AbortSignal): Promise<ServiceResult> {\n\t\treturn this.#request(target, call, signal);\n\t}\n\n\tasync serviceCatalogue(target: RpcTarget, signal?: AbortSignal): Promise<readonly ServiceCatalogueEntry[]> {\n\t\tconst result = await this.#request(target, createServiceCatalogueCall(), signal);\n\t\ttry {\n\t\t\treturn parseServiceCatalogue(result);\n\t\t} catch (error) {\n\t\t\tconst validationError = new ProtocolValidationError(\n\t\t\t\terror instanceof Error ? error.message : \"Invalid service catalogue\",\n\t\t\t);\n\t\t\tthis.#connection.fail(validationError);\n\t\t\tthrow validationError;\n\t\t}\n\t}\n\n\tasync subscribeService(\n\t\ttarget: RpcTarget,\n\t\tserviceId: string,\n\t\tmode: ServiceMode,\n\t\tlistener: (update: ServiceProviderUpdate) => void | Promise<void>,\n\t\tsignal?: AbortSignal,\n\t): Promise<ServiceSubscription> {\n\t\tconst subscriptionId = `service-${++this.#serviceSubscriptionSequence}`;\n\t\tconst active: ActiveServiceListener = {\n\t\t\ttarget,\n\t\t\tlistener,\n\t\t\tdecoder: createServiceStateDecoder(),\n\t\t\tqueuedWireUpdates: [],\n\t\t\tqueued: [],\n\t\t\tdeliveryTail: Promise.resolve(),\n\t\t\thydrated: false,\n\t\t\tready: false,\n\t\t};\n\t\tthis.#serviceListeners.set(subscriptionId, active);\n\t\tlet snapshot: ServiceSubscriptionSnapshot;\n\t\ttry {\n\t\t\tsnapshot = await this.#request(\n\t\t\t\ttarget,\n\t\t\t\tcreateServiceSubscribeCall(subscriptionId, serviceId, mode),\n\t\t\t\tsignal,\n\t\t\t\t(result) => {\n\t\t\t\t\tconst decoded = active.decoder.decodeSnapshot(parseWireServiceSubscriptionSnapshot(result));\n\t\t\t\t\tactive.hydrated = true;\n\t\t\t\t\tfor (const update of active.queuedWireUpdates.splice(0)) {\n\t\t\t\t\t\tactive.queued.push(active.decoder.decodeUpdate(parseWireServiceProviderUpdate(update)));\n\t\t\t\t\t}\n\t\t\t\t\treturn decoded;\n\t\t\t\t},\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tif (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);\n\t\t\tthrow error;\n\t\t}\n\t\tif (this.#serviceListeners.get(subscriptionId) !== active) throw new DisconnectedError();\n\t\tlet disposed = false;\n\t\treturn {\n\t\t\tid: subscriptionId,\n\t\t\ttarget,\n\t\t\tsnapshot,\n\t\t\tstart: () => {\n\t\t\t\tif (disposed || active.ready) return;\n\t\t\t\tactive.ready = true;\n\t\t\t\tfor (const update of active.queued.splice(0)) this.#deliverServiceUpdate(active, update);\n\t\t\t},\n\t\t\tdispose: async () => {\n\t\t\t\tif (disposed) return;\n\t\t\t\tdisposed = true;\n\t\t\t\tif (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);\n\t\t\t\ttry {\n\t\t\t\t\tif (this.connected && this.#targetIsCurrent(target)) {\n\t\t\t\t\t\tawait this.#request(target, createServiceUnsubscribeCall(subscriptionId));\n\t\t\t\t\t}\n\t\t\t\t\tawait active.deliveryTail;\n\t\t\t\t} finally {\n\t\t\t\t\tactive.queuedWireUpdates.length = 0;\n\t\t\t\t\tactive.queued.length = 0;\n\t\t\t\t}\n\t\t\t},\n\t\t};\n\t}\n\n\t#request<T = ServiceResult>(\n\t\ttarget: RpcTarget,\n\t\tcall: ServiceCall,\n\t\tsignal?: AbortSignal,\n\t\ttransform?: (result: ServiceResult) => T,\n\t): Promise<T> {\n\t\tif (this.#disposed) return Promise.reject(new ClientDisposedError());\n\t\tif (!this.connected) return Promise.reject(new DisconnectedError());\n\t\tif (signal?.aborted) return Promise.reject(abortError(signal));\n\t\tconst id = `request-${++this.#requestSequence}`;\n\t\tconst { promise, resolve, reject } = createPromiseResolvers<T>();\n\t\tlet sent = false;\n\t\tlet aborted = false;\n\t\tlet onAbort: (() => void) | undefined;\n\t\tconst sendCancel = (): void => {\n\t\t\tif (!sent || !this.connected) return;\n\t\t\ttry {\n\t\t\t\tthis.#connection.send(\n\t\t\t\t\tencodeClientMessage({ type: \"cancel\", id, target }, { maxFrameLength: this.#connection.maxFrameLength }),\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tthis.#connection.fail(toError(error));\n\t\t\t}\n\t\t};\n\t\tif (signal !== undefined) {\n\t\t\tonAbort = () => {\n\t\t\t\tif (aborted) return;\n\t\t\t\taborted = true;\n\t\t\t\treject(abortError(signal));\n\t\t\t\tsendCancel();\n\t\t\t};\n\t\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\t}\n\t\tthis.#pendingRequests.set(id, {\n\t\t\tresolve: (result) => {\n\t\t\t\ttry {\n\t\t\t\t\tresolve(transform === undefined ? (result as T) : transform(result));\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconst validationError = new ProtocolValidationError(\n\t\t\t\t\t\terror instanceof Error ? error.message : \"Invalid service operation stream\",\n\t\t\t\t\t);\n\t\t\t\t\tthis.#connection.fail(validationError);\n\t\t\t\t\treject(validationError);\n\t\t\t\t}\n\t\t\t},\n\t\t\treject,\n\t\t\tcleanup: () => {\n\t\t\t\tif (signal !== undefined && onAbort !== undefined) signal.removeEventListener(\"abort\", onAbort);\n\t\t\t},\n\t\t});\n\t\tlet frame: Uint8Array;\n\t\ttry {\n\t\t\tframe = encodeClientMessage(\n\t\t\t\t{ type: \"request\", id, target, call: parseServiceCall(call) as unknown as JsonValue },\n\t\t\t\t{ maxFrameLength: this.#connection.maxFrameLength },\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tthis.#takePendingRequest(id)?.reject(toError(error));\n\t\t\treturn promise;\n\t\t}\n\t\tthis.#connection.send(frame);\n\t\tsent = true;\n\t\tif (aborted) sendCancel();\n\t\treturn promise;\n\t}\n\n\t#handleMessage(message: ResponseEnvelope | ServiceEventEnvelope | AttachmentEnvelope): void {\n\t\tif (message.type === \"attachment\") {\n\t\t\tif (message.attachment !== null && message.attachment.serverId !== this.#options.serverId) {\n\t\t\t\tthis.#connection.fail(new ProtocolValidationError(\"Attachment update belongs to another server\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.#setAttachment(message.attachment ?? undefined);\n\t\t\treturn;\n\t\t}\n\t\tif (message.type === \"service_update\") {\n\t\t\tconst active = this.#serviceListeners.get(message.subscriptionId);\n\t\t\tif (active === undefined) return;\n\t\t\tif (!active.hydrated) {\n\t\t\t\tactive.queuedWireUpdates.push(message.update);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlet update: ServiceProviderUpdate;\n\t\t\ttry {\n\t\t\t\tupdate = active.decoder.decodeUpdate(parseWireServiceProviderUpdate(message.update));\n\t\t\t} catch (error) {\n\t\t\t\tthis.#connection.fail(\n\t\t\t\t\tnew ProtocolValidationError(error instanceof Error ? error.message : \"Invalid service operation stream\"),\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (active.ready) this.#deliverServiceUpdate(active, update);\n\t\t\telse active.queued.push(update);\n\t\t\treturn;\n\t\t}\n\t\tconst pending = this.#takePendingRequest(message.id);\n\t\tif (!pending) {\n\t\t\tthis.#connection.fail(new ProtocolValidationError(\"Response has no matching request\"));\n\t\t\treturn;\n\t\t}\n\t\tif (!message.ok) {\n\t\t\tpending.reject(new ServerError(message.error));\n\t\t\treturn;\n\t\t}\n\t\tpending.resolve(message.result);\n\t}\n\n\t#handleConnectionStateChange(change: ConnectionStateChange): void {\n\t\tif (change.state === \"disconnected\") {\n\t\t\tthis.#hello = undefined;\n\t\t\tthis.#setAttachment(undefined);\n\t\t\tthis.#rejectPendingRequests(change.error ?? new DisconnectedError());\n\t\t\tthis.#serviceListeners.clear();\n\t\t}\n\t\tfor (const listener of this.#connectionStateListeners) {\n\t\t\ttry {\n\t\t\t\tlistener(change);\n\t\t\t} catch (error) {\n\t\t\t\tthis.#reportListenerError(error);\n\t\t\t}\n\t\t}\n\t}\n\n\t#takePendingRequest(id: string): PendingRequest | undefined {\n\t\tconst request = this.#pendingRequests.get(id);\n\t\tif (request) {\n\t\t\tthis.#pendingRequests.delete(id);\n\t\t\trequest.cleanup();\n\t\t}\n\t\treturn request;\n\t}\n\n\t#rejectPendingRequests(error: Error): void {\n\t\tconst requests = [...this.#pendingRequests.values()];\n\t\tthis.#pendingRequests.clear();\n\t\tfor (const request of requests) {\n\t\t\trequest.cleanup();\n\t\t\trequest.reject(error);\n\t\t}\n\t}\n\n\tdispose(): Promise<void> {\n\t\tif (this.#disposePromise) return this.#disposePromise;\n\t\tthis.#disposed = true;\n\t\tthis.#disposePromise = Promise.resolve();\n\t\tconst error = new ClientDisposedError();\n\t\tthis.#rejectPendingRequests(error);\n\t\tthis.#connection.disconnect(error);\n\t\tthis.#hello = undefined;\n\t\tthis.#setAttachment(undefined);\n\t\tthis.#connectionStateListeners.clear();\n\t\tthis.#attachmentListeners.clear();\n\t\tthis.#serviceListeners.clear();\n\t\treturn this.#disposePromise;\n\t}\n\n\t[Symbol.asyncDispose](): Promise<void> {\n\t\treturn this.dispose();\n\t}\n\n\t#setAttachment(attachment: SessionTarget | undefined): void {\n\t\tconst previous = this.#attachment;\n\t\tif (\n\t\t\tprevious?.serverId === attachment?.serverId &&\n\t\t\tprevious?.sessionId === attachment?.sessionId &&\n\t\t\tprevious?.attachmentId === attachment?.attachmentId\n\t\t) {\n\t\t\treturn;\n\t\t}\n\t\tthis.#attachment = attachment;\n\t\tfor (const listener of this.#attachmentListeners) {\n\t\t\ttry {\n\t\t\t\tlistener(attachment);\n\t\t\t} catch (error) {\n\t\t\t\tthis.#reportListenerError(error);\n\t\t\t}\n\t\t}\n\t}\n\n\t#deliverServiceUpdate(active: ActiveServiceListener, update: ServiceProviderUpdate): void {\n\t\tactive.deliveryTail = active.deliveryTail\n\t\t\t.then(() => active.listener(update))\n\t\t\t.catch((error: unknown) => this.#reportListenerError(error));\n\t}\n\n\t#targetIsCurrent(target: RpcTarget): boolean {\n\t\tif (!(\"sessionId\" in target)) return this.#hello?.serverId === target.serverId;\n\t\tconst attachment = this.#attachment;\n\t\treturn (\n\t\t\tattachment?.serverId === target.serverId &&\n\t\t\tattachment.sessionId === target.sessionId &&\n\t\t\tattachment.attachmentId === target.attachmentId\n\t\t);\n\t}\n\n\t#assertNotDisposed(): void {\n\t\tif (this.#disposed) throw new ClientDisposedError();\n\t}\n\n\t#reportListenerError(error: unknown): void {\n\t\tif (!this.#options.onListenerError) return;\n\t\ttry {\n\t\t\tthis.#options.onListenerError(toError(error));\n\t\t} catch {\n\t\t\t// Diagnostics cannot affect protocol or transport state.\n\t\t}\n\t}\n}\n\n/** Adapts a lazily resolved routed client target to a Chord service transport. */\nexport function createClientServiceTransport(\n\tclient: Client,\n\tgetTarget: () => RpcTarget | undefined,\n): RemoteServiceTransport {\n\tconst target = (): RpcTarget => {\n\t\tconst resolved = getTarget();\n\t\tif (resolved === undefined) throw new Error(\"Remote service target is unavailable\");\n\t\treturn resolved;\n\t};\n\treturn {\n\t\tinvoke: async (call, context) => client.request(target(), call, context.abortSignal),\n\t\tasync subscribe(serviceId, mode, listener, context) {\n\t\t\tconst subscription = await client.subscribeService(\n\t\t\t\ttarget(),\n\t\t\t\tserviceId,\n\t\t\t\tmode,\n\t\t\t\t(update) => listener(update, BACKGROUND_CONTEXT),\n\t\t\t\tcontext.abortSignal,\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tsnapshot: subscription.snapshot,\n\t\t\t\tactivate: () => subscription.start(),\n\t\t\t\tclose: () => subscription.dispose(),\n\t\t\t};\n\t\t},\n\t};\n}\n\nfunction abortError(signal: AbortSignal): Error {\n\tconst reason: unknown = signal.reason;\n\treturn reason instanceof Error ? reason : new DOMException(\"The operation was aborted\", \"AbortError\");\n}\n"]}
|