@earendil-works/pi-client 0.84.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 ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.84.0] - 2026-08-06
6
+
7
+ ### Breaking Changes
8
+
9
+ - Replaced `SessionSummary` with durable `SessionMetadata` for `PiClient.listSessions()` and server snapshots; runtime state is available only from acquired session snapshots ([#7708](https://github.com/earendil-works/pi/pull/7708)).
10
+
11
+ ### Added
12
+
13
+ - Added the experimental transport-neutral `PiClient` and multi-session `PiSessionHandle` APIs with structured `PiServerError` responses.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @earendil-works/pi-client
2
+
3
+ Transport-neutral client for remote pi sessions. `PiClient` exchanges length-prefixed CBOR messages through a small `ByteTransport` interface. The package has no Node-specific imports.
4
+
5
+ ```ts
6
+ import { PiClient, type ByteTransportFactory } from "@earendil-works/pi-client";
7
+
8
+ const transportFactory: ByteTransportFactory = async (handlers) => {
9
+ // Connect using WebSocket, Unix socket, or another ordered byte transport.
10
+ return {
11
+ async send(chunk) {
12
+ // Deliver chunks in invocation order and honor backpressure.
13
+ },
14
+ close() {},
15
+ };
16
+ };
17
+
18
+ const client = new PiClient({ transportFactory });
19
+ await client.connect();
20
+ const session = await client.createSession({ cwd: "/workspace" });
21
+ const unsubscribe = session.subscribe((snapshot) => render(snapshot));
22
+ await session.prompt("Inspect this project");
23
+ unsubscribe();
24
+ ```
25
+
26
+ Call `handlers.onData(chunk)` for inbound bytes, `handlers.onClose()` for an orderly terminal close, and `handlers.onError(error)` for transport failures. A factory must create a fresh transport for every connection attempt and complete any transport-specific authentication before resolving. For example, a WebSocket factory can provide credentials in its upgrade request.
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.
29
+
30
+ `acquireSession()` returns an independent `SessionLease`; leases cannot be constructed directly. Use `{ mode: "exclusive" }` for a lifecycle or mutation coordinator and `{ mode: "shared" }` when multiple low-level consumers intentionally share the session. Exclusive acquisition fails with `PiSessionOwnershipError` while any lease exists, and shared acquisition fails while an exclusive lease exists. `attachSession()` is a shared-acquisition convenience method. `createSession()` returns an exclusive lease for the newly created session.
31
+
32
+ Calling `dispose()` or `detach()` releases only that lease. A lease rejects commands as soon as release begins. The client sends the protocol detach request after the final lease is released. If explicit `detach()` fails, the lease becomes active again for retry. If cleanup-oriented `dispose()` fails, it reports the protocol error but relinquishes local ownership; `PiClient` reconciles the failed protocol cleanup before the next acquisition. A released lease becomes unavailable without affecting other shared leases. Server removal or disconnection invalidates every lease for the affected attachment, and disposing an invalidated lease is a no-op. Commands fail with `PiDisconnectedError` while the client is disconnected and `PiSessionDetachedError` when the client is connected but a lease is releasing, released, or invalidated. Leases implement `AsyncDisposable`.
33
+
34
+ `subscribe()` observes authoritative snapshots. `onEvent()` observes protocol events. Both return an unsubscribe function. Structured errors returned by the server are exposed as `PiServerError`.
35
+
36
+ ## Limits and security
37
+
38
+ `PiClientOptions.maxFrameLength` bounds inbound and outbound CBOR payloads. Configure matching limits on the client and server. Transports should separately bound queued outbound bytes and preserve send order.
39
+
40
+ Treat peers as untrusted. Use a secure transport with appropriate access controls and authenticate during transport establishment.
41
+
42
+ Subscriber exceptions are isolated from protocol state. Set `onListenerError` in `PiClientOptions` to report them to application logging or diagnostics.
43
+
44
+ ## Unix-domain sockets
45
+
46
+ Node.js and Bun consumers can use the separately exported Unix-domain socket transport:
47
+
48
+ ```ts
49
+ import { PiClient } from "@earendil-works/pi-client";
50
+ import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
51
+
52
+ const client = new PiClient({
53
+ transportFactory: createUnixTransportFactory({
54
+ path: "/tmp/pi.sock",
55
+ }),
56
+ });
57
+
58
+ await client.connect();
59
+ ```
60
+
61
+ `maxPendingBytes` bounds queued outbound data. It defaults to four times the protocol frame limit. The transport preserves send order and waits for socket backpressure before resolving each send.
62
+
63
+ The `@earendil-works/pi-client` root remains transport- and runtime-neutral. Importing the Node-compatible transport requires the explicit `@earendil-works/pi-client/unix` subpath.
@@ -0,0 +1,25 @@
1
+ import { type ServerEvent, type ServerSnapshot, type SessionMetadata } from "@earendil-works/pi-protocol";
2
+ import { type AcquireSessionOptions, type PiSessionHandle } from "./session-handle.ts";
3
+ import type { ConnectionState, ConnectionStateChange, CreateSessionOptions, PiClientOptions, Unsubscribe } from "./types.ts";
4
+ export declare class PiClient {
5
+ #private;
6
+ constructor(options: PiClientOptions);
7
+ get disposed(): boolean;
8
+ get connectionState(): ConnectionState;
9
+ get connected(): boolean;
10
+ get snapshot(): ServerSnapshot | undefined;
11
+ static connect(options: PiClientOptions): Promise<PiClient>;
12
+ connect(): Promise<ServerSnapshot>;
13
+ reconnect(): Promise<ServerSnapshot>;
14
+ disconnect(reason?: string): void;
15
+ subscribe(listener: (snapshot: ServerSnapshot) => void): Unsubscribe;
16
+ onEvent(listener: (event: ServerEvent) => void): Unsubscribe;
17
+ onConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe;
18
+ listSessions(): Promise<readonly SessionMetadata[]>;
19
+ createSession(options?: CreateSessionOptions): Promise<PiSessionHandle>;
20
+ attachSession(sessionId: string): Promise<PiSessionHandle>;
21
+ acquireSession(sessionId: string, options: AcquireSessionOptions): Promise<PiSessionHandle>;
22
+ dispose(): Promise<void>;
23
+ [Symbol.asyncDispose](): Promise<void>;
24
+ }
25
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +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"]}
package/dist/client.js ADDED
@@ -0,0 +1,385 @@
1
+ import { encodeClientMessage, ProtocolValidationError, } from "@earendil-works/pi-protocol";
2
+ import { Connection } from "./connection.js";
3
+ import { PiClientDisposedError, PiDisconnectedError, PiServerError, PiSessionDetachedError, PiSessionOwnershipError, toError, } from "./errors.js";
4
+ import { createPromiseResolvers } from "./promise.js";
5
+ import { SessionHandle, } from "./session-handle.js";
6
+ import { ClientState } from "./state.js";
7
+ export class PiClient {
8
+ #options;
9
+ #connection;
10
+ #state;
11
+ #pendingRequests = new Map();
12
+ #sessionLeaseCounts = new Map();
13
+ #exclusiveSessionLeases = new Map();
14
+ #sessionLeaseGenerations = new Map();
15
+ #sessionAttachments = new Map();
16
+ #sessionDetachments = new Map();
17
+ #sessionCleanupRequired = new Set();
18
+ #sessionReconciliations = new Map();
19
+ #connectionStateListeners = new Set();
20
+ #requestSequence = 0;
21
+ #disposed = false;
22
+ #disposePromise;
23
+ constructor(options) {
24
+ this.#options = options;
25
+ this.#state = new ClientState(options.onListenerError);
26
+ this.#connection = new Connection({
27
+ transportFactory: options.transportFactory,
28
+ maxFrameLength: options.maxFrameLength,
29
+ onHandshake: (snapshot) => this.#state.applyServerSnapshot(snapshot),
30
+ onMessage: (message) => this.#handleMessage(message),
31
+ onStateChange: (change) => this.#handleConnectionStateChange(change),
32
+ });
33
+ }
34
+ get disposed() {
35
+ return this.#disposed;
36
+ }
37
+ get connectionState() {
38
+ return this.#connection.state;
39
+ }
40
+ get connected() {
41
+ return this.#connection.state === "connected";
42
+ }
43
+ get snapshot() {
44
+ return this.#state.snapshot;
45
+ }
46
+ static async connect(options) {
47
+ const client = new PiClient(options);
48
+ try {
49
+ await client.connect();
50
+ return client;
51
+ }
52
+ catch (error) {
53
+ await client.dispose();
54
+ throw error;
55
+ }
56
+ }
57
+ connect() {
58
+ if (this.#disposed)
59
+ return Promise.reject(new PiClientDisposedError());
60
+ if (this.#connection.state === "disconnected")
61
+ this.#state.reset();
62
+ return this.#connection.connect();
63
+ }
64
+ reconnect() {
65
+ return this.connect();
66
+ }
67
+ disconnect(reason = "Client disconnected") {
68
+ this.#connection.disconnect(reason);
69
+ }
70
+ subscribe(listener) {
71
+ this.#assertNotDisposed();
72
+ return this.#state.subscribe(listener);
73
+ }
74
+ onEvent(listener) {
75
+ this.#assertNotDisposed();
76
+ return this.#state.onEvent(listener);
77
+ }
78
+ onConnectionStateChange(listener) {
79
+ this.#assertNotDisposed();
80
+ this.#connectionStateListeners.add(listener);
81
+ return () => this.#connectionStateListeners.delete(listener);
82
+ }
83
+ async listSessions() {
84
+ return (await this.#request({ command: "list" })).sessions;
85
+ }
86
+ async createSession(options = {}) {
87
+ const result = await this.#request({ command: "create", ...options });
88
+ const token = this.#reserveSessionLease(result.session.id, "exclusive");
89
+ return this.#createSessionLease(result.session.id, token);
90
+ }
91
+ async attachSession(sessionId) {
92
+ return this.acquireSession(sessionId, { mode: "shared" });
93
+ }
94
+ async acquireSession(sessionId, options) {
95
+ this.#assertNotDisposed();
96
+ const token = this.#reserveSessionLease(sessionId, options.mode);
97
+ try {
98
+ const detachment = this.#sessionDetachments.get(sessionId);
99
+ if (detachment)
100
+ await detachment.catch(() => { });
101
+ const reconciled = this.#sessionCleanupRequired.has(sessionId)
102
+ ? await this.#reconcileSessionCleanup(sessionId)
103
+ : false;
104
+ if (reconciled || !this.#state.isSessionAttached(sessionId)) {
105
+ let attachment = this.#sessionAttachments.get(sessionId);
106
+ if (!attachment) {
107
+ attachment = this.#attachSession(sessionId);
108
+ this.#sessionAttachments.set(sessionId, attachment);
109
+ }
110
+ try {
111
+ await attachment;
112
+ }
113
+ finally {
114
+ if (this.#sessionAttachments.get(sessionId) === attachment)
115
+ this.#sessionAttachments.delete(sessionId);
116
+ }
117
+ }
118
+ return this.#createSessionLease(sessionId, token);
119
+ }
120
+ catch (error) {
121
+ this.#releaseSessionLease(sessionId, token);
122
+ throw error;
123
+ }
124
+ }
125
+ async #attachSession(sessionId) {
126
+ const previous = this.#state.forgetSessionSnapshot(sessionId);
127
+ try {
128
+ await this.#request({ command: "attach", sessionId });
129
+ }
130
+ catch (error) {
131
+ if (previous)
132
+ this.#state.restoreSessionSnapshot(previous);
133
+ throw error;
134
+ }
135
+ }
136
+ #request(command) {
137
+ if (this.#disposed)
138
+ return Promise.reject(new PiClientDisposedError());
139
+ if (!this.connected)
140
+ return Promise.reject(new PiDisconnectedError());
141
+ const id = `request-${++this.#requestSequence}`;
142
+ const { promise, resolve, reject } = createPromiseResolvers();
143
+ this.#pendingRequests.set(id, { command, resolve, reject });
144
+ let frame;
145
+ try {
146
+ frame = encodeClientMessage({ type: "request", id, request: command }, { maxFrameLength: this.#connection.maxFrameLength });
147
+ }
148
+ catch (error) {
149
+ this.#takePendingRequest(id)?.reject(toError(error));
150
+ return promise;
151
+ }
152
+ this.#connection.send(frame);
153
+ return promise;
154
+ }
155
+ #createSessionLease(sessionId, token) {
156
+ const generation = this.#sessionLeaseGenerations.get(sessionId) ?? 0;
157
+ this.#sessionLeaseGenerations.set(sessionId, generation);
158
+ let state = "active";
159
+ let releasePromise;
160
+ const refreshState = () => {
161
+ if ((state === "active" || state === "releasing") &&
162
+ this.#sessionLeaseGenerations.get(sessionId) !== generation) {
163
+ state = "invalidated";
164
+ }
165
+ };
166
+ const isActive = () => {
167
+ refreshState();
168
+ return state === "active" && this.#state.isSessionAttached(sessionId);
169
+ };
170
+ const assertActive = () => {
171
+ this.#assertNotDisposed();
172
+ if (!this.connected)
173
+ throw new PiDisconnectedError();
174
+ if (!isActive())
175
+ throw new PiSessionDetachedError(sessionId);
176
+ };
177
+ const release = (relinquishOnFailure) => {
178
+ refreshState();
179
+ if (state === "released" || state === "invalidated")
180
+ return Promise.resolve();
181
+ if (releasePromise)
182
+ return releasePromise;
183
+ assertActive();
184
+ state = "releasing";
185
+ releasePromise = (async () => {
186
+ const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
187
+ if (count <= 1) {
188
+ const detachment = this.#request({ command: "detach", sessionId }).then(() => undefined);
189
+ this.#sessionDetachments.set(sessionId, detachment);
190
+ try {
191
+ await detachment;
192
+ this.#releaseSessionLease(sessionId, token);
193
+ }
194
+ finally {
195
+ if (this.#sessionDetachments.get(sessionId) === detachment) {
196
+ this.#sessionDetachments.delete(sessionId);
197
+ }
198
+ }
199
+ }
200
+ else {
201
+ this.#releaseSessionLease(sessionId, token);
202
+ }
203
+ state = "released";
204
+ })().catch((error) => {
205
+ refreshState();
206
+ if (state === "invalidated")
207
+ return;
208
+ if (relinquishOnFailure) {
209
+ this.#releaseSessionLease(sessionId, token);
210
+ this.#sessionCleanupRequired.add(sessionId);
211
+ state = "released";
212
+ }
213
+ else {
214
+ state = "active";
215
+ releasePromise = undefined;
216
+ }
217
+ throw error;
218
+ });
219
+ return releasePromise;
220
+ };
221
+ const callbacks = {
222
+ isAttached: isActive,
223
+ getSnapshot: () => (isActive() ? this.#state.getSessionSnapshot(sessionId) : undefined),
224
+ subscribe: (listener) => {
225
+ assertActive();
226
+ return this.#state.subscribeSession(sessionId, (snapshot) => {
227
+ if (isActive())
228
+ listener(snapshot);
229
+ });
230
+ },
231
+ onEvent: (listener) => {
232
+ assertActive();
233
+ return this.#state.onSessionEvent(sessionId, (event) => {
234
+ if (isActive() || event.type === "session_removed")
235
+ listener(event);
236
+ });
237
+ },
238
+ detach: () => release(false),
239
+ dispose: () => release(true),
240
+ request: (command) => {
241
+ assertActive();
242
+ return this.#request(command);
243
+ },
244
+ };
245
+ return new SessionHandle(sessionId, callbacks);
246
+ }
247
+ #handleMessage(message) {
248
+ if (message.type === "event") {
249
+ if (message.event.type === "session_removed")
250
+ this.#invalidateSessionLeases(message.event.sessionId);
251
+ this.#state.applyEvent(message.event);
252
+ return;
253
+ }
254
+ const pending = this.#takePendingRequest(message.id);
255
+ if (!pending) {
256
+ this.#connection.fail(new ProtocolValidationError("Response has no matching request"));
257
+ return;
258
+ }
259
+ if (!message.ok) {
260
+ pending.reject(new PiServerError(message.error));
261
+ return;
262
+ }
263
+ if (message.result.command !== pending.command.command) {
264
+ const error = new ProtocolValidationError(`Response command ${message.result.command} does not match ${pending.command.command}`);
265
+ pending.reject(error);
266
+ this.#connection.fail(error);
267
+ return;
268
+ }
269
+ this.#state.applyResult(message.result);
270
+ pending.resolve(message.result);
271
+ }
272
+ #handleConnectionStateChange(change) {
273
+ if (change.state === "disconnected") {
274
+ this.#state.clearAttachments();
275
+ this.#invalidateAllSessionLeases();
276
+ this.#rejectPendingRequests(change.error ?? new PiDisconnectedError());
277
+ }
278
+ this.#notifyConnectionStateListeners(change);
279
+ }
280
+ #takePendingRequest(id) {
281
+ const request = this.#pendingRequests.get(id);
282
+ if (request)
283
+ this.#pendingRequests.delete(id);
284
+ return request;
285
+ }
286
+ #rejectPendingRequests(error) {
287
+ const requests = [...this.#pendingRequests.values()];
288
+ this.#pendingRequests.clear();
289
+ for (const request of requests)
290
+ request.reject(error);
291
+ }
292
+ dispose() {
293
+ if (this.#disposePromise)
294
+ return this.#disposePromise;
295
+ this.#disposed = true;
296
+ this.#disposePromise = Promise.resolve();
297
+ const error = new PiClientDisposedError();
298
+ this.#rejectPendingRequests(error);
299
+ this.#connection.disconnect(error);
300
+ this.#state.dispose();
301
+ this.#invalidateAllSessionLeases();
302
+ this.#connectionStateListeners.clear();
303
+ return this.#disposePromise;
304
+ }
305
+ [Symbol.asyncDispose]() {
306
+ return this.dispose();
307
+ }
308
+ #assertNotDisposed() {
309
+ if (this.#disposed)
310
+ throw new PiClientDisposedError();
311
+ }
312
+ async #reconcileSessionCleanup(sessionId) {
313
+ if (!this.#sessionCleanupRequired.has(sessionId))
314
+ return false;
315
+ let reconciliation = this.#sessionReconciliations.get(sessionId);
316
+ if (!reconciliation) {
317
+ reconciliation = this.#request({ command: "detach", sessionId })
318
+ .then(() => undefined)
319
+ .then(() => {
320
+ this.#sessionCleanupRequired.delete(sessionId);
321
+ })
322
+ .finally(() => {
323
+ this.#sessionReconciliations.delete(sessionId);
324
+ });
325
+ this.#sessionReconciliations.set(sessionId, reconciliation);
326
+ }
327
+ await reconciliation;
328
+ return true;
329
+ }
330
+ #reserveSessionLease(sessionId, mode) {
331
+ const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
332
+ if (mode === "exclusive" && count > 0) {
333
+ throw new PiSessionOwnershipError(sessionId, `Session ${sessionId} already has an active lease`);
334
+ }
335
+ if (mode === "shared" && this.#exclusiveSessionLeases.has(sessionId)) {
336
+ throw new PiSessionOwnershipError(sessionId, `Session ${sessionId} has an exclusive lease`);
337
+ }
338
+ const token = { mode };
339
+ this.#sessionLeaseCounts.set(sessionId, count + 1);
340
+ if (mode === "exclusive")
341
+ this.#exclusiveSessionLeases.set(sessionId, token);
342
+ return token;
343
+ }
344
+ #releaseSessionLease(sessionId, token) {
345
+ const count = this.#sessionLeaseCounts.get(sessionId) ?? 0;
346
+ if (count <= 1)
347
+ this.#sessionLeaseCounts.delete(sessionId);
348
+ else
349
+ this.#sessionLeaseCounts.set(sessionId, count - 1);
350
+ if (this.#exclusiveSessionLeases.get(sessionId) === token)
351
+ this.#exclusiveSessionLeases.delete(sessionId);
352
+ }
353
+ #invalidateSessionLeases(sessionId) {
354
+ this.#sessionLeaseCounts.delete(sessionId);
355
+ this.#exclusiveSessionLeases.delete(sessionId);
356
+ this.#sessionCleanupRequired.delete(sessionId);
357
+ this.#sessionLeaseGenerations.set(sessionId, (this.#sessionLeaseGenerations.get(sessionId) ?? 0) + 1);
358
+ }
359
+ #invalidateAllSessionLeases() {
360
+ for (const sessionId of this.#sessionLeaseCounts.keys())
361
+ this.#invalidateSessionLeases(sessionId);
362
+ this.#sessionCleanupRequired.clear();
363
+ }
364
+ #notifyConnectionStateListeners(change) {
365
+ for (const listener of this.#connectionStateListeners) {
366
+ try {
367
+ listener(change);
368
+ }
369
+ catch (error) {
370
+ this.#reportListenerError(error);
371
+ }
372
+ }
373
+ }
374
+ #reportListenerError(error) {
375
+ if (!this.#options.onListenerError)
376
+ return;
377
+ try {
378
+ this.#options.onListenerError(toError(error));
379
+ }
380
+ catch {
381
+ // Diagnostics cannot affect protocol or transport state.
382
+ }
383
+ }
384
+ }
385
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAIN,mBAAmB,EACnB,uBAAuB,GAMvB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EACN,qBAAqB,EACrB,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,uBAAuB,EACvB,OAAO,GACP,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,EAGN,aAAa,GAGb,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAqBzC,MAAM,OAAO,QAAQ;IACX,QAAQ,CAAkB;IAC1B,WAAW,CAAa;IACxB,MAAM,CAAc;IACpB,gBAAgB,GAAG,IAAI,GAAG,EAA0B,CAAC;IACrD,mBAAmB,GAAG,IAAI,GAAG,EAAkB,CAAC;IAChD,uBAAuB,GAAG,IAAI,GAAG,EAA6B,CAAC;IAC/D,wBAAwB,GAAG,IAAI,GAAG,EAAkB,CAAC;IACrD,mBAAmB,GAAG,IAAI,GAAG,EAAyB,CAAC;IACvD,mBAAmB,GAAG,IAAI,GAAG,EAAyB,CAAC;IACvD,uBAAuB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC5C,uBAAuB,GAAG,IAAI,GAAG,EAAyB,CAAC;IAC3D,yBAAyB,GAAG,IAAI,GAAG,EAA2C,CAAC;IACxF,gBAAgB,GAAG,CAAC,CAAC;IACrB,SAAS,GAAG,KAAK,CAAC;IAClB,eAAe,CAA4B;IAE3C,YAAY,OAAwB,EAAE;QACrC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;QACvD,IAAI,CAAC,WAAW,GAAG,IAAI,UAAU,CAAC;YACjC,gBAAgB,EAAE,OAAO,CAAC,gBAAgB;YAC1C,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,WAAW,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,mBAAmB,CAAC,QAAQ,CAAC;YACpE,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC;YACpD,aAAa,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,4BAA4B,CAAC,MAAM,CAAC;SACpE,CAAC,CAAC;IAAA,CACH;IAED,IAAI,QAAQ,GAAY;QACvB,OAAO,IAAI,CAAC,SAAS,CAAC;IAAA,CACtB;IAED,IAAI,eAAe,GAAoB;QACtC,OAAO,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;IAAA,CAC9B;IAED,IAAI,SAAS,GAAY;QACxB,OAAO,IAAI,CAAC,WAAW,CAAC,KAAK,KAAK,WAAW,CAAC;IAAA,CAC9C;IAED,IAAI,QAAQ,GAA+B;QAC1C,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC;IAAA,CAC5B;IAED,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,OAAwB,EAAqB;QACjE,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAC,OAAO,CAAC,CAAC;QACrC,IAAI,CAAC;YACJ,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;YACvB,OAAO,MAAM,CAAC;QACf,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;YACvB,MAAM,KAAK,CAAC;QACb,CAAC;IAAA,CACD;IAED,OAAO,GAA4B;QAClC,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,qBAAqB,EAAE,CAAC,CAAC;QACvE,IAAI,IAAI,CAAC,WAAW,CAAC,KAAK,KAAK,cAAc;YAAE,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACnE,OAAO,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC;IAAA,CAClC;IAED,SAAS,GAA4B;QACpC,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC;IAAA,CACtB;IAED,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAQ;QAChD,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IAAA,CACpC;IAED,SAAS,CAAC,QAA4C,EAAe;QACpE,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAAA,CACvC;IAED,OAAO,CAAC,QAAsC,EAAe;QAC5D,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAAA,CACrC;IAED,uBAAuB,CAAC,QAAiD,EAAe;QACvF,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,IAAI,CAAC,yBAAyB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC7C,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,yBAAyB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAAA,CAC7D;IAED,KAAK,CAAC,YAAY,GAAwC;QACzD,OAAO,CAAC,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;IAAA,CAC3D;IAED,KAAK,CAAC,aAAa,CAAC,OAAO,GAAyB,EAAE,EAA4B;QACjF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;QACtE,MAAM,KAAK,GAAG,IAAI,CAAC,oBAAoB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;QACxE,OAAO,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;IAAA,CAC1D;IAED,KAAK,CAAC,aAAa,CAAC,SAAiB,EAA4B;QAChE,OAAO,IAAI,CAAC,cAAc,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;IAAA,CAC1D;IAED,KAAK,CAAC,cAAc,CAAC,SAAiB,EAAE,OAA8B,EAA4B;QACjG,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAC1B,MAAM,KAAK,GAAG,IAAI,CAAC,oBAAoB,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QACjE,IAAI,CAAC;YACJ,MAAM,UAAU,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAC3D,IAAI,UAAU;gBAAE,MAAM,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAC,CAAC,CAAC,CAAC;YACjD,MAAM,UAAU,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC;gBAC7D,CAAC,CAAC,MAAM,IAAI,CAAC,wBAAwB,CAAC,SAAS,CAAC;gBAChD,CAAC,CAAC,KAAK,CAAC;YACT,IAAI,UAAU,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,iBAAiB,CAAC,SAAS,CAAC,EAAE,CAAC;gBAC7D,IAAI,UAAU,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;gBACzD,IAAI,CAAC,UAAU,EAAE,CAAC;oBACjB,UAAU,GAAG,IAAI,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC;oBAC5C,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;gBACrD,CAAC;gBACD,IAAI,CAAC;oBACJ,MAAM,UAAU,CAAC;gBAClB,CAAC;wBAAS,CAAC;oBACV,IAAI,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,UAAU;wBAAE,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;gBACxG,CAAC;YACF,CAAC;YACD,OAAO,IAAI,CAAC,mBAAmB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QACnD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,IAAI,CAAC,oBAAoB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;YAC5C,MAAM,KAAK,CAAC;QACb,CAAC;IAAA,CACD;IAED,KAAK,CAAC,cAAc,CAAC,SAAiB,EAAiB;QACtD,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,qBAAqB,CAAC,SAAS,CAAC,CAAC;QAC9D,IAAI,CAAC;YACJ,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;QACvD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,IAAI,QAAQ;gBAAE,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,QAAQ,CAAC,CAAC;YAC3D,MAAM,KAAK,CAAC;QACb,CAAC;IAAA,CACD;IAED,QAAQ,CAAiC,OAAiB,EAAuC;QAChG,IAAI,IAAI,CAAC,SAAS;YAAE,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,qBAAqB,EAAE,CAAC,CAAC;QACvE,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,mBAAmB,EAAE,CAAC,CAAC;QACtE,MAAM,EAAE,GAAG,WAAW,EAAE,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAChD,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,sBAAsB,EAAiB,CAAC;QAC7E,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;QAC5D,IAAI,KAAiB,CAAC;QACtB,IAAI,CAAC;YACJ,KAAK,GAAG,mBAAmB,CAC1B,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,EACzC,EAAE,cAAc,EAAE,IAAI,CAAC,WAAW,CAAC,cAAc,EAAE,CACnD,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,IAAI,CAAC,mBAAmB,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;YACrD,OAAO,OAA8C,CAAC;QACvD,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7B,OAAO,OAA8C,CAAC;IAAA,CACtD;IAED,mBAAmB,CAAC,SAAiB,EAAE,KAAwB,EAAmB;QACjF,MAAM,UAAU,GAAG,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACrE,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;QACzD,IAAI,KAAK,GAAsB,QAAQ,CAAC;QACxC,IAAI,cAAyC,CAAC;QAC9C,MAAM,YAAY,GAAG,GAAG,EAAE,CAAC;YAC1B,IACC,CAAC,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,WAAW,CAAC;gBAC7C,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,UAAU,EAC1D,CAAC;gBACF,KAAK,GAAG,aAAa,CAAC;YACvB,CAAC;QAAA,CACD,CAAC;QACF,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC;YACtB,YAAY,EAAE,CAAC;YACf,OAAO,KAAK,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QAAA,CACtE,CAAC;QACF,MAAM,YAAY,GAAG,GAAG,EAAE,CAAC;YAC1B,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC1B,IAAI,CAAC,IAAI,CAAC,SAAS;gBAAE,MAAM,IAAI,mBAAmB,EAAE,CAAC;YACrD,IAAI,CAAC,QAAQ,EAAE;gBAAE,MAAM,IAAI,sBAAsB,CAAC,SAAS,CAAC,CAAC;QAAA,CAC7D,CAAC;QACF,MAAM,OAAO,GAAG,CAAC,mBAA4B,EAAiB,EAAE,CAAC;YAChE,YAAY,EAAE,CAAC;YACf,IAAI,KAAK,KAAK,UAAU,IAAI,KAAK,KAAK,aAAa;gBAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;YAC9E,IAAI,cAAc;gBAAE,OAAO,cAAc,CAAC;YAC1C,YAAY,EAAE,CAAC;YACf,KAAK,GAAG,WAAW,CAAC;YACpB,cAAc,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC7B,MAAM,KAAK,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;gBAC3D,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;oBAChB,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;oBACzF,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;oBACpD,IAAI,CAAC;wBACJ,MAAM,UAAU,CAAC;wBACjB,IAAI,CAAC,oBAAoB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;oBAC7C,CAAC;4BAAS,CAAC;wBACV,IAAI,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,UAAU,EAAE,CAAC;4BAC5D,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;wBAC5C,CAAC;oBACF,CAAC;gBACF,CAAC;qBAAM,CAAC;oBACP,IAAI,CAAC,oBAAoB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;gBAC7C,CAAC;gBACD,KAAK,GAAG,UAAU,CAAC;YAAA,CACnB,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE,CAAC;gBAC9B,YAAY,EAAE,CAAC;gBACf,IAAI,KAAK,KAAK,aAAa;oBAAE,OAAO;gBACpC,IAAI,mBAAmB,EAAE,CAAC;oBACzB,IAAI,CAAC,oBAAoB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;oBAC5C,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;oBAC5C,KAAK,GAAG,UAAU,CAAC;gBACpB,CAAC;qBAAM,CAAC;oBACP,KAAK,GAAG,QAAQ,CAAC;oBACjB,cAAc,GAAG,SAAS,CAAC;gBAC5B,CAAC;gBACD,MAAM,KAAK,CAAC;YAAA,CACZ,CAAC,CAAC;YACH,OAAO,cAAc,CAAC;QAAA,CACtB,CAAC;QACF,MAAM,SAAS,GAA2B;YACzC,UAAU,EAAE,QAAQ;YACpB,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;YACvF,SAAS,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC;gBACxB,YAAY,EAAE,CAAC;gBACf,OAAO,IAAI,CAAC,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC;oBAC5D,IAAI,QAAQ,EAAE;wBAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;gBAAA,CACnC,CAAC,CAAC;YAAA,CACH;YACD,OAAO,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC;gBACtB,YAAY,EAAE,CAAC;gBACf,OAAO,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC;oBACvD,IAAI,QAAQ,EAAE,IAAI,KAAK,CAAC,IAAI,KAAK,iBAAiB;wBAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;gBAAA,CACpE,CAAC,CAAC;YAAA,CACH;YACD,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC;YAC5B,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC;YAC5B,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC;gBACrB,YAAY,EAAE,CAAC;gBACf,OAAO,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YAAA,CAC9B;SACD,CAAC;QACF,OAAO,IAAI,aAAa,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAAA,CAC/C;IAED,cAAc,CAAC,OAAyC,EAAQ;QAC/D,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAC9B,IAAI,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,iBAAiB;gBAAE,IAAI,CAAC,wBAAwB,CAAC,OAAO,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;YACrG,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YACtC,OAAO;QACR,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,mBAAmB,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QACrD,IAAI,CAAC,OAAO,EAAE,CAAC;YACd,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,uBAAuB,CAAC,kCAAkC,CAAC,CAAC,CAAC;YACvF,OAAO;QACR,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;YACjB,OAAO,CAAC,MAAM,CAAC,IAAI,aAAa,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;YACjD,OAAO;QACR,CAAC;QACD,IAAI,OAAO,CAAC,MAAM,CAAC,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;YACxD,MAAM,KAAK,GAAG,IAAI,uBAAuB,CACxC,oBAAoB,OAAO,CAAC,MAAM,CAAC,OAAO,mBAAmB,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,CACtF,CAAC;YACF,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACtB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC7B,OAAO;QACR,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACxC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAAA,CAChC;IAED,4BAA4B,CAAC,MAA6B,EAAQ;QACjE,IAAI,MAAM,CAAC,KAAK,KAAK,cAAc,EAAE,CAAC;YACrC,IAAI,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC;YAC/B,IAAI,CAAC,2BAA2B,EAAE,CAAC;YACnC,IAAI,CAAC,sBAAsB,CAAC,MAAM,CAAC,KAAK,IAAI,IAAI,mBAAmB,EAAE,CAAC,CAAC;QACxE,CAAC;QACD,IAAI,CAAC,+BAA+B,CAAC,MAAM,CAAC,CAAC;IAAA,CAC7C;IAED,mBAAmB,CAAC,EAAU,EAA8B;QAC3D,MAAM,OAAO,GAAG,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC9C,IAAI,OAAO;YAAE,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC9C,OAAO,OAAO,CAAC;IAAA,CACf;IAED,sBAAsB,CAAC,KAAY,EAAQ;QAC1C,MAAM,QAAQ,GAAG,CAAC,GAAG,IAAI,CAAC,gBAAgB,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,CAAC;QAC9B,KAAK,MAAM,OAAO,IAAI,QAAQ;YAAE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAAA,CACtD;IAED,OAAO,GAAkB;QACxB,IAAI,IAAI,CAAC,eAAe;YAAE,OAAO,IAAI,CAAC,eAAe,CAAC;QACtD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,eAAe,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,IAAI,qBAAqB,EAAE,CAAC;QAC1C,IAAI,CAAC,sBAAsB,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACtB,IAAI,CAAC,2BAA2B,EAAE,CAAC;QACnC,IAAI,CAAC,yBAAyB,CAAC,KAAK,EAAE,CAAC;QACvC,OAAO,IAAI,CAAC,eAAe,CAAC;IAAA,CAC5B;IAED,CAAC,MAAM,CAAC,YAAY,CAAC,GAAkB;QACtC,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC;IAAA,CACtB;IAED,kBAAkB,GAAS;QAC1B,IAAI,IAAI,CAAC,SAAS;YAAE,MAAM,IAAI,qBAAqB,EAAE,CAAC;IAAA,CACtD;IAED,KAAK,CAAC,wBAAwB,CAAC,SAAiB,EAAoB;QACnE,IAAI,CAAC,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC;YAAE,OAAO,KAAK,CAAC;QAC/D,IAAI,cAAc,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACjE,IAAI,CAAC,cAAc,EAAE,CAAC;YACrB,cAAc,GAAG,IAAI,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;iBAC9D,IAAI,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC;iBACrB,IAAI,CAAC,GAAG,EAAE,CAAC;gBACX,IAAI,CAAC,uBAAuB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;YAAA,CAC/C,CAAC;iBACD,OAAO,CAAC,GAAG,EAAE,CAAC;gBACd,IAAI,CAAC,uBAAuB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;YAAA,CAC/C,CAAC,CAAC;YACJ,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QAC7D,CAAC;QACD,MAAM,cAAc,CAAC;QACrB,OAAO,IAAI,CAAC;IAAA,CACZ;IAED,oBAAoB,CAAC,SAAiB,EAAE,IAAsB,EAAqB;QAClF,MAAM,KAAK,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC3D,IAAI,IAAI,KAAK,WAAW,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACvC,MAAM,IAAI,uBAAuB,CAAC,SAAS,EAAE,WAAW,SAAS,8BAA8B,CAAC,CAAC;QAClG,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YACtE,MAAM,IAAI,uBAAuB,CAAC,SAAS,EAAE,WAAW,SAAS,yBAAyB,CAAC,CAAC;QAC7F,CAAC;QACD,MAAM,KAAK,GAAsB,EAAE,IAAI,EAAE,CAAC;QAC1C,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QACnD,IAAI,IAAI,KAAK,WAAW;YAAE,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAC7E,OAAO,KAAK,CAAC;IAAA,CACb;IAED,oBAAoB,CAAC,SAAiB,EAAE,KAAwB,EAAQ;QACvE,MAAM,KAAK,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC3D,IAAI,KAAK,IAAI,CAAC;YAAE,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;;YACtD,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QACxD,IAAI,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,KAAK;YAAE,IAAI,CAAC,uBAAuB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAAA,CAC1G;IAED,wBAAwB,CAAC,SAAiB,EAAQ;QACjD,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,uBAAuB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC/C,IAAI,CAAC,uBAAuB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC/C,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAAA,CACtG;IAED,2BAA2B,GAAS;QACnC,KAAK,MAAM,SAAS,IAAI,IAAI,CAAC,mBAAmB,CAAC,IAAI,EAAE;YAAE,IAAI,CAAC,wBAAwB,CAAC,SAAS,CAAC,CAAC;QAClG,IAAI,CAAC,uBAAuB,CAAC,KAAK,EAAE,CAAC;IAAA,CACrC;IAED,+BAA+B,CAAC,MAA6B,EAAQ;QACpE,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,yBAAyB,EAAE,CAAC;YACvD,IAAI,CAAC;gBACJ,QAAQ,CAAC,MAAM,CAAC,CAAC;YAClB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,IAAI,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC;YAClC,CAAC;QACF,CAAC;IAAA,CACD;IAED,oBAAoB,CAAC,KAAc,EAAQ;QAC1C,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,eAAe;YAAE,OAAO;QAC3C,IAAI,CAAC;YACJ,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;QAC/C,CAAC;QAAC,MAAM,CAAC;YACR,yDAAyD;QAC1D,CAAC;IAAA,CACD;CACD","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"]}
@@ -0,0 +1,24 @@
1
+ import { type ServerMessage, type ServerSnapshot } from "@earendil-works/pi-protocol";
2
+ import type { ByteTransportFactory } from "./transport.ts";
3
+ import type { ConnectionState, ConnectionStateChange } from "./types.ts";
4
+ interface ConnectionOptions {
5
+ transportFactory: ByteTransportFactory;
6
+ maxFrameLength?: number;
7
+ onHandshake(snapshot: ServerSnapshot): void;
8
+ onMessage(message: Exclude<ServerMessage, {
9
+ type: "hello" | "hello_error";
10
+ }>): void;
11
+ onStateChange(change: ConnectionStateChange): void;
12
+ }
13
+ export declare class Connection {
14
+ #private;
15
+ constructor(options: ConnectionOptions);
16
+ get state(): ConnectionState;
17
+ get maxFrameLength(): number;
18
+ connect(): Promise<ServerSnapshot>;
19
+ disconnect(reason?: string | Error): void;
20
+ fail(error: Error): void;
21
+ send(frame: Uint8Array): void;
22
+ }
23
+ export {};
24
+ //# sourceMappingURL=connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAKN,KAAK,aAAa,EAElB,KAAK,cAAc,EACnB,MAAM,6BAA6B,CAAC;AAGrC,OAAO,KAAK,EAAiB,oBAAoB,EAAyB,MAAM,gBAAgB,CAAC;AACjG,OAAO,KAAK,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAmBzE,UAAU,iBAAiB;IAC1B,gBAAgB,EAAE,oBAAoB,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAC;IAC5C,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,IAAI,EAAE,OAAO,GAAG,aAAa,CAAA;KAAE,CAAC,GAAG,IAAI,CAAC;IACpF,aAAa,CAAC,MAAM,EAAE,qBAAqB,GAAG,IAAI,CAAC;CACnD;AAED,qBAAa,UAAU;;IAMtB,YAAY,OAAO,EAAE,iBAAiB,EAUrC;IAED,IAAI,KAAK,IAAI,eAAe,CAE3B;IAED,IAAI,cAAc,IAAI,MAAM,CAE3B;IAED,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC,CAwBjC;IAED,UAAU,CAAC,MAAM,GAAE,MAAM,GAAG,KAA6B,GAAG,IAAI,CAG/D;IAED,IAAI,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAEvB;IAED,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAgB5B;CAuHD","sourcesContent":["import {\n\tDEFAULT_MAX_FRAME_LENGTH,\n\tencodeClientMessage,\n\tPROTOCOL_VERSION,\n\tProtocolValidationError,\n\ttype ServerMessage,\n\tServerMessageDecoder,\n\ttype ServerSnapshot,\n} from \"@earendil-works/pi-protocol\";\nimport { PiDisconnectedError, PiServerError, toDisconnectedError, toError } from \"./errors.ts\";\nimport { createPromiseResolvers, type PromiseResolvers } from \"./promise.ts\";\nimport type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from \"./transport.ts\";\nimport type { ConnectionState, ConnectionStateChange } from \"./types.ts\";\n\nconst MAX_UINT32 = 0xffff_ffff;\n\ntype ActiveConnection = {\n\tid: number;\n\tdecoder: ServerMessageDecoder;\n\ttransport?: ByteTransport;\n};\n\ntype ConnectionLifecycle =\n\t| { state: \"disconnected\" }\n\t| ({ state: \"connecting\"; handshake: PromiseResolvers<ServerSnapshot> } & ActiveConnection)\n\t| ({\n\t\t\tstate: \"connected\";\n\t\t\ttransport: ByteTransport;\n\t\t\thandshake: PromiseResolvers<ServerSnapshot> | undefined;\n\t } & ActiveConnection);\n\ninterface ConnectionOptions {\n\ttransportFactory: ByteTransportFactory;\n\tmaxFrameLength?: number;\n\tonHandshake(snapshot: ServerSnapshot): void;\n\tonMessage(message: Exclude<ServerMessage, { type: \"hello\" | \"hello_error\" }>): void;\n\tonStateChange(change: ConnectionStateChange): void;\n}\n\nexport class Connection {\n\treadonly #options: ConnectionOptions;\n\treadonly #maxFrameLength: number;\n\t#lifecycle: ConnectionLifecycle = { state: \"disconnected\" };\n\t#sequence = 0;\n\n\tconstructor(options: ConnectionOptions) {\n\t\tthis.#options = options;\n\t\tthis.#maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;\n\t\tif (\n\t\t\t!Number.isSafeInteger(this.#maxFrameLength) ||\n\t\t\tthis.#maxFrameLength <= 0 ||\n\t\t\tthis.#maxFrameLength > MAX_UINT32\n\t\t) {\n\t\t\tthrow new TypeError(`PiClient maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);\n\t\t}\n\t}\n\n\tget state(): ConnectionState {\n\t\treturn this.#lifecycle.state;\n\t}\n\n\tget maxFrameLength(): number {\n\t\treturn this.#maxFrameLength;\n\t}\n\n\tconnect(): Promise<ServerSnapshot> {\n\t\tif (this.#lifecycle.state !== \"disconnected\") {\n\t\t\treturn Promise.reject(new PiDisconnectedError(`PiClient is already ${this.#lifecycle.state}`));\n\t\t}\n\t\tconst id = ++this.#sequence;\n\t\tconst handshake = createPromiseResolvers<ServerSnapshot>();\n\t\tthis.#lifecycle = {\n\t\t\tstate: \"connecting\",\n\t\t\tid,\n\t\t\tdecoder: new ServerMessageDecoder({ maxFrameLength: this.#maxFrameLength }),\n\t\t\thandshake,\n\t\t};\n\t\tthis.#options.onStateChange({ state: \"connecting\" });\n\t\tconst handlers = {\n\t\t\tonData: (chunk) => this.#handleData(id, chunk),\n\t\t\tonClose: () => {\n\t\t\t\tif (this.#isCurrent(id)) this.#handleClose();\n\t\t\t},\n\t\t\tonError: (error) => {\n\t\t\t\tif (this.#isCurrent(id)) this.#failAndClose(toDisconnectedError(error));\n\t\t\t},\n\t\t} satisfies ByteTransportHandlers;\n\t\tvoid this.#openTransport(id, handlers);\n\t\treturn handshake.promise;\n\t}\n\n\tdisconnect(reason: string | Error = \"Client disconnected\"): void {\n\t\tif (this.#lifecycle.state === \"disconnected\") return;\n\t\tthis.#failAndClose(typeof reason === \"string\" ? new PiDisconnectedError(reason) : reason);\n\t}\n\n\tfail(error: Error): void {\n\t\tthis.#failAndClose(error);\n\t}\n\n\tsend(frame: Uint8Array): void {\n\t\tconst lifecycle = this.#lifecycle;\n\t\tif (lifecycle.state !== \"connected\") throw new PiDisconnectedError();\n\t\tlet sending: Promise<void>;\n\t\ttry {\n\t\t\tsending = lifecycle.transport.send(frame);\n\t\t} catch (error) {\n\t\t\tthis.#failAndClose(toDisconnectedError(error));\n\t\t\treturn;\n\t\t}\n\t\tvoid sending.catch((error: unknown) => {\n\t\t\tconst current = this.#lifecycle;\n\t\t\tif (current.state !== \"disconnected\" && current.transport === lifecycle.transport) {\n\t\t\t\tthis.#failAndClose(toDisconnectedError(error));\n\t\t\t}\n\t\t});\n\t}\n\n\tasync #openTransport(id: number, handlers: ByteTransportHandlers): Promise<void> {\n\t\tlet transport: ByteTransport;\n\t\ttry {\n\t\t\ttransport = await this.#options.transportFactory(handlers);\n\t\t} catch (error) {\n\t\t\tif (this.#isCurrent(id)) this.#fail(toDisconnectedError(error));\n\t\t\treturn;\n\t\t}\n\t\tconst lifecycle = this.#lifecycle;\n\t\tif (lifecycle.state !== \"connecting\" || lifecycle.id !== id) {\n\t\t\ttransport.close();\n\t\t\treturn;\n\t\t}\n\t\tthis.#lifecycle = { ...lifecycle, transport };\n\t\ttry {\n\t\t\tawait transport.send(\n\t\t\t\tencodeClientMessage({ type: \"hello\", version: PROTOCOL_VERSION }, { maxFrameLength: this.#maxFrameLength }),\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tif (this.#isCurrent(id)) this.#failAndClose(toDisconnectedError(error));\n\t\t}\n\t}\n\n\t#handleData(id: number, chunk: Uint8Array): void {\n\t\tconst lifecycle = this.#lifecycle;\n\t\tif (lifecycle.state === \"disconnected\" || lifecycle.id !== id) return;\n\t\tif (lifecycle.state === \"connecting\" && !lifecycle.transport) {\n\t\t\tthis.#failAndClose(new ProtocolValidationError(\"Received server data before the client hello was sent\"));\n\t\t\treturn;\n\t\t}\n\t\tlet messages: ServerMessage[];\n\t\ttry {\n\t\t\tmessages = lifecycle.decoder.push(chunk);\n\t\t} catch (error) {\n\t\t\tthis.#failAndClose(toError(error));\n\t\t\treturn;\n\t\t}\n\t\tfor (const message of messages) {\n\t\t\tif (this.#lifecycle.state === \"disconnected\") return;\n\t\t\tthis.#handleMessage(message);\n\t\t}\n\t}\n\n\t#handleMessage(message: ServerMessage): void {\n\t\tconst lifecycle = this.#lifecycle;\n\t\tif (lifecycle.state === \"connecting\") {\n\t\t\tif (message.type === \"hello_error\") {\n\t\t\t\tthis.#failAndClose(new PiServerError(message.error));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (message.type !== \"hello\") {\n\t\t\t\tthis.#failAndClose(new ProtocolValidationError(\"Expected server hello as first message\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!lifecycle.transport) {\n\t\t\t\tthis.#failAndClose(new ProtocolValidationError(\"Received server hello before the client hello was sent\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst connected = {\n\t\t\t\tstate: \"connected\",\n\t\t\t\tid: lifecycle.id,\n\t\t\t\tdecoder: lifecycle.decoder,\n\t\t\t\ttransport: lifecycle.transport,\n\t\t\t\thandshake: lifecycle.handshake,\n\t\t\t} satisfies Extract<ConnectionLifecycle, { state: \"connected\" }>;\n\t\t\tthis.#lifecycle = connected;\n\t\t\ttry {\n\t\t\t\tthis.#options.onHandshake(message.snapshot);\n\t\t\t} catch (error) {\n\t\t\t\tif (this.#lifecycle === connected) this.#failAndClose(toError(error));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (this.#lifecycle !== connected) return;\n\t\t\tthis.#options.onStateChange({ state: \"connected\" });\n\t\t\tif (this.#lifecycle !== connected) return;\n\t\t\tthis.#lifecycle = { ...connected, handshake: undefined };\n\t\t\tlifecycle.handshake.resolve(message.snapshot);\n\t\t\treturn;\n\t\t}\n\t\tif (lifecycle.state !== \"connected\") return;\n\t\tif (message.type === \"hello\" || message.type === \"hello_error\") {\n\t\t\tthis.#failAndClose(new ProtocolValidationError(\"Unexpected handshake message\"));\n\t\t\treturn;\n\t\t}\n\t\tthis.#options.onMessage(message);\n\t}\n\n\t#handleClose(): void {\n\t\tconst lifecycle = this.#lifecycle;\n\t\tif (lifecycle.state === \"disconnected\") return;\n\t\tlet error: Error = new PiDisconnectedError(\"Byte transport closed\");\n\t\ttry {\n\t\t\tlifecycle.decoder.end();\n\t\t} catch (decoderError) {\n\t\t\terror = toError(decoderError);\n\t\t}\n\t\tthis.#fail(error);\n\t}\n\n\t#failAndClose(error: Error): void {\n\t\tconst lifecycle = this.#lifecycle;\n\t\tconst transport = lifecycle.state === \"disconnected\" ? undefined : lifecycle.transport;\n\t\tthis.#fail(error);\n\t\ttransport?.close();\n\t}\n\n\t#fail(error: Error): void {\n\t\tconst lifecycle = this.#lifecycle;\n\t\tif (lifecycle.state === \"disconnected\") return;\n\t\tthis.#lifecycle = { state: \"disconnected\" };\n\t\tlifecycle.handshake?.reject(error);\n\t\tthis.#options.onStateChange({ state: \"disconnected\", error });\n\t}\n\n\t#isCurrent(id: number): boolean {\n\t\treturn this.#lifecycle.state !== \"disconnected\" && this.#lifecycle.id === id;\n\t}\n}\n"]}