@graphorin/client 0.5.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +114 -0
- package/dist/errors.js.map +1 -0
- package/dist/graphorin-client.d.ts +180 -0
- package/dist/graphorin-client.d.ts.map +1 -0
- package/dist/graphorin-client.js +594 -0
- package/dist/graphorin-client.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/reconnect.d.ts +54 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/reconnect.js +53 -0
- package/dist/reconnect.js.map +1 -0
- package/dist/transport/index.d.ts +4 -0
- package/dist/transport/index.js +4 -0
- package/dist/transport/sse.d.ts +15 -0
- package/dist/transport/sse.d.ts.map +1 -0
- package/dist/transport/sse.js +149 -0
- package/dist/transport/sse.js.map +1 -0
- package/dist/transport/types.d.ts +93 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/ws.d.ts +17 -0
- package/dist/transport/ws.d.ts.map +1 -0
- package/dist/transport/ws.js +149 -0
- package/dist/transport/ws.js.map +1 -0
- package/package.json +87 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graphorin-client.js","names":["#options","#pending","#subscriptions","#closed","#transport","#connectingPromise","#openWs","#initializeRpc","#openSse","#sendRpc","subject","#sseSubscription","#createSubscription","params: { subject: string; sinceEventId?: string }","params: {\n runId: string;\n drain?: boolean;\n reason?: string;\n onPendingApprovals?: 'deny' | 'preserve';\n }","headers: Record<string, string>","frame: ClientMessage","#abortController","#wsUrl","#listeners","#sseUrl","#handleFrame","#handleTransportError","#handleTransportClose","#sseFallback","#reconnect","queue: ServerEventFrame[]","lifecycle: ServerLifecycleFrame[]","replayMarkers: ServerReplayMarkerFrame[]","waiters: Array<{\n resolve: (value: IteratorResult<ServerEventFrame>) => void;\n reject: (err: Error) => void;\n }>","closeError: Error | undefined","lastEventId: string | undefined","#nextId","timer: ReturnType<typeof setTimeout> | undefined","pending: PendingRpc","#idCounter"],"sources":["../src/graphorin-client.ts"],"sourcesContent":["/**\n * `GraphorinClient` — ergonomic façade over the\n * {@link Transport} contract. Handles:\n *\n * - WS handshake (`openWebSocketTransport`) with optional ticket flow.\n * - Optional SSE fallback (`openSseTransport`) for environments\n * that block WebSocket upgrades.\n * - JSON-RPC request / response correlation for `subscribe` /\n * `unsubscribe` / `cancel` / `resume` / `ping` calls.\n * - Async-iterable subscriptions (`for await (const event of\n * sub.events())`).\n * - Exponential-backoff reconnect with `lastEventId` resume against\n * the server replay buffer.\n *\n * The class is intentionally small (≈ 400 LOC) so the production\n * cross-cuts (telemetry, sticky reconnect on transient errors,\n * load-shedding) live in higher-level wrappers consumers build on\n * top of `GraphorinClient` instead of leaking into the protocol\n * adapter itself.\n *\n * @packageDocumentation\n */\n\nimport {\n type ClientMessage,\n type ClientMessageId,\n isErrorFrame,\n isEventFrame,\n isLifecycleFrame,\n isPongFrame,\n isReplayMarkerFrame,\n isRpcFailure,\n isRpcSuccess,\n isSubscribedFrame,\n isUnsubscribedFrame,\n type ServerEventFrame,\n type ServerLifecycleFrame,\n type ServerMessage,\n type ServerReplayMarkerFrame,\n} from '@graphorin/protocol';\n\nimport {\n ClientAbortedError,\n ClientNotConnectedError,\n GraphorinClientError,\n kindForRpcCode,\n ProtocolViolationError,\n TransportFailedError,\n} from './errors.js';\nimport { type BackoffPolicy, computeBackoffMs, sleep } from './reconnect.js';\nimport {\n openSseTransport,\n openWebSocketTransport,\n type Transport,\n type TransportAuth,\n type TransportCloseReason,\n type TransportKind,\n} from './transport/index.js';\n\n/**\n * Discriminator for the subscription target. Mirrors the strict\n * subject grammar enforced by the server:\n * - `'session'`/`<id>` ⇒ `'session:<id>/events'`\n * - `'agent'`/`<id>` + `runId` ⇒ `'agent:<id>/runs/<runId>/events'`\n * - `'run'`/`<runId>` ⇒ `'session:<sessionId>/runs/<runId>/events'`\n * (when `sessionId` is provided)\n * - `'workflow'`/`<id>` ⇒ `'workflow:<id>/events'`\n *\n * @stable\n */\nexport type SubscriptionTarget =\n | { readonly target: 'session'; readonly id: string }\n | {\n readonly target: 'agent';\n readonly id: string;\n readonly runId: string;\n }\n | {\n readonly target: 'run';\n readonly runId: string;\n readonly sessionId?: string;\n }\n | { readonly target: 'workflow'; readonly id: string };\n\n/**\n * Transport selector. `'auto'` (default) attempts a WebSocket\n * handshake first and falls back to SSE on failure.\n *\n * @stable\n */\nexport type TransportPreference = TransportKind | 'auto';\n\n/**\n * Public configuration accepted by {@link GraphorinClient}.\n *\n * @stable\n */\nexport interface GraphorinClientOptions {\n /**\n * Session bound to the SSE fallback (IP-3): substituted into the\n * `:sessionId` slot of `sseSessionPath`. Required to connect over\n * SSE — the old client sent the literal template and could never\n * receive an event.\n */\n readonly sessionId?: string;\n /**\n * Server base URL. Examples: `'wss://graphorin.example.com'` or\n * `'http://localhost:8080'`. The path `/v1/ws` is appended for\n * the WebSocket transport.\n */\n readonly baseUrl: string;\n readonly auth: TransportAuth;\n readonly transport?: TransportPreference;\n /** Override the WS path (default `'/v1/ws'`). */\n readonly wsPath?: string;\n /**\n * SSE path template. The placeholder `:sessionId` is replaced at\n * subscribe time. Default: `'/v1/sessions/:sessionId/events'`.\n * Required when the transport is `'sse'` or when the WS handshake\n * fails on `'auto'`.\n */\n readonly sseSessionPath?: string;\n readonly reconnect?: BackoffPolicy;\n /** Inject a `WebSocket` constructor (Node SDKs / tests). */\n readonly WebSocket?: typeof WebSocket;\n /** Inject an `EventSource` constructor (Node SDKs / tests). */\n readonly EventSource?: typeof EventSource;\n /** Inject a `fetch` implementation (defaults to `globalThis.fetch`). */\n readonly fetch?: typeof fetch;\n /** Optional client identifier surfaced on diagnostics. */\n readonly clientId?: string;\n /**\n * IP-19: per-RPC reply timeout in milliseconds. When set (and > 0), an RPC\n * that receives no matching reply within this window rejects with a\n * {@link TransportFailedError} instead of hanging forever on a\n * non-responsive server. Default: unset — no timeout, so a legitimately\n * slow server reply is never aborted (opt-in).\n */\n readonly rpcTimeoutMs?: number;\n}\n\n/**\n * Snapshot returned by {@link Subscription.metadata}.\n *\n * @stable\n */\nexport interface SubscriptionMetadata {\n readonly id: string;\n readonly subject: string;\n readonly target: SubscriptionTarget;\n readonly snapshotEventId: string | undefined;\n readonly lastEventId: string | undefined;\n readonly closed: boolean;\n}\n\n/**\n * Public surface returned by {@link GraphorinClient.subscribe}.\n *\n * @stable\n */\nexport interface Subscription {\n readonly subscriptionId: string;\n readonly subject: string;\n events(): AsyncIterable<ServerEventFrame>;\n /**\n * Close the subscription on the server. Idempotent.\n */\n unsubscribe(): Promise<void>;\n metadata(): SubscriptionMetadata;\n}\n\ninterface SubscriptionInternal extends Subscription {\n __push(frame: ServerEventFrame): void;\n __pushLifecycle(frame: ServerLifecycleFrame): void;\n __pushReplayMarker(frame: ServerReplayMarkerFrame): void;\n __close(reason: 'unsubscribed' | 'transport-closed' | 'aborted', error?: Error): void;\n __subject(): string;\n __target(): SubscriptionTarget;\n __snapshotEventId(): string | undefined;\n __lastEventId(): string | undefined;\n /** IP-7: re-point this subscription at a new server subscriptionId. */\n __rebind(newSubscriptionId: string): void;\n}\n\n/**\n * @stable\n */\nexport class GraphorinClient {\n readonly #options: GraphorinClientOptions;\n readonly #pending: Map<ClientMessageId, PendingRpc> = new Map();\n readonly #subscriptions: Map<string, SubscriptionInternal> = new Map();\n /** IP-3: the synthetic single subscription an SSE connection carries. */\n #sseSubscription: SubscriptionInternal | undefined;\n #transport: Transport | undefined;\n #idCounter = 0;\n #closed = false;\n #connectingPromise: Promise<void> | undefined;\n #abortController: AbortController = new AbortController();\n\n constructor(options: GraphorinClientOptions) {\n if (typeof options.baseUrl !== 'string' || options.baseUrl.length === 0) {\n throw new TypeError('GraphorinClient: baseUrl must be a non-empty string.');\n }\n this.#options = options;\n }\n\n /**\n * Open the underlying transport. Resolves once the server has\n * accepted the handshake (`'open'`); rejects with a typed\n * {@link GraphorinClientError} otherwise.\n *\n * Calling `connect()` while already connected is a no-op; calling\n * it during another `connect()` returns the same promise.\n */\n async connect(): Promise<void> {\n if (this.#closed) {\n throw new ClientAbortedError('GraphorinClient was disconnected; create a new instance.');\n }\n if (this.#transport !== undefined) return;\n if (this.#connectingPromise !== undefined) return this.#connectingPromise;\n this.#connectingPromise = (async () => {\n const preference = this.#options.transport ?? 'auto';\n try {\n if (preference === 'ws' || preference === 'auto') {\n try {\n this.#transport = await this.#openWs();\n await this.#initializeRpc();\n return;\n } catch (err) {\n if (preference === 'ws') throw err;\n }\n }\n this.#transport = await this.#openSse();\n } finally {\n this.#connectingPromise = undefined;\n }\n })();\n return this.#connectingPromise;\n }\n\n async #initializeRpc(): Promise<void> {\n if (this.#transport?.kind !== 'ws') return;\n await this.#sendRpc('initialize', {\n clientInfo: { name: 'graphorin-client', version: '0.5.0' },\n });\n }\n\n /** Send a `ping` RPC and resolve when the server replies with `pong`. */\n async ping(): Promise<void> {\n await this.#sendRpc('ping');\n }\n\n /**\n * Subscribe to a server-side event stream. Resolves with a\n * {@link Subscription} once the server confirms with the matching\n * `subscribed` frame; rejects when the server returns an\n * `error` instead.\n */\n async subscribe(\n target: SubscriptionTarget,\n opts?: { readonly sinceEventId?: string },\n ): Promise<Subscription> {\n if (this.#transport === undefined) throw new ClientNotConnectedError();\n if (this.#transport.kind === 'sse') {\n // IP-3: the SSE connection IS one implicit subscription to the\n // bound session's events. Frames arrive with a server-generated\n // subscriptionId the client cannot know upfront, so routing\n // falls through to this synthetic subscription (see\n // #handleFrame). Other subjects still require the WS transport.\n const subject = subjectFor(target);\n const boundSubject = `session:${this.#options.sessionId ?? ''}/events`;\n if (subject !== boundSubject) {\n throw new TransportFailedError(\n `subscribe('${subject}') requires the WebSocket transport — the SSE fallback carries only the bound session stream ('${boundSubject}').`,\n );\n }\n if (this.#sseSubscription === undefined) {\n this.#sseSubscription = this.#createSubscription({\n subscriptionId: '__sse__',\n subject,\n target,\n snapshotEventId: undefined,\n });\n this.#subscriptions.set('__sse__', this.#sseSubscription);\n }\n return this.#sseSubscription;\n }\n const subject = subjectFor(target);\n // IP-7: a resubscribe passes the SUBSCRIPTION's own cursor — the\n // fresh transport's lastEventId is always undefined, so the old\n // code never consulted the server replay buffer.\n const lastEventId = opts?.sinceEventId ?? this.#transport.lastEventId;\n const params: { subject: string; sinceEventId?: string } = {\n subject,\n };\n if (lastEventId !== undefined) params.sinceEventId = lastEventId;\n const reply = await this.#sendRpc('subscription.subscribe', params);\n const result = reply as { subscriptionId?: unknown; snapshotEventId?: unknown };\n const subscriptionId =\n typeof result.subscriptionId === 'string' && result.subscriptionId.length > 0\n ? result.subscriptionId\n : undefined;\n if (subscriptionId === undefined) {\n throw new ProtocolViolationError('Server subscribe reply missing subscriptionId.');\n }\n const snapshotEventId =\n typeof result.snapshotEventId === 'string' ? result.snapshotEventId : undefined;\n const sub = this.#createSubscription({\n subscriptionId,\n subject,\n target,\n snapshotEventId,\n });\n this.#subscriptions.set(subscriptionId, sub);\n return sub;\n }\n\n /**\n * Cancel a server-side run. Sends the `run.cancel` RPC and\n * resolves with the server's `result` payload (typically\n * `{ cancelled: true, partialStateAvailable: true }`).\n */\n async cancel(\n runId: string,\n opts: {\n readonly drain?: boolean;\n readonly reason?: string;\n readonly onPendingApprovals?: 'deny' | 'preserve';\n } = {},\n ): Promise<unknown> {\n if (typeof runId !== 'string' || runId.length === 0) {\n throw new TypeError('cancel: runId must be a non-empty string.');\n }\n const params: {\n runId: string;\n drain?: boolean;\n reason?: string;\n onPendingApprovals?: 'deny' | 'preserve';\n } = { runId };\n if (opts.drain !== undefined) params.drain = opts.drain;\n if (opts.reason !== undefined) params.reason = opts.reason;\n if (opts.onPendingApprovals !== undefined) {\n params.onPendingApprovals = opts.onPendingApprovals;\n }\n return this.#sendRpc('run.cancel', params);\n }\n\n /**\n * Resume a paused (HITL) run. The WebSocket protocol intentionally\n * does NOT carry a `resume` control message — resumes are durable\n * + idempotent + body-carrying, which maps onto the REST endpoint\n * `POST /v1/runs/:runId/resume`. NOTE (IP-14): the server endpoint\n * currently answers **501** — server-side durable resume is not\n * implemented yet. Library-mode callers resume directly:\n * `agent.run(result.state, { directive })`.\n */\n async resume(\n runId: string,\n directive?: unknown,\n opts: { readonly idempotencyKey?: string } = {},\n ): Promise<unknown> {\n if (typeof runId !== 'string' || runId.length === 0) {\n throw new TypeError('resume: runId must be a non-empty string.');\n }\n const fetchImpl = this.#options.fetch ?? globalThis.fetch;\n if (typeof fetchImpl !== 'function') {\n throw new TransportFailedError(\n 'No fetch implementation available; pass `fetch` via the client options.',\n );\n }\n const url = joinUrl(this.#options.baseUrl, `/v1/runs/${encodeURIComponent(runId)}/resume`, {\n ws: false,\n });\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n if (this.#options.auth.kind === 'bearer') {\n headers.Authorization = `Bearer ${this.#options.auth.token}`;\n }\n if (opts.idempotencyKey !== undefined) headers['Idempotency-Key'] = opts.idempotencyKey;\n const res = await fetchImpl(url, {\n method: 'POST',\n headers,\n body: JSON.stringify(directive === undefined ? {} : { directive }),\n });\n if (!res.ok) {\n throw new TransportFailedError(\n `resume(${runId}): server responded ${res.status} ${res.statusText}.`,\n { code: res.status },\n );\n }\n try {\n return await res.json();\n } catch {\n return undefined;\n }\n }\n\n /**\n * Send an MCP-compatible cancellation notification. Does not wait\n * for a server reply (notifications have no `id`).\n */\n cancelNotify(requestId: string): void {\n if (this.#transport === undefined) throw new ClientNotConnectedError();\n if (typeof requestId !== 'string' || requestId.length === 0) {\n throw new TypeError('cancelNotify: requestId must be a non-empty string.');\n }\n const frame: ClientMessage = {\n v: '1',\n jsonrpc: '2.0',\n method: 'notifications/cancelled',\n params: { requestId },\n };\n this.#transport.send(frame);\n }\n\n /**\n * Disconnect the underlying transport and abort every pending RPC\n * + subscription. Idempotent.\n */\n async disconnect(): Promise<void> {\n if (this.#closed) return;\n this.#closed = true;\n const transport = this.#transport;\n this.#transport = undefined;\n this.#abortController.abort(new ClientAbortedError());\n for (const pending of this.#pending.values()) {\n pending.reject(new ClientAbortedError('Client disconnected before reply.'));\n }\n this.#pending.clear();\n for (const sub of this.#subscriptions.values()) {\n sub.__close('aborted', new ClientAbortedError('Client disconnected.'));\n }\n this.#subscriptions.clear();\n if (transport !== undefined) {\n try {\n transport.close(1000, 'client-disconnect');\n } catch {\n // Best-effort close: never throw from disconnect().\n }\n }\n }\n\n /** Return the active transport kind (or `undefined` if not connected). */\n get transportKind(): TransportKind | undefined {\n return this.#transport?.kind;\n }\n\n // -----------------------------------------------------------------\n // Internal helpers\n // -----------------------------------------------------------------\n\n async #openWs(): Promise<Transport> {\n const url = this.#wsUrl();\n const transport = await openWebSocketTransport(\n {\n url,\n auth: this.#options.auth,\n ...(this.#options.WebSocket !== undefined ? { WebSocket: this.#options.WebSocket } : {}),\n ...(this.#options.clientId !== undefined ? { clientId: this.#options.clientId } : {}),\n },\n this.#listeners('ws'),\n );\n return transport;\n }\n\n async #openSse(): Promise<Transport> {\n if (this.#options.auth.kind !== 'bearer') {\n throw new TransportFailedError(\n \"SSE fallback requires the 'bearer' auth strategy. The WebSocket ticket flow does not extend to EventSource.\",\n );\n }\n const url = this.#sseUrl();\n const transport = await openSseTransport(\n {\n url,\n auth: this.#options.auth,\n // IP-3: the fetch-streaming transport uses the client's fetch\n // seam (the EventSource seam is gone with the rewrite).\n ...(this.#options.fetch !== undefined ? { fetch: this.#options.fetch } : {}),\n ...(this.#options.clientId !== undefined ? { clientId: this.#options.clientId } : {}),\n },\n this.#listeners('sse'),\n );\n return transport;\n }\n\n #wsUrl(): string {\n const path = this.#options.wsPath ?? '/v1/ws';\n return joinUrl(this.#options.baseUrl, path, { ws: true });\n }\n\n #sseUrl(): string {\n const template = this.#options.sseSessionPath ?? '/v1/sessions/:sessionId/events';\n // IP-3: bind the session into the path — the literal template was\n // previously sent as-is, so the SSE fallback could never connect\n // to a real session stream.\n if (template.includes(':sessionId')) {\n const sessionId = this.#options.sessionId;\n if (sessionId === undefined || sessionId.length === 0) {\n throw new TransportFailedError(\n \"The SSE fallback needs a session binding — pass `sessionId` in the client options (it fills ':sessionId' in sseSessionPath).\",\n );\n }\n return joinUrl(\n this.#options.baseUrl,\n template.replace(':sessionId', encodeURIComponent(sessionId)),\n { ws: false },\n );\n }\n return joinUrl(this.#options.baseUrl, template, { ws: false });\n }\n\n #listeners(kind: TransportKind): {\n onOpen(): void;\n onFrame(frame: ServerMessage): void;\n onError(err: Error): void;\n onClose(reason: TransportCloseReason): void;\n } {\n return {\n onOpen: () => {},\n onFrame: (frame) => this.#handleFrame(frame),\n onError: (err) => this.#handleTransportError(err, kind),\n onClose: (reason) => this.#handleTransportClose(reason, kind),\n };\n }\n\n #handleFrame(frame: ServerMessage): void {\n if (isRpcSuccess(frame)) {\n const pending = this.#pending.get(frame.id);\n if (pending !== undefined) {\n this.#pending.delete(frame.id);\n pending.resolve(frame.result);\n }\n return;\n }\n if (isRpcFailure(frame)) {\n const pending = this.#pending.get(frame.id);\n if (pending !== undefined) {\n this.#pending.delete(frame.id);\n // IP-19: surface the server's error class (rate-limited, scope-denied,\n // auth-failed, …) instead of collapsing every RPC failure to\n // 'protocol-violation'.\n pending.reject(\n new GraphorinClientError(\n kindForRpcCode(frame.error.code),\n `Server returned an error for RPC '${frame.id}': ${frame.error.message} (code=${frame.error.code}).`,\n ),\n );\n }\n return;\n }\n if (isSubscribedFrame(frame)) {\n // Subscribed frames are acknowledged by the matching RPC reply.\n return;\n }\n if (isUnsubscribedFrame(frame)) {\n const sub = this.#subscriptions.get(frame.subscriptionId);\n if (sub !== undefined) {\n sub.__close('unsubscribed');\n this.#subscriptions.delete(frame.subscriptionId);\n }\n return;\n }\n if (isPongFrame(frame)) return;\n if (isLifecycleFrame(frame)) {\n const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();\n if (sub !== undefined) sub.__pushLifecycle(frame);\n return;\n }\n if (isReplayMarkerFrame(frame)) {\n const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();\n if (sub !== undefined) sub.__pushReplayMarker(frame);\n return;\n }\n if (isEventFrame(frame)) {\n const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();\n if (sub !== undefined) sub.__push(frame);\n return;\n }\n if (isErrorFrame(frame)) {\n const target =\n frame.subscriptionId !== undefined\n ? this.#subscriptions.get(frame.subscriptionId)\n : undefined;\n const error = new GraphorinClientError(\n 'protocol-violation',\n `Server error frame: ${frame.message} (code=${frame.code}).`,\n );\n if (target !== undefined) {\n target.__close('aborted', error);\n if (frame.subscriptionId !== undefined) this.#subscriptions.delete(frame.subscriptionId);\n }\n }\n }\n\n /**\n * IP-3: on the SSE transport every frame belongs to the single\n * implicit session subscription regardless of the server-generated\n * subscriptionId it carries.\n */\n #sseFallback(): SubscriptionInternal | undefined {\n return this.#transport?.kind === 'sse' ? this.#sseSubscription : undefined;\n }\n\n #handleTransportError(err: Error, _kind: TransportKind): void {\n for (const pending of this.#pending.values()) {\n pending.reject(err);\n }\n this.#pending.clear();\n }\n\n async #handleTransportClose(reason: TransportCloseReason, _kind: TransportKind): Promise<void> {\n if (this.#closed) return;\n this.#transport = undefined;\n const closeError = new TransportFailedError(\n `Transport closed: ${reason.reason || reason.graphorinReason || 'unknown'} (code=${reason.code}).`,\n { code: reason.code },\n );\n // Best-effort drain of pending RPCs so callers see the failure\n // immediately rather than hanging until the reconnect window\n // closes.\n for (const pending of this.#pending.values()) {\n pending.reject(closeError);\n }\n this.#pending.clear();\n if (\n reason.graphorinReason === 'auth.required' ||\n reason.graphorinReason === 'auth.invalid' ||\n reason.graphorinReason === 'auth.revoked' ||\n reason.graphorinReason === 'protocol.violation'\n ) {\n // Fatal: stop trying to reconnect and surface the failure.\n for (const sub of this.#subscriptions.values()) {\n sub.__close('aborted', closeError);\n }\n this.#subscriptions.clear();\n return;\n }\n await this.#reconnect();\n }\n\n async #reconnect(): Promise<void> {\n if (this.#closed) return;\n let attempt = 0;\n while (!this.#closed) {\n attempt += 1;\n const delay = computeBackoffMs(attempt, this.#options.reconnect);\n if (delay === null) {\n const exhausted = new TransportFailedError(\n `Reconnect attempts exhausted after ${attempt - 1} retries.`,\n );\n for (const sub of this.#subscriptions.values()) {\n sub.__close('aborted', exhausted);\n }\n this.#subscriptions.clear();\n return;\n }\n try {\n await sleep(delay, this.#abortController.signal);\n await this.connect();\n // Best-effort resubscribe with the recorded lastEventId so\n // the server replays missed events from the buffer.\n const transport = this.#transport;\n if (transport === undefined) continue;\n for (const [oldId, sub] of [...this.#subscriptions]) {\n this.#subscriptions.delete(oldId);\n try {\n // IP-7: re-establish the server-side subscription with the\n // SUBSCRIPTION's own replay cursor, then re-point the SAME\n // object at the new server id — the consumer's in-flight\n // `for await` survives and the replayed events arrive in\n // the iterator it is already reading. The old code created\n // a NEW subscription and closed this one: the consumer's\n // loop ended `{done: true}` while events piled up unread\n // in an orphan.\n const cursor = sub.__lastEventId();\n const reply = await this.#sendRpc('subscription.subscribe', {\n subject: sub.__subject(),\n ...(cursor !== undefined ? { sinceEventId: cursor } : {}),\n });\n const result = reply as { subscriptionId?: unknown };\n const newId =\n typeof result.subscriptionId === 'string' && result.subscriptionId.length > 0\n ? result.subscriptionId\n : undefined;\n if (newId === undefined) {\n throw new ProtocolViolationError('Server resubscribe reply missing subscriptionId.');\n }\n sub.__rebind(newId);\n this.#subscriptions.set(newId, sub);\n } catch (err) {\n sub.__close('aborted', err instanceof Error ? err : new Error(String(err)));\n }\n }\n return;\n } catch (err) {\n if (this.#closed) return;\n // Loop and retry.\n if (err instanceof ClientAbortedError) return;\n }\n }\n }\n\n #createSubscription(args: {\n subscriptionId: string;\n readonly subject: string;\n readonly target: SubscriptionTarget;\n readonly snapshotEventId: string | undefined;\n }): SubscriptionInternal {\n const queue: ServerEventFrame[] = [];\n const lifecycle: ServerLifecycleFrame[] = [];\n const replayMarkers: ServerReplayMarkerFrame[] = [];\n const waiters: Array<{\n resolve: (value: IteratorResult<ServerEventFrame>) => void;\n reject: (err: Error) => void;\n }> = [];\n let closed = false;\n let closeError: Error | undefined;\n let lastEventId: string | undefined = args.snapshotEventId;\n\n const push = (frame: ServerEventFrame): void => {\n lastEventId = frame.eventId;\n const waiter = waiters.shift();\n if (waiter !== undefined) {\n waiter.resolve({ value: frame, done: false });\n return;\n }\n queue.push(frame);\n };\n\n const close = (reason: 'unsubscribed' | 'transport-closed' | 'aborted', err?: Error): void => {\n if (closed) return;\n closed = true;\n if (err !== undefined) closeError = err;\n else if (reason === 'aborted') closeError = new ClientAbortedError();\n while (waiters.length > 0) {\n const waiter = waiters.shift();\n if (waiter === undefined) continue;\n // IP-19(c): a waiter blocked in next() must observe the SAME outcome a\n // fresh next() would after close — reject with closeError when the\n // stream tore down abnormally, rather than silently resolving done:true\n // and swallowing the transport/abort failure.\n if (closeError !== undefined) {\n waiter.reject(closeError);\n } else {\n waiter.resolve({ value: undefined as unknown as ServerEventFrame, done: true });\n }\n }\n };\n\n const sub: SubscriptionInternal = {\n get subscriptionId(): string {\n return args.subscriptionId;\n },\n subject: args.subject,\n events: () => ({\n [Symbol.asyncIterator]: () => ({\n next: (): Promise<IteratorResult<ServerEventFrame>> => {\n const buffered = queue.shift();\n if (buffered !== undefined) {\n return Promise.resolve({ value: buffered, done: false });\n }\n if (closed) {\n if (closeError !== undefined) return Promise.reject(closeError);\n return Promise.resolve({\n value: undefined as unknown as ServerEventFrame,\n done: true,\n });\n }\n return new Promise<IteratorResult<ServerEventFrame>>((resolve, reject) => {\n waiters.push({ resolve, reject });\n });\n },\n return: (): Promise<IteratorResult<ServerEventFrame>> => {\n close('unsubscribed');\n return Promise.resolve({ value: undefined as unknown as ServerEventFrame, done: true });\n },\n }),\n }),\n unsubscribe: async () => {\n if (closed) return;\n try {\n await this.#sendRpc('subscription.unsubscribe', {\n subscriptionId: args.subscriptionId,\n });\n } catch (err) {\n if (!(err instanceof ClientAbortedError)) throw err;\n }\n close('unsubscribed');\n this.#subscriptions.delete(args.subscriptionId);\n },\n metadata: () => ({\n id: args.subscriptionId,\n subject: args.subject,\n target: args.target,\n snapshotEventId: args.snapshotEventId,\n lastEventId,\n closed,\n }),\n __push: push,\n __pushLifecycle: (frame) => {\n lifecycle.push(frame);\n if (\n frame.status === 'completed' ||\n frame.status === 'aborted' ||\n frame.status === 'failed'\n ) {\n close(frame.status === 'completed' ? 'unsubscribed' : 'aborted');\n this.#subscriptions.delete(args.subscriptionId);\n }\n },\n __pushReplayMarker: (frame) => {\n replayMarkers.push(frame);\n },\n __close: close,\n __subject: () => args.subject,\n __target: () => args.target,\n __snapshotEventId: () => args.snapshotEventId,\n __lastEventId: () => lastEventId,\n __rebind: (newSubscriptionId: string) => {\n args.subscriptionId = newSubscriptionId;\n },\n };\n return sub;\n }\n\n async #sendRpc(method: ClientMessage['method'], params?: object): Promise<unknown> {\n if (this.#transport === undefined) throw new ClientNotConnectedError();\n const id = this.#nextId();\n const frame = buildRpcFrame(method, id, params);\n const timeoutMs = this.#options.rpcTimeoutMs;\n return new Promise((resolve, reject) => {\n // IP-19: arm an optional reply timer. Wrapping resolve/reject so that\n // EVERY settle path (matching reply, disconnect, transport error) clears\n // the timer — otherwise a stray timer could reject an already-settled id.\n let timer: ReturnType<typeof setTimeout> | undefined;\n const clear = (): void => {\n if (timer !== undefined) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n const pending: PendingRpc = {\n resolve: (value) => {\n clear();\n resolve(value);\n },\n reject: (reason) => {\n clear();\n reject(reason);\n },\n };\n this.#pending.set(id, pending);\n if (timeoutMs !== undefined && timeoutMs > 0) {\n timer = setTimeout(() => {\n // Fire only if this exact request is still outstanding.\n if (this.#pending.get(id) === pending) {\n this.#pending.delete(id);\n reject(\n new TransportFailedError(\n `RPC '${method}' (id=${id}) timed out after ${timeoutMs}ms with no server reply.`,\n ),\n );\n }\n }, timeoutMs);\n }\n try {\n // Asserting non-null because we just verified above.\n (this.#transport as Transport).send(frame);\n } catch (err) {\n clear();\n this.#pending.delete(id);\n reject(err);\n }\n });\n }\n\n #nextId(): string {\n this.#idCounter += 1;\n const prefix = this.#options.clientId ?? 'graphorin';\n return `${prefix}-${this.#idCounter.toString(36)}`;\n }\n}\n\ninterface PendingRpc {\n readonly resolve: (value: unknown) => void;\n readonly reject: (reason: Error) => void;\n}\n\nfunction subjectFor(target: SubscriptionTarget): string {\n switch (target.target) {\n case 'session':\n assertNonEmpty(target.id, 'session.id');\n return `session:${target.id}/events`;\n case 'agent':\n assertNonEmpty(target.id, 'agent.id');\n assertNonEmpty(target.runId, 'agent.runId');\n return `agent:${target.id}/runs/${target.runId}/events`;\n case 'run':\n assertNonEmpty(target.runId, 'run.runId');\n if (target.sessionId !== undefined) {\n assertNonEmpty(target.sessionId, 'run.sessionId');\n return `session:${target.sessionId}/runs/${target.runId}/events`;\n }\n return `run:${target.runId}/events`;\n case 'workflow':\n assertNonEmpty(target.id, 'workflow.id');\n return `workflow:${target.id}/events`;\n }\n}\n\nfunction assertNonEmpty(value: string, fieldName: string): void {\n if (typeof value !== 'string' || value.length === 0) {\n throw new TypeError(`${fieldName} must be a non-empty string.`);\n }\n}\n\nfunction joinUrl(baseUrl: string, path: string, opts: { readonly ws: boolean }): string {\n let normalized = baseUrl;\n if (opts.ws) {\n if (normalized.startsWith('http://')) normalized = normalized.replace(/^http:\\/\\//, 'ws://');\n else if (normalized.startsWith('https://'))\n normalized = normalized.replace(/^https:\\/\\//, 'wss://');\n } else {\n if (normalized.startsWith('ws://')) normalized = normalized.replace(/^ws:\\/\\//, 'http://');\n else if (normalized.startsWith('wss://'))\n normalized = normalized.replace(/^wss:\\/\\//, 'https://');\n }\n if (normalized.endsWith('/')) normalized = normalized.slice(0, -1);\n const tail = path.startsWith('/') ? path : `/${path}`;\n return `${normalized}${tail}`;\n}\n\nfunction buildRpcFrame(\n method: ClientMessage['method'],\n id: ClientMessageId,\n params?: object,\n): ClientMessage {\n switch (method) {\n case 'initialize':\n return {\n v: '1',\n jsonrpc: '2.0',\n id,\n method: 'initialize',\n params: (params ?? { clientInfo: { name: 'graphorin-client', version: '0.5.0' } }) as never,\n };\n case 'subscription.subscribe':\n return {\n v: '1',\n jsonrpc: '2.0',\n id,\n method: 'subscription.subscribe',\n params: (params ?? { subject: '' }) as never,\n };\n case 'subscription.unsubscribe':\n return {\n v: '1',\n jsonrpc: '2.0',\n id,\n method: 'subscription.unsubscribe',\n params: (params ?? { subscriptionId: '' }) as never,\n };\n case 'run.cancel':\n return {\n v: '1',\n jsonrpc: '2.0',\n id,\n method: 'run.cancel',\n params: (params ?? { runId: '' }) as never,\n };\n case 'ping':\n return {\n v: '1',\n jsonrpc: '2.0',\n id,\n method: 'ping',\n ...(params !== undefined ? { params: params as never } : {}),\n };\n case 'notifications/cancelled':\n throw new TypeError(\n \"buildRpcFrame: 'notifications/cancelled' is a notification, not an RPC.\",\n );\n }\n // Exhaustiveness check; should be unreachable.\n throw new TypeError(`Unknown RPC method '${method}'.`);\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AuthFailedError, ClientAbortedError, ClientNotConnectedError, GraphorinClientError, GraphorinClientErrorKind, InvalidServerFrameError, ProtocolViolationError, SubprotocolMismatchError, SubscriptionNotFoundError, TransportFailedError, kindForRpcCode } from "./errors.js";
|
|
2
|
+
import { BackoffPolicy, computeBackoffMs, sleep } from "./reconnect.js";
|
|
3
|
+
import { Transport, TransportAuth, TransportCloseReason, TransportKind, TransportListeners, TransportOptions } from "./transport/types.js";
|
|
4
|
+
import { openSseTransport } from "./transport/sse.js";
|
|
5
|
+
import { openWebSocketTransport } from "./transport/ws.js";
|
|
6
|
+
import "./transport/index.js";
|
|
7
|
+
import { GraphorinClient, GraphorinClientOptions, Subscription, SubscriptionMetadata, SubscriptionTarget, TransportPreference } from "./graphorin-client.js";
|
|
8
|
+
|
|
9
|
+
//#region src/index.d.ts
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `@graphorin/client` — reference TypeScript client for the
|
|
13
|
+
* Graphorin standalone server.
|
|
14
|
+
*
|
|
15
|
+
* Wraps the WebSocket subprotocol `graphorin.protocol.v1` (with an
|
|
16
|
+
* optional Server-Sent Events fallback for proxy-restricted
|
|
17
|
+
* environments) behind an ergonomic {@link GraphorinClient} class:
|
|
18
|
+
* `connect()`, `subscribe({ target, id })` returning an
|
|
19
|
+
* async-iterable subscription, `cancel(runId, opts)`,
|
|
20
|
+
* `disconnect()`. Handles the browser ticket flow, exponential-
|
|
21
|
+
* backoff reconnect with `lastEventId` resume against the server
|
|
22
|
+
* replay buffer, and Zod-validated frame parsing on both
|
|
23
|
+
* directions.
|
|
24
|
+
*
|
|
25
|
+
* Browser-friendly: zero Node-only dependencies; the only runtime
|
|
26
|
+
* dependencies are `@graphorin/protocol` and `zod`.
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
/** Canonical version constant. Mirrors the `package.json` version. */
|
|
31
|
+
declare const VERSION = "0.5.0";
|
|
32
|
+
//#endregion
|
|
33
|
+
export { AuthFailedError, type BackoffPolicy, ClientAbortedError, ClientNotConnectedError, GraphorinClient, GraphorinClientError, GraphorinClientErrorKind, type GraphorinClientOptions, InvalidServerFrameError, ProtocolViolationError, SubprotocolMismatchError, type Subscription, type SubscriptionMetadata, SubscriptionNotFoundError, type SubscriptionTarget, type Transport, type TransportAuth, type TransportCloseReason, TransportFailedError, type TransportKind, type TransportListeners, type TransportOptions, type TransportPreference, VERSION, computeBackoffMs, kindForRpcCode, openSseTransport, openWebSocketTransport, sleep };
|
|
34
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;;;;;AAqBA;;;;;;;;;;;cAAa,OAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AuthFailedError, ClientAbortedError, ClientNotConnectedError, GraphorinClientError, InvalidServerFrameError, ProtocolViolationError, SubprotocolMismatchError, SubscriptionNotFoundError, TransportFailedError, kindForRpcCode } from "./errors.js";
|
|
2
|
+
import { computeBackoffMs, sleep } from "./reconnect.js";
|
|
3
|
+
import { openSseTransport } from "./transport/sse.js";
|
|
4
|
+
import { openWebSocketTransport } from "./transport/ws.js";
|
|
5
|
+
import { GraphorinClient } from "./graphorin-client.js";
|
|
6
|
+
|
|
7
|
+
//#region src/index.ts
|
|
8
|
+
/**
|
|
9
|
+
* `@graphorin/client` — reference TypeScript client for the
|
|
10
|
+
* Graphorin standalone server.
|
|
11
|
+
*
|
|
12
|
+
* Wraps the WebSocket subprotocol `graphorin.protocol.v1` (with an
|
|
13
|
+
* optional Server-Sent Events fallback for proxy-restricted
|
|
14
|
+
* environments) behind an ergonomic {@link GraphorinClient} class:
|
|
15
|
+
* `connect()`, `subscribe({ target, id })` returning an
|
|
16
|
+
* async-iterable subscription, `cancel(runId, opts)`,
|
|
17
|
+
* `disconnect()`. Handles the browser ticket flow, exponential-
|
|
18
|
+
* backoff reconnect with `lastEventId` resume against the server
|
|
19
|
+
* replay buffer, and Zod-validated frame parsing on both
|
|
20
|
+
* directions.
|
|
21
|
+
*
|
|
22
|
+
* Browser-friendly: zero Node-only dependencies; the only runtime
|
|
23
|
+
* dependencies are `@graphorin/protocol` and `zod`.
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
/** Canonical version constant. Mirrors the `package.json` version. */
|
|
28
|
+
const VERSION = "0.5.0";
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { AuthFailedError, ClientAbortedError, ClientNotConnectedError, GraphorinClient, GraphorinClientError, InvalidServerFrameError, ProtocolViolationError, SubprotocolMismatchError, SubscriptionNotFoundError, TransportFailedError, VERSION, computeBackoffMs, kindForRpcCode, openSseTransport, openWebSocketTransport, sleep };
|
|
32
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * `@graphorin/client` — reference TypeScript client for the\n * Graphorin standalone server.\n *\n * Wraps the WebSocket subprotocol `graphorin.protocol.v1` (with an\n * optional Server-Sent Events fallback for proxy-restricted\n * environments) behind an ergonomic {@link GraphorinClient} class:\n * `connect()`, `subscribe({ target, id })` returning an\n * async-iterable subscription, `cancel(runId, opts)`,\n * `disconnect()`. Handles the browser ticket flow, exponential-\n * backoff reconnect with `lastEventId` resume against the server\n * replay buffer, and Zod-validated frame parsing on both\n * directions.\n *\n * Browser-friendly: zero Node-only dependencies; the only runtime\n * dependencies are `@graphorin/protocol` and `zod`.\n *\n * @packageDocumentation\n */\n\n/** Canonical version constant. Mirrors the `package.json` version. */\nexport const VERSION = '0.5.0';\n\nexport * from './errors.js';\nexport {\n GraphorinClient,\n type GraphorinClientOptions,\n type Subscription,\n type SubscriptionMetadata,\n type SubscriptionTarget,\n type TransportPreference,\n} from './graphorin-client.js';\nexport {\n type BackoffPolicy,\n computeBackoffMs,\n sleep,\n} from './reconnect.js';\nexport {\n openSseTransport,\n openWebSocketTransport,\n type Transport,\n type TransportAuth,\n type TransportCloseReason,\n type TransportKind,\n type TransportListeners,\n type TransportOptions,\n} from './transport/index.js';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,UAAU"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/reconnect.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Pure-functional reconnect-backoff helper. Encapsulated in its own
|
|
4
|
+
* module so the {@link GraphorinClient} stays free of timing
|
|
5
|
+
* heuristics — and so tests can drive the policy with a deterministic
|
|
6
|
+
* RNG.
|
|
7
|
+
*
|
|
8
|
+
* Algorithm: exponential backoff with full-jitter
|
|
9
|
+
* (`delay = random(0, min(maxMs, baseMs * 2^attempt))`). The
|
|
10
|
+
* implementation matches the AWS Architecture Blog "exponential
|
|
11
|
+
* backoff and jitter" reference but is otherwise an original
|
|
12
|
+
* formulation.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Stable shape consumed by {@link computeBackoffMs}.
|
|
18
|
+
*
|
|
19
|
+
* @stable
|
|
20
|
+
*/
|
|
21
|
+
interface BackoffPolicy {
|
|
22
|
+
/** Initial slot in milliseconds. Default `500`. */
|
|
23
|
+
readonly baseMs?: number;
|
|
24
|
+
/** Cap on every individual sleep. Default `30_000`. */
|
|
25
|
+
readonly maxMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Hard cap on the number of attempts. The client surfaces a
|
|
28
|
+
* `TransportFailedError` once exceeded. Default `Infinity`.
|
|
29
|
+
*/
|
|
30
|
+
readonly maxAttempts?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optional injection seam used by tests; defaults to `Math.random`.
|
|
33
|
+
*/
|
|
34
|
+
readonly random?: () => number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compute the number of milliseconds to sleep before the
|
|
38
|
+
* `attempt`-th reconnect (1-indexed). Returns `null` when the policy
|
|
39
|
+
* has been exhausted (`attempt > maxAttempts`).
|
|
40
|
+
*
|
|
41
|
+
* @stable
|
|
42
|
+
*/
|
|
43
|
+
declare function computeBackoffMs(attempt: number, policy?: BackoffPolicy): number | null;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve when the requested number of milliseconds elapsed, or
|
|
46
|
+
* reject (with a {@link DOMException}-style abort error) when the
|
|
47
|
+
* supplied {@link AbortSignal} fires first.
|
|
48
|
+
*
|
|
49
|
+
* @stable
|
|
50
|
+
*/
|
|
51
|
+
declare function sleep(durationMs: number, signal?: AbortSignal): Promise<void>;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { BackoffPolicy, computeBackoffMs, sleep };
|
|
54
|
+
//# sourceMappingURL=reconnect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconnect.d.ts","names":[],"sources":["../src/reconnect.ts"],"sourcesContent":[],"mappings":";;AAoBA;AAuBA;AAqBA;;;;;;;;;;;;;;;;UA5CiB,aAAA;;;;;;;;;;;;;;;;;;;;;;iBAuBD,gBAAA,2BAA0C;;;;;;;;iBAqB1C,KAAA,8BAAmC,cAAc"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//#region src/reconnect.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compute the number of milliseconds to sleep before the
|
|
4
|
+
* `attempt`-th reconnect (1-indexed). Returns `null` when the policy
|
|
5
|
+
* has been exhausted (`attempt > maxAttempts`).
|
|
6
|
+
*
|
|
7
|
+
* @stable
|
|
8
|
+
*/
|
|
9
|
+
function computeBackoffMs(attempt, policy = {}) {
|
|
10
|
+
if (!Number.isFinite(attempt) || attempt < 1) throw new RangeError("computeBackoffMs: attempt must be a positive integer.");
|
|
11
|
+
const baseMs = policy.baseMs ?? 500;
|
|
12
|
+
const maxMs = policy.maxMs ?? 3e4;
|
|
13
|
+
const maxAttempts = policy.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
14
|
+
const random = policy.random ?? Math.random;
|
|
15
|
+
if (attempt > maxAttempts) return null;
|
|
16
|
+
const exp = Math.min(maxMs, baseMs * 2 ** Math.min(attempt - 1, 30));
|
|
17
|
+
return Math.floor(random() * exp);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve when the requested number of milliseconds elapsed, or
|
|
21
|
+
* reject (with a {@link DOMException}-style abort error) when the
|
|
22
|
+
* supplied {@link AbortSignal} fires first.
|
|
23
|
+
*
|
|
24
|
+
* @stable
|
|
25
|
+
*/
|
|
26
|
+
function sleep(durationMs, signal) {
|
|
27
|
+
if (signal?.aborted) return Promise.reject(abortError(signal.reason));
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const timeout = setTimeout(() => {
|
|
30
|
+
cleanup();
|
|
31
|
+
resolve();
|
|
32
|
+
}, durationMs);
|
|
33
|
+
const onAbort = () => {
|
|
34
|
+
cleanup();
|
|
35
|
+
reject(abortError(signal?.reason));
|
|
36
|
+
};
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
signal?.removeEventListener("abort", onAbort);
|
|
40
|
+
};
|
|
41
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function abortError(reason) {
|
|
45
|
+
if (reason instanceof Error) return reason;
|
|
46
|
+
const err = /* @__PURE__ */ new Error("Aborted.");
|
|
47
|
+
err.name = "AbortError";
|
|
48
|
+
return err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { computeBackoffMs, sleep };
|
|
53
|
+
//# sourceMappingURL=reconnect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconnect.js","names":[],"sources":["../src/reconnect.ts"],"sourcesContent":["/**\n * Pure-functional reconnect-backoff helper. Encapsulated in its own\n * module so the {@link GraphorinClient} stays free of timing\n * heuristics — and so tests can drive the policy with a deterministic\n * RNG.\n *\n * Algorithm: exponential backoff with full-jitter\n * (`delay = random(0, min(maxMs, baseMs * 2^attempt))`). The\n * implementation matches the AWS Architecture Blog \"exponential\n * backoff and jitter\" reference but is otherwise an original\n * formulation.\n *\n * @packageDocumentation\n */\n\n/**\n * Stable shape consumed by {@link computeBackoffMs}.\n *\n * @stable\n */\nexport interface BackoffPolicy {\n /** Initial slot in milliseconds. Default `500`. */\n readonly baseMs?: number;\n /** Cap on every individual sleep. Default `30_000`. */\n readonly maxMs?: number;\n /**\n * Hard cap on the number of attempts. The client surfaces a\n * `TransportFailedError` once exceeded. Default `Infinity`.\n */\n readonly maxAttempts?: number;\n /**\n * Optional injection seam used by tests; defaults to `Math.random`.\n */\n readonly random?: () => number;\n}\n\n/**\n * Compute the number of milliseconds to sleep before the\n * `attempt`-th reconnect (1-indexed). Returns `null` when the policy\n * has been exhausted (`attempt > maxAttempts`).\n *\n * @stable\n */\nexport function computeBackoffMs(attempt: number, policy: BackoffPolicy = {}): number | null {\n if (!Number.isFinite(attempt) || attempt < 1) {\n throw new RangeError('computeBackoffMs: attempt must be a positive integer.');\n }\n const baseMs = policy.baseMs ?? 500;\n const maxMs = policy.maxMs ?? 30_000;\n const maxAttempts = policy.maxAttempts ?? Number.POSITIVE_INFINITY;\n const random = policy.random ?? Math.random;\n if (attempt > maxAttempts) return null;\n // Exponential growth with full jitter, clamped to maxMs.\n const exp = Math.min(maxMs, baseMs * 2 ** Math.min(attempt - 1, 30));\n return Math.floor(random() * exp);\n}\n\n/**\n * Resolve when the requested number of milliseconds elapsed, or\n * reject (with a {@link DOMException}-style abort error) when the\n * supplied {@link AbortSignal} fires first.\n *\n * @stable\n */\nexport function sleep(durationMs: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(abortError(signal.reason));\n }\n return new Promise<void>((resolve, reject) => {\n const timeout = setTimeout(() => {\n cleanup();\n resolve();\n }, durationMs);\n const onAbort = (): void => {\n cleanup();\n reject(abortError(signal?.reason));\n };\n const cleanup = (): void => {\n clearTimeout(timeout);\n signal?.removeEventListener('abort', onAbort);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction abortError(reason: unknown): Error {\n if (reason instanceof Error) return reason;\n const err = new Error('Aborted.');\n err.name = 'AbortError';\n return err;\n}\n"],"mappings":";;;;;;;;AA2CA,SAAgB,iBAAiB,SAAiB,SAAwB,EAAE,EAAiB;AAC3F,KAAI,CAAC,OAAO,SAAS,QAAQ,IAAI,UAAU,EACzC,OAAM,IAAI,WAAW,wDAAwD;CAE/E,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,QAAQ,OAAO,SAAS;CAC9B,MAAM,cAAc,OAAO,eAAe,OAAO;CACjD,MAAM,SAAS,OAAO,UAAU,KAAK;AACrC,KAAI,UAAU,YAAa,QAAO;CAElC,MAAM,MAAM,KAAK,IAAI,OAAO,SAAS,KAAK,KAAK,IAAI,UAAU,GAAG,GAAG,CAAC;AACpE,QAAO,KAAK,MAAM,QAAQ,GAAG,IAAI;;;;;;;;;AAUnC,SAAgB,MAAM,YAAoB,QAAqC;AAC7E,KAAI,QAAQ,QACV,QAAO,QAAQ,OAAO,WAAW,OAAO,OAAO,CAAC;AAElD,QAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,MAAM,UAAU,iBAAiB;AAC/B,YAAS;AACT,YAAS;KACR,WAAW;EACd,MAAM,gBAAsB;AAC1B,YAAS;AACT,UAAO,WAAW,QAAQ,OAAO,CAAC;;EAEpC,MAAM,gBAAsB;AAC1B,gBAAa,QAAQ;AACrB,WAAQ,oBAAoB,SAAS,QAAQ;;AAE/C,UAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM,CAAC;GAC1D;;AAGJ,SAAS,WAAW,QAAwB;AAC1C,KAAI,kBAAkB,MAAO,QAAO;CACpC,MAAM,sBAAM,IAAI,MAAM,WAAW;AACjC,KAAI,OAAO;AACX,QAAO"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Transport, TransportAuth, TransportCloseReason, TransportKind, TransportListeners, TransportOptions } from "./types.js";
|
|
2
|
+
import { openSseTransport } from "./sse.js";
|
|
3
|
+
import { openWebSocketTransport } from "./ws.js";
|
|
4
|
+
export { type Transport, type TransportAuth, type TransportCloseReason, type TransportKind, type TransportListeners, type TransportOptions, openSseTransport, openWebSocketTransport };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Transport, TransportListeners, TransportOptions } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/transport/sse.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Open an SSE transport. Resolves once the server answers with a
|
|
7
|
+
* streaming response; rejects with a typed
|
|
8
|
+
* {@link TransportFailedError} on construction / connection failure.
|
|
9
|
+
*
|
|
10
|
+
* @stable
|
|
11
|
+
*/
|
|
12
|
+
declare function openSseTransport(options: TransportOptions, listeners: TransportListeners): Promise<Transport>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { openSseTransport };
|
|
15
|
+
//# sourceMappingURL=sse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.d.ts","names":[],"sources":["../../src/transport/sse.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;iBA8BsB,gBAAA,UACX,6BACE,qBACV,QAAQ"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { InvalidServerFrameError, TransportFailedError } from "../errors.js";
|
|
2
|
+
import { ServerMessageSchema } from "@graphorin/protocol";
|
|
3
|
+
|
|
4
|
+
//#region src/transport/sse.ts
|
|
5
|
+
/**
|
|
6
|
+
* Server-Sent-Events transport — the read-only fallback for clients
|
|
7
|
+
* stuck behind proxies that block WebSocket upgrades. The transport
|
|
8
|
+
* is unidirectional (server → client only); control-plane operations
|
|
9
|
+
* (subscribe / abort / resume) must go through REST endpoints
|
|
10
|
+
* exposed by `@graphorin/server`.
|
|
11
|
+
*
|
|
12
|
+
* IP-3: implemented on **fetch streaming**, not `EventSource`. The
|
|
13
|
+
* server writes *named* events (`event: <frame.type>`), which an
|
|
14
|
+
* `EventSource` delivers only to listeners registered per type — an
|
|
15
|
+
* unbounded catalogue the old `message`-only listener never saw.
|
|
16
|
+
* Every frame's full JSON rides in `data:`, so a direct SSE parse
|
|
17
|
+
* that ignores the event name delivers everything — and `fetch`
|
|
18
|
+
* sends the `Authorization` header natively (no polyfill seam).
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Open an SSE transport. Resolves once the server answers with a
|
|
24
|
+
* streaming response; rejects with a typed
|
|
25
|
+
* {@link TransportFailedError} on construction / connection failure.
|
|
26
|
+
*
|
|
27
|
+
* @stable
|
|
28
|
+
*/
|
|
29
|
+
async function openSseTransport(options, listeners) {
|
|
30
|
+
if (options.auth.kind !== "bearer") throw new TransportFailedError(`SSE transport supports only the 'bearer' auth strategy; got '${options.auth.kind}'.`);
|
|
31
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
32
|
+
if (typeof fetchImpl !== "function") throw new TransportFailedError("No fetch implementation available for the SSE transport.");
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
let resp;
|
|
35
|
+
try {
|
|
36
|
+
resp = await fetchImpl(options.url, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers: {
|
|
39
|
+
Accept: "text/event-stream",
|
|
40
|
+
Authorization: `Bearer ${options.auth.token}`
|
|
41
|
+
},
|
|
42
|
+
signal: controller.signal
|
|
43
|
+
});
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
throw new TransportFailedError(`SSE connection to '${options.url}' failed.`, cause instanceof Error ? { cause } : {});
|
|
46
|
+
}
|
|
47
|
+
if (!resp.ok || resp.body === null) throw new TransportFailedError(`SSE endpoint '${options.url}' answered ${resp.status}${resp.body === null ? " with no body" : ""}.`);
|
|
48
|
+
let lastEventId;
|
|
49
|
+
let closeFired = false;
|
|
50
|
+
const close = (reason) => {
|
|
51
|
+
if (closeFired) return;
|
|
52
|
+
closeFired = true;
|
|
53
|
+
listeners.onClose(reason);
|
|
54
|
+
};
|
|
55
|
+
(async () => {
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
let buffer = "";
|
|
58
|
+
let dataLines = [];
|
|
59
|
+
let pendingId;
|
|
60
|
+
const dispatch = () => {
|
|
61
|
+
if (dataLines.length === 0) {
|
|
62
|
+
pendingId = void 0;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const raw = dataLines.join("\n");
|
|
66
|
+
dataLines = [];
|
|
67
|
+
const id = pendingId;
|
|
68
|
+
pendingId = void 0;
|
|
69
|
+
let payload;
|
|
70
|
+
try {
|
|
71
|
+
payload = JSON.parse(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const parsed = ServerMessageSchema.safeParse(payload);
|
|
76
|
+
if (!parsed.success) {
|
|
77
|
+
listeners.onError(new InvalidServerFrameError("Server SSE frame failed schema validation.", parsed.error.issues.map((i) => ({
|
|
78
|
+
path: i.path,
|
|
79
|
+
message: i.message
|
|
80
|
+
}))));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const frame = parsed.data;
|
|
84
|
+
const eventId = frame.eventId ?? id;
|
|
85
|
+
if (typeof eventId === "string" && eventId.length > 0) lastEventId = eventId;
|
|
86
|
+
listeners.onFrame(frame);
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
for await (const chunk of resp.body) {
|
|
90
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
91
|
+
for (;;) {
|
|
92
|
+
const nl = buffer.indexOf("\n");
|
|
93
|
+
if (nl === -1) break;
|
|
94
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
95
|
+
buffer = buffer.slice(nl + 1);
|
|
96
|
+
if (line.length === 0) {
|
|
97
|
+
dispatch();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (line.startsWith(":")) continue;
|
|
101
|
+
if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
102
|
+
else if (line.startsWith("id:")) pendingId = line.slice(3).trim();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
close({
|
|
106
|
+
code: 1e3,
|
|
107
|
+
reason: "stream ended",
|
|
108
|
+
wasClean: true
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (controller.signal.aborted) {
|
|
112
|
+
close({
|
|
113
|
+
code: 1e3,
|
|
114
|
+
reason: "closed by client",
|
|
115
|
+
wasClean: true
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
close({
|
|
120
|
+
code: 1006,
|
|
121
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
122
|
+
wasClean: false
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
listeners.onOpen();
|
|
127
|
+
return {
|
|
128
|
+
kind: "sse",
|
|
129
|
+
url: options.url,
|
|
130
|
+
send: () => {
|
|
131
|
+
throw new TransportFailedError("The SSE transport is read-only; control-plane calls go through REST.");
|
|
132
|
+
},
|
|
133
|
+
close: () => {
|
|
134
|
+
controller.abort();
|
|
135
|
+
close({
|
|
136
|
+
code: 1e3,
|
|
137
|
+
reason: "closed by client",
|
|
138
|
+
wasClean: true
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
get lastEventId() {
|
|
142
|
+
return lastEventId;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
export { openSseTransport };
|
|
149
|
+
//# sourceMappingURL=sse.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","names":["resp: Response","lastEventId: string | undefined","dataLines: string[]","pendingId: string | undefined","payload: unknown"],"sources":["../../src/transport/sse.ts"],"sourcesContent":["/**\n * Server-Sent-Events transport — the read-only fallback for clients\n * stuck behind proxies that block WebSocket upgrades. The transport\n * is unidirectional (server → client only); control-plane operations\n * (subscribe / abort / resume) must go through REST endpoints\n * exposed by `@graphorin/server`.\n *\n * IP-3: implemented on **fetch streaming**, not `EventSource`. The\n * server writes *named* events (`event: <frame.type>`), which an\n * `EventSource` delivers only to listeners registered per type — an\n * unbounded catalogue the old `message`-only listener never saw.\n * Every frame's full JSON rides in `data:`, so a direct SSE parse\n * that ignores the event name delivers everything — and `fetch`\n * sends the `Authorization` header natively (no polyfill seam).\n *\n * @packageDocumentation\n */\n\nimport { ServerMessageSchema } from '@graphorin/protocol';\n\nimport { InvalidServerFrameError, TransportFailedError } from '../errors.js';\nimport type { Transport, TransportListeners, TransportOptions } from './types.js';\n\n/**\n * Open an SSE transport. Resolves once the server answers with a\n * streaming response; rejects with a typed\n * {@link TransportFailedError} on construction / connection failure.\n *\n * @stable\n */\nexport async function openSseTransport(\n options: TransportOptions,\n listeners: TransportListeners,\n): Promise<Transport> {\n if (options.auth.kind !== 'bearer') {\n throw new TransportFailedError(\n `SSE transport supports only the 'bearer' auth strategy; got '${options.auth.kind}'.`,\n );\n }\n const fetchImpl = options.fetch ?? globalThis.fetch;\n if (typeof fetchImpl !== 'function') {\n throw new TransportFailedError('No fetch implementation available for the SSE transport.');\n }\n const controller = new AbortController();\n let resp: Response;\n try {\n resp = await fetchImpl(options.url, {\n method: 'GET',\n headers: {\n Accept: 'text/event-stream',\n Authorization: `Bearer ${options.auth.token}`,\n },\n signal: controller.signal,\n });\n } catch (cause) {\n throw new TransportFailedError(\n `SSE connection to '${options.url}' failed.`,\n cause instanceof Error ? { cause } : {},\n );\n }\n if (!resp.ok || resp.body === null) {\n throw new TransportFailedError(\n `SSE endpoint '${options.url}' answered ${resp.status}${resp.body === null ? ' with no body' : ''}.`,\n );\n }\n\n let lastEventId: string | undefined;\n let closeFired = false;\n const close = (reason: { code: number; reason: string; wasClean: boolean }): void => {\n if (closeFired) return;\n closeFired = true;\n listeners.onClose(reason);\n };\n\n // Consume the stream in the background: split on newlines, parse\n // `id:` / `data:` fields, dispatch on blank lines. The `event:`\n // name is intentionally ignored — the full frame JSON is in `data:`.\n void (async () => {\n const decoder = new TextDecoder();\n let buffer = '';\n let dataLines: string[] = [];\n let pendingId: string | undefined;\n\n const dispatch = (): void => {\n if (dataLines.length === 0) {\n pendingId = undefined;\n return;\n }\n const raw = dataLines.join('\\n');\n dataLines = [];\n const id = pendingId;\n pendingId = undefined;\n let payload: unknown;\n try {\n payload = JSON.parse(raw);\n } catch {\n // Keep-alive / non-JSON data is not a frame.\n return;\n }\n const parsed = ServerMessageSchema.safeParse(payload);\n if (!parsed.success) {\n listeners.onError(\n new InvalidServerFrameError(\n 'Server SSE frame failed schema validation.',\n parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),\n ),\n );\n return;\n }\n const frame = parsed.data;\n const eventId = (frame as { eventId?: string }).eventId ?? id;\n if (typeof eventId === 'string' && eventId.length > 0) lastEventId = eventId;\n listeners.onFrame(frame);\n };\n\n try {\n for await (const chunk of resp.body as unknown as AsyncIterable<Uint8Array>) {\n buffer += decoder.decode(chunk, { stream: true });\n for (;;) {\n const nl = buffer.indexOf('\\n');\n if (nl === -1) break;\n const line = buffer.slice(0, nl).replace(/\\r$/, '');\n buffer = buffer.slice(nl + 1);\n if (line.length === 0) {\n dispatch();\n continue;\n }\n if (line.startsWith(':')) continue; // comment / keep-alive\n if (line.startsWith('data:')) {\n dataLines.push(line.slice(5).replace(/^ /, ''));\n } else if (line.startsWith('id:')) {\n pendingId = line.slice(3).trim();\n }\n // `event:` / `retry:` fields are intentionally ignored.\n }\n }\n close({ code: 1000, reason: 'stream ended', wasClean: true });\n } catch (err) {\n if (controller.signal.aborted) {\n close({ code: 1000, reason: 'closed by client', wasClean: true });\n return;\n }\n close({\n code: 1006,\n reason: err instanceof Error ? err.message : String(err),\n wasClean: false,\n });\n }\n })();\n\n listeners.onOpen();\n const transport: Transport = {\n kind: 'sse',\n url: options.url,\n send: () => {\n throw new TransportFailedError(\n 'The SSE transport is read-only; control-plane calls go through REST.',\n );\n },\n close: () => {\n controller.abort();\n close({ code: 1000, reason: 'closed by client', wasClean: true });\n },\n get lastEventId(): string | undefined {\n return lastEventId;\n },\n };\n return transport;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ClientMessage, ServerMessage } from "@graphorin/protocol";
|
|
2
|
+
|
|
3
|
+
//#region src/transport/types.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Discriminator used by the public client API + diagnostics.
|
|
7
|
+
*
|
|
8
|
+
* @stable
|
|
9
|
+
*/
|
|
10
|
+
type TransportKind = 'ws' | 'sse';
|
|
11
|
+
/**
|
|
12
|
+
* Stable shape consumed by every transport implementation.
|
|
13
|
+
*
|
|
14
|
+
* @stable
|
|
15
|
+
*/
|
|
16
|
+
interface TransportOptions {
|
|
17
|
+
readonly url: string;
|
|
18
|
+
readonly auth: TransportAuth;
|
|
19
|
+
readonly fetch?: typeof fetch;
|
|
20
|
+
readonly WebSocket?: typeof WebSocket;
|
|
21
|
+
readonly EventSource?: typeof EventSource;
|
|
22
|
+
/** Per-connection identifier surfaced on diagnostics + reconnects. */
|
|
23
|
+
readonly clientId?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Authentication strategy passed to a transport. The bearer-token
|
|
27
|
+
* shape is consumed by both transports (WS via the
|
|
28
|
+
* `Authorization` header on Node SDK clients; SSE via the
|
|
29
|
+
* `Authorization` header on every fetch); the ticket-provider shape
|
|
30
|
+
* is only used by the WS browser path (the WebSocket browser API
|
|
31
|
+
* does not allow custom headers, so the ticket rides the
|
|
32
|
+
* `Sec-WebSocket-Protocol` header).
|
|
33
|
+
*
|
|
34
|
+
* @stable
|
|
35
|
+
*/
|
|
36
|
+
type TransportAuth = {
|
|
37
|
+
readonly kind: 'bearer';
|
|
38
|
+
readonly token: string;
|
|
39
|
+
} | {
|
|
40
|
+
readonly kind: 'ticket';
|
|
41
|
+
readonly ticketProvider: () => Promise<string>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Minimal listener surface the transport invokes in lifecycle order:
|
|
45
|
+
* `onOpen` ⇒ `onFrame*` (zero or more) ⇒ `onClose`. `onError` may
|
|
46
|
+
* fire at any time before `onClose`.
|
|
47
|
+
*
|
|
48
|
+
* @stable
|
|
49
|
+
*/
|
|
50
|
+
interface TransportListeners {
|
|
51
|
+
onOpen(): void;
|
|
52
|
+
onFrame(frame: ServerMessage): void;
|
|
53
|
+
onError(error: Error): void;
|
|
54
|
+
onClose(reason: TransportCloseReason): void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Discriminator returned via {@link TransportListeners.onClose}. The
|
|
58
|
+
* client uses the discriminator to decide whether to reconnect.
|
|
59
|
+
*
|
|
60
|
+
* @stable
|
|
61
|
+
*/
|
|
62
|
+
interface TransportCloseReason {
|
|
63
|
+
readonly code: number;
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
/** True when the close came from a server-side `error` frame. */
|
|
66
|
+
readonly wasClean: boolean;
|
|
67
|
+
/** Optional Graphorin reason discriminator, per `@graphorin/protocol`. */
|
|
68
|
+
readonly graphorinReason?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Active transport handle. The client owns the handle and disposes
|
|
72
|
+
* it via {@link Transport.close} on every cleanup path.
|
|
73
|
+
*
|
|
74
|
+
* @stable
|
|
75
|
+
*/
|
|
76
|
+
interface Transport {
|
|
77
|
+
readonly kind: TransportKind;
|
|
78
|
+
readonly url: string;
|
|
79
|
+
/**
|
|
80
|
+
* Send a client → server frame. Throws when the transport is not
|
|
81
|
+
* in the open state, or when the underlying back-end does not
|
|
82
|
+
* support send (the SSE transport throws every send via
|
|
83
|
+
* {@link import('../errors.js').TransportFailedError} — clients
|
|
84
|
+
* should fall back to REST for control-plane operations on SSE).
|
|
85
|
+
*/
|
|
86
|
+
send(frame: ClientMessage): void;
|
|
87
|
+
close(code?: number, reason?: string): void;
|
|
88
|
+
/** Last server-issued event id observed on this connection. */
|
|
89
|
+
readonly lastEventId: string | undefined;
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { Transport, TransportAuth, TransportCloseReason, TransportKind, TransportListeners, TransportOptions };
|
|
93
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../../src/transport/types.ts"],"sourcesContent":[],"mappings":";;;;;AA4CA;AAWA;;;AAIkB,KA3CN,aAAA,GA2CM,IAAA,GAAA,KAAA;;AASlB;AAeA;;;UA5DiB,gBAAA;;iBAEA;0BACS;8BACI;gCACE;;;;;;;;;;;;;;;KAgBpB,aAAA;;;;;iCAEkD;;;;;;;;;UAS7C,kBAAA;;iBAEA;iBACA;kBACC;;;;;;;;UASD,oBAAA;;;;;;;;;;;;;;UAeA,SAAA;iBACA;;;;;;;;;cASH"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Transport, TransportListeners, TransportOptions } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/transport/ws.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Open a WebSocket transport. Resolves once the underlying socket
|
|
7
|
+
* fires `open` (i.e. the upgrade succeeded + the subprotocol matches
|
|
8
|
+
* `SUBPROTOCOL_NAME`); rejects with a typed
|
|
9
|
+
* {@link TransportFailedError} / {@link SubprotocolMismatchError} /
|
|
10
|
+
* {@link AuthFailedError} otherwise.
|
|
11
|
+
*
|
|
12
|
+
* @stable
|
|
13
|
+
*/
|
|
14
|
+
declare function openWebSocketTransport(options: TransportOptions, listeners: TransportListeners): Promise<Transport>;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { openWebSocketTransport };
|
|
17
|
+
//# sourceMappingURL=ws.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transport/ws.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;iBA2CsB,sBAAA,UACX,6BACE,qBACV,QAAQ"}
|