@graphorin/client 0.6.1 → 0.7.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.
@@ -0,0 +1,1075 @@
1
+ import pkg from '../package.json' with { type: 'json' };
2
+ /**
3
+ * `GraphorinClient` - ergonomic façade over the
4
+ * {@link Transport} contract. Handles:
5
+ *
6
+ * - WS handshake (`openWebSocketTransport`) with optional ticket flow.
7
+ * - Optional SSE fallback (`openSseTransport`) for environments
8
+ * that block WebSocket upgrades.
9
+ * - JSON-RPC request / response correlation for `subscribe` /
10
+ * `unsubscribe` / `cancel` / `resume` / `ping` calls.
11
+ * - Async-iterable subscriptions (`for await (const event of
12
+ * sub.events())`).
13
+ * - Exponential-backoff reconnect with `lastEventId` resume against
14
+ * the server replay buffer.
15
+ *
16
+ * The class is intentionally small (≈ 400 LOC) so the production
17
+ * cross-cuts (telemetry, sticky reconnect on transient errors,
18
+ * load-shedding) live in higher-level wrappers consumers build on
19
+ * top of `GraphorinClient` instead of leaking into the protocol
20
+ * adapter itself.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ import {
26
+ type ClientMessage,
27
+ type ClientMessageId,
28
+ isErrorFrame,
29
+ isEventFrame,
30
+ isLifecycleFrame,
31
+ isPongFrame,
32
+ isReplayMarkerFrame,
33
+ isRpcFailure,
34
+ isRpcSuccess,
35
+ isSubscribedFrame,
36
+ isUnsubscribedFrame,
37
+ type ServerEventFrame,
38
+ type ServerLifecycleFrame,
39
+ type ServerMessage,
40
+ type ServerReplayMarkerFrame,
41
+ } from '@graphorin/protocol';
42
+
43
+ import {
44
+ ClientAbortedError,
45
+ ClientNotConnectedError,
46
+ GraphorinClientError,
47
+ kindForRpcCode,
48
+ ProtocolViolationError,
49
+ TransportFailedError,
50
+ } from './errors.js';
51
+ import { type BackoffPolicy, computeBackoffMs, sleep } from './reconnect.js';
52
+ import {
53
+ openSseTransport,
54
+ openWebSocketTransport,
55
+ type Transport,
56
+ type TransportAuth,
57
+ type TransportCloseReason,
58
+ type TransportKind,
59
+ } from './transport/index.js';
60
+
61
+ /**
62
+ * Discriminator for the subscription target. Mirrors the strict
63
+ * subject grammar enforced by the server:
64
+ * - `'session'`/`<id>` ⇒ `'session:<id>/events'`
65
+ * - `'agent'`/`<id>` + `runId` ⇒ `'agent:<id>/runs/<runId>/events'`
66
+ * - `'run'`/`<runId>` ⇒ `'session:<sessionId>/runs/<runId>/events'`
67
+ * (when `sessionId` is provided)
68
+ * - `'workflow'`/`<id>` ⇒ `'workflow:<id>/events'`
69
+ *
70
+ * @stable
71
+ */
72
+ export type SubscriptionTarget =
73
+ | { readonly target: 'session'; readonly id: string }
74
+ | {
75
+ readonly target: 'agent';
76
+ readonly id: string;
77
+ readonly runId: string;
78
+ }
79
+ | {
80
+ readonly target: 'run';
81
+ readonly runId: string;
82
+ readonly sessionId?: string;
83
+ }
84
+ | { readonly target: 'workflow'; readonly id: string };
85
+
86
+ /**
87
+ * Transport selector. `'auto'` (default) attempts a WebSocket
88
+ * handshake first and falls back to SSE on failure.
89
+ *
90
+ * @stable
91
+ */
92
+ export type TransportPreference = TransportKind | 'auto';
93
+
94
+ /**
95
+ * Public configuration accepted by {@link GraphorinClient}.
96
+ *
97
+ * @stable
98
+ */
99
+ export interface GraphorinClientOptions {
100
+ /**
101
+ * Session bound to the SSE fallback (IP-3): substituted into the
102
+ * `:sessionId` slot of `sseSessionPath`. Required to connect over
103
+ * SSE - the old client sent the literal template and could never
104
+ * receive an event.
105
+ */
106
+ readonly sessionId?: string;
107
+ /**
108
+ * Server base URL. Examples: `'wss://graphorin.example.com'` or
109
+ * `'http://localhost:8080'`. The path `/v1/ws` is appended for
110
+ * the WebSocket transport.
111
+ */
112
+ readonly baseUrl: string;
113
+ readonly auth: TransportAuth;
114
+ /**
115
+ * Transport selection. Default `'auto'`: WebSocket first, SSE
116
+ * fallback. W-108 caveat for `'auto'`: SSE carries only the bound
117
+ * session subject, so when a RECONNECT falls back from WS to SSE,
118
+ * live WS subscriptions (agent / workflow subjects) cannot be
119
+ * resumed - they are closed with a `TransportFailedError` (their
120
+ * `for await` consumers reject immediately instead of hanging).
121
+ * Force `'ws'` when your application depends on those subscriptions
122
+ * surviving reconnects.
123
+ */
124
+ readonly transport?: TransportPreference;
125
+ /**
126
+ * W-152: per-subscription buffer cap. When the `for await` consumer
127
+ * falls behind and the buffered queue reaches this many frames, the
128
+ * subscription closes with a typed `flow-overflow` error (mirroring
129
+ * the server's queue-overflow close) instead of growing the heap
130
+ * without bound or silently dropping events. Default 10000 (10x the
131
+ * server's default per-connection limit); `0` disables the cap and
132
+ * restores the old unbounded behavior.
133
+ */
134
+ readonly subscriptionQueueLimit?: number;
135
+ /** Override the WS path (default `'/v1/ws'`). */
136
+ readonly wsPath?: string;
137
+ /**
138
+ * SSE path template. The placeholder `:sessionId` is replaced at
139
+ * subscribe time. Default: `'/v1/sessions/:sessionId/events'`.
140
+ * Required when the transport is `'sse'` or when the WS handshake
141
+ * fails on `'auto'`.
142
+ */
143
+ readonly sseSessionPath?: string;
144
+ readonly reconnect?: BackoffPolicy;
145
+ /** Inject a `WebSocket` constructor (Node SDKs / tests). */
146
+ readonly WebSocket?: typeof WebSocket;
147
+ /** Inject an `EventSource` constructor (Node SDKs / tests). */
148
+ readonly EventSource?: typeof EventSource;
149
+ /** Inject a `fetch` implementation (defaults to `globalThis.fetch`). */
150
+ readonly fetch?: typeof fetch;
151
+ /** Optional client identifier surfaced on diagnostics. */
152
+ readonly clientId?: string;
153
+ /**
154
+ * IP-19: per-RPC reply timeout in milliseconds. When set (and > 0), an RPC
155
+ * that receives no matching reply within this window rejects with a
156
+ * {@link TransportFailedError} instead of hanging forever on a
157
+ * non-responsive server. Default: unset - no timeout, so a legitimately
158
+ * slow server reply is never aborted (opt-in).
159
+ */
160
+ readonly rpcTimeoutMs?: number;
161
+ }
162
+
163
+ /**
164
+ * Snapshot returned by {@link Subscription.metadata}.
165
+ *
166
+ * @stable
167
+ */
168
+ export interface SubscriptionMetadata {
169
+ readonly id: string;
170
+ readonly subject: string;
171
+ readonly target: SubscriptionTarget;
172
+ readonly snapshotEventId: string | undefined;
173
+ readonly lastEventId: string | undefined;
174
+ readonly closed: boolean;
175
+ /** W-152: current buffered (undelivered) event-frame count. */
176
+ readonly queuedEvents: number;
177
+ }
178
+
179
+ /**
180
+ * Public surface returned by {@link GraphorinClient.subscribe}.
181
+ *
182
+ * @stable
183
+ */
184
+ export interface Subscription {
185
+ readonly subscriptionId: string;
186
+ readonly subject: string;
187
+ events(): AsyncIterable<ServerEventFrame>;
188
+ /**
189
+ * Close the subscription on the server. Idempotent.
190
+ */
191
+ unsubscribe(): Promise<void>;
192
+ metadata(): SubscriptionMetadata;
193
+ }
194
+
195
+ interface SubscriptionInternal extends Subscription {
196
+ __push(frame: ServerEventFrame): void;
197
+ __pushLifecycle(frame: ServerLifecycleFrame): void;
198
+ __pushReplayMarker(frame: ServerReplayMarkerFrame): void;
199
+ __close(reason: 'unsubscribed' | 'transport-closed' | 'aborted', error?: Error): void;
200
+ __subject(): string;
201
+ __target(): SubscriptionTarget;
202
+ __snapshotEventId(): string | undefined;
203
+ __lastEventId(): string | undefined;
204
+ /** IP-7: re-point this subscription at a new server subscriptionId. */
205
+ __rebind(newSubscriptionId: string): void;
206
+ }
207
+
208
+ /**
209
+ * @stable
210
+ */
211
+ export class GraphorinClient {
212
+ readonly #options: GraphorinClientOptions;
213
+ readonly #pending: Map<ClientMessageId, PendingRpc> = new Map();
214
+ readonly #subscriptions: Map<string, SubscriptionInternal> = new Map();
215
+ /** IP-3: the synthetic single subscription an SSE connection carries. */
216
+ #sseSubscription: SubscriptionInternal | undefined;
217
+ #transport: Transport | undefined;
218
+ #idCounter = 0;
219
+ #closed = false;
220
+ #connectingPromise: Promise<void> | undefined;
221
+ #abortController: AbortController = new AbortController();
222
+
223
+ constructor(options: GraphorinClientOptions) {
224
+ if (typeof options.baseUrl !== 'string' || options.baseUrl.length === 0) {
225
+ throw new TypeError('GraphorinClient: baseUrl must be a non-empty string.');
226
+ }
227
+ this.#options = options;
228
+ }
229
+
230
+ /**
231
+ * Open the underlying transport. Resolves once the server has
232
+ * accepted the handshake (`'open'`); rejects with a typed
233
+ * {@link GraphorinClientError} otherwise.
234
+ *
235
+ * Calling `connect()` while already connected is a no-op; calling
236
+ * it during another `connect()` returns the same promise.
237
+ */
238
+ async connect(): Promise<void> {
239
+ if (this.#closed) {
240
+ throw new ClientAbortedError('GraphorinClient was disconnected; create a new instance.');
241
+ }
242
+ if (this.#transport !== undefined) return;
243
+ if (this.#connectingPromise !== undefined) return this.#connectingPromise;
244
+ this.#connectingPromise = (async () => {
245
+ const preference = this.#options.transport ?? 'auto';
246
+ try {
247
+ if (preference === 'ws' || preference === 'auto') {
248
+ try {
249
+ this.#transport = await this.#openWs();
250
+ await this.#initializeRpc();
251
+ return;
252
+ } catch (err) {
253
+ if (preference === 'ws') throw err;
254
+ }
255
+ }
256
+ this.#transport = await this.#openSse();
257
+ } finally {
258
+ this.#connectingPromise = undefined;
259
+ }
260
+ })();
261
+ return this.#connectingPromise;
262
+ }
263
+
264
+ async #initializeRpc(): Promise<void> {
265
+ if (this.#transport?.kind !== 'ws') return;
266
+ await this.#sendRpc('initialize', {
267
+ clientInfo: { name: 'graphorin-client', version: pkg.version },
268
+ });
269
+ }
270
+
271
+ /** Send a `ping` RPC and resolve when the server replies with `pong`. */
272
+ async ping(): Promise<void> {
273
+ await this.#sendRpc('ping');
274
+ }
275
+
276
+ /**
277
+ * Subscribe to a server-side event stream. Resolves with a
278
+ * {@link Subscription} once the server confirms with the matching
279
+ * `subscribed` frame; rejects when the server returns an
280
+ * `error` instead.
281
+ */
282
+ async subscribe(
283
+ target: SubscriptionTarget,
284
+ opts?: { readonly sinceEventId?: string },
285
+ ): Promise<Subscription> {
286
+ if (this.#transport === undefined) throw new ClientNotConnectedError();
287
+ if (this.#transport.kind === 'sse') {
288
+ // IP-3: the SSE connection IS one implicit subscription to the
289
+ // bound session's events. Frames arrive with a server-generated
290
+ // subscriptionId the client cannot know upfront, so routing
291
+ // falls through to this synthetic subscription (see
292
+ // #handleFrame). Other subjects still require the WS transport.
293
+ const subject = subjectFor(target);
294
+ const boundSubject = `session:${this.#options.sessionId ?? ''}/events`;
295
+ if (subject !== boundSubject) {
296
+ throw new TransportFailedError(
297
+ `subscribe('${subject}') requires the WebSocket transport - the SSE fallback carries only the bound session stream ('${boundSubject}').`,
298
+ );
299
+ }
300
+ // periphery-03: a CLOSED bound subscription (a prior terminal
301
+ // reconnect failure) must not be handed back - recreate it so a
302
+ // recovered client delivers events again.
303
+ if (this.#sseSubscription === undefined || this.#sseSubscription.metadata().closed) {
304
+ this.#sseSubscription = this.#createSubscription({
305
+ subscriptionId: '__sse__',
306
+ subject,
307
+ target,
308
+ snapshotEventId: undefined,
309
+ });
310
+ this.#subscriptions.set('__sse__', this.#sseSubscription);
311
+ }
312
+ return this.#sseSubscription;
313
+ }
314
+ const subject = subjectFor(target);
315
+ // IP-7: a resubscribe passes the SUBSCRIPTION's own cursor - the
316
+ // fresh transport's lastEventId is always undefined, so the old
317
+ // code never consulted the server replay buffer.
318
+ const lastEventId = opts?.sinceEventId ?? this.#transport.lastEventId;
319
+ const params: { subject: string; sinceEventId?: string } = {
320
+ subject,
321
+ };
322
+ if (lastEventId !== undefined) params.sinceEventId = lastEventId;
323
+ const reply = await this.#sendRpc('subscription.subscribe', params);
324
+ const result = reply as { subscriptionId?: unknown; snapshotEventId?: unknown };
325
+ const subscriptionId =
326
+ typeof result.subscriptionId === 'string' && result.subscriptionId.length > 0
327
+ ? result.subscriptionId
328
+ : undefined;
329
+ if (subscriptionId === undefined) {
330
+ throw new ProtocolViolationError('Server subscribe reply missing subscriptionId.');
331
+ }
332
+ const snapshotEventId =
333
+ typeof result.snapshotEventId === 'string' ? result.snapshotEventId : undefined;
334
+ const sub = this.#createSubscription({
335
+ subscriptionId,
336
+ subject,
337
+ target,
338
+ snapshotEventId,
339
+ });
340
+ this.#subscriptions.set(subscriptionId, sub);
341
+ return sub;
342
+ }
343
+
344
+ /**
345
+ * Cancel a server-side run. Sends the `run.cancel` RPC and
346
+ * resolves with the server's `result` payload (typically
347
+ * `{ cancelled: true, partialStateAvailable: true }`).
348
+ */
349
+ async cancel(
350
+ runId: string,
351
+ opts: {
352
+ readonly drain?: boolean;
353
+ readonly reason?: string;
354
+ readonly onPendingApprovals?: 'deny' | 'preserve';
355
+ } = {},
356
+ ): Promise<unknown> {
357
+ if (typeof runId !== 'string' || runId.length === 0) {
358
+ throw new TypeError('cancel: runId must be a non-empty string.');
359
+ }
360
+ const params: {
361
+ runId: string;
362
+ drain?: boolean;
363
+ reason?: string;
364
+ onPendingApprovals?: 'deny' | 'preserve';
365
+ } = { runId };
366
+ if (opts.drain !== undefined) params.drain = opts.drain;
367
+ if (opts.reason !== undefined) params.reason = opts.reason;
368
+ if (opts.onPendingApprovals !== undefined) {
369
+ params.onPendingApprovals = opts.onPendingApprovals;
370
+ }
371
+ return this.#sendRpc('run.cancel', params);
372
+ }
373
+
374
+ /**
375
+ * Resume a paused (HITL) run. The WebSocket protocol intentionally
376
+ * does NOT carry a `resume` control message - resumes are durable
377
+ * + idempotent + body-carrying, which maps onto the REST endpoint
378
+ * `POST /v1/runs/:runId/resume`. NOTE (IP-14): the server endpoint
379
+ * currently answers **501** - server-side durable resume is not
380
+ * implemented yet. Library-mode callers resume directly:
381
+ * `agent.run(result.state, { directive })`.
382
+ */
383
+ async resume(
384
+ runId: string,
385
+ directive?: unknown,
386
+ opts: { readonly idempotencyKey?: string } = {},
387
+ ): Promise<unknown> {
388
+ if (typeof runId !== 'string' || runId.length === 0) {
389
+ throw new TypeError('resume: runId must be a non-empty string.');
390
+ }
391
+ const fetchImpl = this.#options.fetch ?? globalThis.fetch;
392
+ if (typeof fetchImpl !== 'function') {
393
+ throw new TransportFailedError(
394
+ 'No fetch implementation available; pass `fetch` via the client options.',
395
+ );
396
+ }
397
+ const url = joinUrl(this.#options.baseUrl, `/v1/runs/${encodeURIComponent(runId)}/resume`, {
398
+ ws: false,
399
+ });
400
+ const headers: Record<string, string> = {
401
+ 'Content-Type': 'application/json',
402
+ };
403
+ if (this.#options.auth.kind === 'bearer') {
404
+ headers.Authorization = `Bearer ${this.#options.auth.token}`;
405
+ }
406
+ if (opts.idempotencyKey !== undefined) headers['Idempotency-Key'] = opts.idempotencyKey;
407
+ const res = await fetchImpl(url, {
408
+ method: 'POST',
409
+ headers,
410
+ body: JSON.stringify(directive === undefined ? {} : { directive }),
411
+ });
412
+ if (!res.ok) {
413
+ throw new TransportFailedError(
414
+ `resume(${runId}): server responded ${res.status} ${res.statusText}.`,
415
+ { code: res.status },
416
+ );
417
+ }
418
+ try {
419
+ return await res.json();
420
+ } catch {
421
+ return undefined;
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Send an MCP-compatible cancellation notification. Does not wait
427
+ * for a server reply (notifications have no `id`).
428
+ */
429
+ cancelNotify(requestId: string): void {
430
+ if (this.#transport === undefined) throw new ClientNotConnectedError();
431
+ if (typeof requestId !== 'string' || requestId.length === 0) {
432
+ throw new TypeError('cancelNotify: requestId must be a non-empty string.');
433
+ }
434
+ const frame: ClientMessage = {
435
+ v: '1',
436
+ jsonrpc: '2.0',
437
+ method: 'notifications/cancelled',
438
+ params: { requestId },
439
+ };
440
+ this.#transport.send(frame);
441
+ }
442
+
443
+ /**
444
+ * Disconnect the underlying transport and abort every pending RPC
445
+ * + subscription. Idempotent.
446
+ */
447
+ async disconnect(): Promise<void> {
448
+ if (this.#closed) return;
449
+ this.#closed = true;
450
+ const transport = this.#transport;
451
+ this.#transport = undefined;
452
+ this.#abortController.abort(new ClientAbortedError());
453
+ for (const pending of this.#pending.values()) {
454
+ pending.reject(new ClientAbortedError('Client disconnected before reply.'));
455
+ }
456
+ this.#pending.clear();
457
+ for (const sub of this.#subscriptions.values()) {
458
+ sub.__close('aborted', new ClientAbortedError('Client disconnected.'));
459
+ }
460
+ this.#subscriptions.clear();
461
+ if (transport !== undefined) {
462
+ try {
463
+ transport.close(1000, 'client-disconnect');
464
+ } catch {
465
+ // Best-effort close: never throw from disconnect().
466
+ }
467
+ }
468
+ }
469
+
470
+ /** Return the active transport kind (or `undefined` if not connected). */
471
+ get transportKind(): TransportKind | undefined {
472
+ return this.#transport?.kind;
473
+ }
474
+
475
+ // -----------------------------------------------------------------
476
+ // Internal helpers
477
+ // -----------------------------------------------------------------
478
+
479
+ async #openWs(): Promise<Transport> {
480
+ const url = this.#wsUrl();
481
+ const transport = await openWebSocketTransport(
482
+ {
483
+ url,
484
+ auth: this.#options.auth,
485
+ ...(this.#options.WebSocket !== undefined ? { WebSocket: this.#options.WebSocket } : {}),
486
+ ...(this.#options.clientId !== undefined ? { clientId: this.#options.clientId } : {}),
487
+ },
488
+ this.#listeners('ws'),
489
+ );
490
+ return transport;
491
+ }
492
+
493
+ async #openSse(): Promise<Transport> {
494
+ if (this.#options.auth.kind !== 'bearer') {
495
+ throw new TransportFailedError(
496
+ "SSE fallback requires the 'bearer' auth strategy. The WebSocket ticket flow does not extend to EventSource.",
497
+ );
498
+ }
499
+ const url = this.#sseUrl();
500
+ // periphery-03: resume from where the bound session subscription
501
+ // left off - without the cursor every reconnect replays the whole
502
+ // server buffer into the consumer.
503
+ const resumeCursor = this.#sseSubscription?.__lastEventId();
504
+ const transport = await openSseTransport(
505
+ {
506
+ url,
507
+ auth: this.#options.auth,
508
+ // IP-3: the fetch-streaming transport uses the client's fetch
509
+ // seam (the EventSource seam is gone with the rewrite).
510
+ ...(this.#options.fetch !== undefined ? { fetch: this.#options.fetch } : {}),
511
+ ...(this.#options.clientId !== undefined ? { clientId: this.#options.clientId } : {}),
512
+ ...(resumeCursor !== undefined ? { lastEventId: resumeCursor } : {}),
513
+ },
514
+ this.#listeners('sse'),
515
+ );
516
+ return transport;
517
+ }
518
+
519
+ #wsUrl(): string {
520
+ const path = this.#options.wsPath ?? '/v1/ws';
521
+ return joinUrl(this.#options.baseUrl, path, { ws: true });
522
+ }
523
+
524
+ #sseUrl(): string {
525
+ const template = this.#options.sseSessionPath ?? '/v1/sessions/:sessionId/events';
526
+ // IP-3: bind the session into the path - the literal template was
527
+ // previously sent as-is, so the SSE fallback could never connect
528
+ // to a real session stream.
529
+ if (template.includes(':sessionId')) {
530
+ const sessionId = this.#options.sessionId;
531
+ if (sessionId === undefined || sessionId.length === 0) {
532
+ throw new TransportFailedError(
533
+ "The SSE fallback needs a session binding - pass `sessionId` in the client options (it fills ':sessionId' in sseSessionPath).",
534
+ );
535
+ }
536
+ return joinUrl(
537
+ this.#options.baseUrl,
538
+ template.replace(':sessionId', encodeURIComponent(sessionId)),
539
+ { ws: false },
540
+ );
541
+ }
542
+ return joinUrl(this.#options.baseUrl, template, { ws: false });
543
+ }
544
+
545
+ #listeners(kind: TransportKind): {
546
+ onOpen(): void;
547
+ onFrame(frame: ServerMessage): void;
548
+ onError(err: Error): void;
549
+ onClose(reason: TransportCloseReason): void;
550
+ } {
551
+ return {
552
+ onOpen: () => {},
553
+ onFrame: (frame) => this.#handleFrame(frame),
554
+ onError: (err) => this.#handleTransportError(err, kind),
555
+ onClose: (reason) => this.#handleTransportClose(reason, kind),
556
+ };
557
+ }
558
+
559
+ #handleFrame(frame: ServerMessage): void {
560
+ if (isRpcSuccess(frame)) {
561
+ const pending = this.#pending.get(frame.id);
562
+ if (pending !== undefined) {
563
+ this.#pending.delete(frame.id);
564
+ pending.resolve(frame.result);
565
+ }
566
+ return;
567
+ }
568
+ if (isRpcFailure(frame)) {
569
+ const pending = this.#pending.get(frame.id);
570
+ if (pending !== undefined) {
571
+ this.#pending.delete(frame.id);
572
+ // IP-19: surface the server's error class (rate-limited, scope-denied,
573
+ // auth-failed, …) instead of collapsing every RPC failure to
574
+ // 'protocol-violation'.
575
+ pending.reject(
576
+ new GraphorinClientError(
577
+ kindForRpcCode(frame.error.code),
578
+ `Server returned an error for RPC '${frame.id}': ${frame.error.message} (code=${frame.error.code}).`,
579
+ ),
580
+ );
581
+ }
582
+ return;
583
+ }
584
+ if (isSubscribedFrame(frame)) {
585
+ // Subscribed frames are acknowledged by the matching RPC reply.
586
+ return;
587
+ }
588
+ if (isUnsubscribedFrame(frame)) {
589
+ const sub = this.#subscriptions.get(frame.subscriptionId);
590
+ if (sub !== undefined) {
591
+ sub.__close('unsubscribed');
592
+ this.#subscriptions.delete(frame.subscriptionId);
593
+ }
594
+ return;
595
+ }
596
+ if (isPongFrame(frame)) return;
597
+ if (isLifecycleFrame(frame)) {
598
+ const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();
599
+ if (sub !== undefined) sub.__pushLifecycle(frame);
600
+ return;
601
+ }
602
+ if (isReplayMarkerFrame(frame)) {
603
+ const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();
604
+ if (sub !== undefined) sub.__pushReplayMarker(frame);
605
+ return;
606
+ }
607
+ if (isEventFrame(frame)) {
608
+ const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();
609
+ if (sub !== undefined) sub.__push(frame);
610
+ return;
611
+ }
612
+ if (isErrorFrame(frame)) {
613
+ const target =
614
+ frame.subscriptionId !== undefined
615
+ ? this.#subscriptions.get(frame.subscriptionId)
616
+ : undefined;
617
+ const error = new GraphorinClientError(
618
+ 'protocol-violation',
619
+ `Server error frame: ${frame.message} (code=${frame.code}).`,
620
+ );
621
+ if (target !== undefined) {
622
+ target.__close('aborted', error);
623
+ if (frame.subscriptionId !== undefined) this.#subscriptions.delete(frame.subscriptionId);
624
+ }
625
+ }
626
+ }
627
+
628
+ /**
629
+ * IP-3: on the SSE transport every frame belongs to the single
630
+ * implicit session subscription regardless of the server-generated
631
+ * subscriptionId it carries.
632
+ */
633
+ #sseFallback(): SubscriptionInternal | undefined {
634
+ return this.#transport?.kind === 'sse' ? this.#sseSubscription : undefined;
635
+ }
636
+
637
+ #handleTransportError(err: Error, _kind: TransportKind): void {
638
+ for (const pending of this.#pending.values()) {
639
+ pending.reject(err);
640
+ }
641
+ this.#pending.clear();
642
+ }
643
+
644
+ async #handleTransportClose(reason: TransportCloseReason, _kind: TransportKind): Promise<void> {
645
+ if (this.#closed) return;
646
+ this.#transport = undefined;
647
+ const closeError = new TransportFailedError(
648
+ `Transport closed: ${reason.reason || reason.graphorinReason || 'unknown'} (code=${reason.code}).`,
649
+ { code: reason.code },
650
+ );
651
+ // Best-effort drain of pending RPCs so callers see the failure
652
+ // immediately rather than hanging until the reconnect window
653
+ // closes.
654
+ for (const pending of this.#pending.values()) {
655
+ pending.reject(closeError);
656
+ }
657
+ this.#pending.clear();
658
+ if (
659
+ reason.graphorinReason === 'auth.required' ||
660
+ reason.graphorinReason === 'auth.invalid' ||
661
+ reason.graphorinReason === 'auth.revoked' ||
662
+ reason.graphorinReason === 'protocol.violation'
663
+ ) {
664
+ // Fatal: stop trying to reconnect and surface the failure.
665
+ for (const sub of this.#subscriptions.values()) {
666
+ sub.__close('aborted', closeError);
667
+ }
668
+ this.#subscriptions.clear();
669
+ return;
670
+ }
671
+ await this.#reconnect();
672
+ }
673
+
674
+ async #reconnect(): Promise<void> {
675
+ if (this.#closed) return;
676
+ let attempt = 0;
677
+ while (!this.#closed) {
678
+ attempt += 1;
679
+ const delay = computeBackoffMs(attempt, this.#options.reconnect);
680
+ if (delay === null) {
681
+ const exhausted = new TransportFailedError(
682
+ `Reconnect attempts exhausted after ${attempt - 1} retries.`,
683
+ );
684
+ for (const sub of this.#subscriptions.values()) {
685
+ sub.__close('aborted', exhausted);
686
+ }
687
+ this.#subscriptions.clear();
688
+ return;
689
+ }
690
+ try {
691
+ await sleep(delay, this.#abortController.signal);
692
+ await this.connect();
693
+ // Best-effort resubscribe with the recorded lastEventId so
694
+ // the server replays missed events from the buffer.
695
+ const transport = this.#transport;
696
+ if (transport === undefined) continue;
697
+ // periphery-03: on the SSE fallback the connection IS the
698
+ // subscription - the reconnect already carried the resume
699
+ // cursor as `Last-Event-ID`, and frames keep flowing into the
700
+ // same bound subscription object. The RPC resubscribe below
701
+ // would call `transport.send(...)`, which the read-only SSE
702
+ // transport rejects; pre-fix that error CLOSED the
703
+ // subscription and the client silently stopped delivering
704
+ // events for the life of the object.
705
+ if (transport.kind === 'sse') {
706
+ // W-108: transport 'auto' can reconnect a WS-FIRST client
707
+ // over SSE when the WS handshake flakes. SSE carries exactly
708
+ // ONE bound-session subject ('__sse__'); a surviving WS
709
+ // subscription (agent/workflow subject) is unreachable over
710
+ // it - no frame will ever route to it and its consumer would
711
+ // block in `for await` forever. Close them with a typed
712
+ // error instead of a silent hang (and instead of an endless
713
+ // WS retry, which would wedge against servers with WS
714
+ // disabled): the application decides whether to reconnect
715
+ // with `transport: 'ws'` or read the bound session subject.
716
+ const fellBack = new TransportFailedError(
717
+ "Reconnect fell back to SSE; WebSocket subscriptions cannot be resumed over SSE - resubscribe on the bound session subject or force transport: 'ws'.",
718
+ );
719
+ for (const [id, sub] of [...this.#subscriptions]) {
720
+ if (id === '__sse__') continue;
721
+ this.#subscriptions.delete(id);
722
+ sub.__close('aborted', fellBack);
723
+ }
724
+ return;
725
+ }
726
+ for (const [oldId, sub] of [...this.#subscriptions]) {
727
+ this.#subscriptions.delete(oldId);
728
+ try {
729
+ // IP-7: re-establish the server-side subscription with the
730
+ // SUBSCRIPTION's own replay cursor, then re-point the SAME
731
+ // object at the new server id - the consumer's in-flight
732
+ // `for await` survives and the replayed events arrive in
733
+ // the iterator it is already reading. The old code created
734
+ // a NEW subscription and closed this one: the consumer's
735
+ // loop ended `{done: true}` while events piled up unread
736
+ // in an orphan.
737
+ const cursor = sub.__lastEventId();
738
+ const reply = await this.#sendRpc('subscription.subscribe', {
739
+ subject: sub.__subject(),
740
+ ...(cursor !== undefined ? { sinceEventId: cursor } : {}),
741
+ });
742
+ const result = reply as { subscriptionId?: unknown };
743
+ const newId =
744
+ typeof result.subscriptionId === 'string' && result.subscriptionId.length > 0
745
+ ? result.subscriptionId
746
+ : undefined;
747
+ if (newId === undefined) {
748
+ throw new ProtocolViolationError('Server resubscribe reply missing subscriptionId.');
749
+ }
750
+ sub.__rebind(newId);
751
+ this.#subscriptions.set(newId, sub);
752
+ } catch (err) {
753
+ sub.__close('aborted', err instanceof Error ? err : new Error(String(err)));
754
+ }
755
+ }
756
+ return;
757
+ } catch (err) {
758
+ if (this.#closed) return;
759
+ // Loop and retry.
760
+ if (err instanceof ClientAbortedError) return;
761
+ }
762
+ }
763
+ }
764
+
765
+ #createSubscription(args: {
766
+ subscriptionId: string;
767
+ readonly subject: string;
768
+ readonly target: SubscriptionTarget;
769
+ readonly snapshotEventId: string | undefined;
770
+ }): SubscriptionInternal {
771
+ const queue: ServerEventFrame[] = [];
772
+ const lifecycle: ServerLifecycleFrame[] = [];
773
+ const replayMarkers: ServerReplayMarkerFrame[] = [];
774
+ const waiters: Array<{
775
+ resolve: (value: IteratorResult<ServerEventFrame>) => void;
776
+ reject: (err: Error) => void;
777
+ }> = [];
778
+ let closed = false;
779
+ let closeError: Error | undefined;
780
+ let lastEventId: string | undefined = args.snapshotEventId;
781
+
782
+ const queueLimit = this.#options.subscriptionQueueLimit ?? 10_000;
783
+ const push = (frame: ServerEventFrame): void => {
784
+ // W-152: frames arriving after close (server-side unsubscribe is
785
+ // async) must not keep growing a queue nobody will drain.
786
+ if (closed) return;
787
+ lastEventId = frame.eventId;
788
+ const waiter = waiters.shift();
789
+ if (waiter !== undefined) {
790
+ waiter.resolve({ value: frame, done: false });
791
+ return;
792
+ }
793
+ if (queueLimit > 0 && queue.length >= queueLimit) {
794
+ // W-152: deterministic policy over silent loss - mirror the
795
+ // server's queue-overflow close (4006 flow.throttled) with a
796
+ // typed client error the pending/next for-await observes.
797
+ close(
798
+ 'aborted',
799
+ new GraphorinClientError(
800
+ 'flow-overflow',
801
+ `Subscription '${args.subscriptionId}' buffered ${queue.length} events with no consumer progress (subscriptionQueueLimit=${queueLimit}). Consume the events() iterator faster, raise the limit, or set 0 to disable the cap.`,
802
+ ),
803
+ );
804
+ return;
805
+ }
806
+ queue.push(frame);
807
+ };
808
+
809
+ const close = (reason: 'unsubscribed' | 'transport-closed' | 'aborted', err?: Error): void => {
810
+ if (closed) return;
811
+ closed = true;
812
+ if (err !== undefined) closeError = err;
813
+ else if (reason === 'aborted') closeError = new ClientAbortedError();
814
+ while (waiters.length > 0) {
815
+ const waiter = waiters.shift();
816
+ if (waiter === undefined) continue;
817
+ // IP-19(c): a waiter blocked in next() must observe the SAME outcome a
818
+ // fresh next() would after close - reject with closeError when the
819
+ // stream tore down abnormally, rather than silently resolving done:true
820
+ // and swallowing the transport/abort failure.
821
+ if (closeError !== undefined) {
822
+ waiter.reject(closeError);
823
+ } else {
824
+ waiter.resolve({ value: undefined as unknown as ServerEventFrame, done: true });
825
+ }
826
+ }
827
+ };
828
+
829
+ const sub: SubscriptionInternal = {
830
+ get subscriptionId(): string {
831
+ return args.subscriptionId;
832
+ },
833
+ subject: args.subject,
834
+ events: () => ({
835
+ [Symbol.asyncIterator]: () => ({
836
+ next: (): Promise<IteratorResult<ServerEventFrame>> => {
837
+ const buffered = queue.shift();
838
+ if (buffered !== undefined) {
839
+ return Promise.resolve({ value: buffered, done: false });
840
+ }
841
+ if (closed) {
842
+ if (closeError !== undefined) return Promise.reject(closeError);
843
+ return Promise.resolve({
844
+ value: undefined as unknown as ServerEventFrame,
845
+ done: true,
846
+ });
847
+ }
848
+ return new Promise<IteratorResult<ServerEventFrame>>((resolve, reject) => {
849
+ waiters.push({ resolve, reject });
850
+ });
851
+ },
852
+ return: (): Promise<IteratorResult<ServerEventFrame>> => {
853
+ close('unsubscribed');
854
+ return Promise.resolve({ value: undefined as unknown as ServerEventFrame, done: true });
855
+ },
856
+ }),
857
+ }),
858
+ unsubscribe: async () => {
859
+ if (closed) return;
860
+ try {
861
+ await this.#sendRpc('subscription.unsubscribe', {
862
+ subscriptionId: args.subscriptionId,
863
+ });
864
+ } catch (err) {
865
+ if (!(err instanceof ClientAbortedError)) throw err;
866
+ }
867
+ close('unsubscribed');
868
+ this.#subscriptions.delete(args.subscriptionId);
869
+ },
870
+ metadata: () => ({
871
+ id: args.subscriptionId,
872
+ subject: args.subject,
873
+ target: args.target,
874
+ snapshotEventId: args.snapshotEventId,
875
+ lastEventId,
876
+ closed,
877
+ queuedEvents: queue.length,
878
+ }),
879
+ __push: push,
880
+ __pushLifecycle: (frame) => {
881
+ lifecycle.push(frame);
882
+ if (
883
+ frame.status === 'completed' ||
884
+ frame.status === 'aborted' ||
885
+ frame.status === 'failed'
886
+ ) {
887
+ close(frame.status === 'completed' ? 'unsubscribed' : 'aborted');
888
+ this.#subscriptions.delete(args.subscriptionId);
889
+ }
890
+ },
891
+ __pushReplayMarker: (frame) => {
892
+ replayMarkers.push(frame);
893
+ },
894
+ __close: close,
895
+ __subject: () => args.subject,
896
+ __target: () => args.target,
897
+ __snapshotEventId: () => args.snapshotEventId,
898
+ __lastEventId: () => lastEventId,
899
+ __rebind: (newSubscriptionId: string) => {
900
+ args.subscriptionId = newSubscriptionId;
901
+ },
902
+ };
903
+ return sub;
904
+ }
905
+
906
+ async #sendRpc(method: ClientMessage['method'], params?: object): Promise<unknown> {
907
+ if (this.#transport === undefined) throw new ClientNotConnectedError();
908
+ const id = this.#nextId();
909
+ const frame = buildRpcFrame(method, id, params);
910
+ const timeoutMs = this.#options.rpcTimeoutMs;
911
+ return new Promise((resolve, reject) => {
912
+ // IP-19: arm an optional reply timer. Wrapping resolve/reject so that
913
+ // EVERY settle path (matching reply, disconnect, transport error) clears
914
+ // the timer - otherwise a stray timer could reject an already-settled id.
915
+ let timer: ReturnType<typeof setTimeout> | undefined;
916
+ const clear = (): void => {
917
+ if (timer !== undefined) {
918
+ clearTimeout(timer);
919
+ timer = undefined;
920
+ }
921
+ };
922
+ const pending: PendingRpc = {
923
+ resolve: (value) => {
924
+ clear();
925
+ resolve(value);
926
+ },
927
+ reject: (reason) => {
928
+ clear();
929
+ reject(reason);
930
+ },
931
+ };
932
+ this.#pending.set(id, pending);
933
+ if (timeoutMs !== undefined && timeoutMs > 0) {
934
+ timer = setTimeout(() => {
935
+ // Fire only if this exact request is still outstanding.
936
+ if (this.#pending.get(id) === pending) {
937
+ this.#pending.delete(id);
938
+ reject(
939
+ new TransportFailedError(
940
+ `RPC '${method}' (id=${id}) timed out after ${timeoutMs}ms with no server reply.`,
941
+ ),
942
+ );
943
+ }
944
+ }, timeoutMs);
945
+ }
946
+ try {
947
+ // Asserting non-null because we just verified above.
948
+ (this.#transport as Transport).send(frame);
949
+ } catch (err) {
950
+ clear();
951
+ this.#pending.delete(id);
952
+ reject(err);
953
+ }
954
+ });
955
+ }
956
+
957
+ #nextId(): string {
958
+ this.#idCounter += 1;
959
+ const prefix = this.#options.clientId ?? 'graphorin';
960
+ return `${prefix}-${this.#idCounter.toString(36)}`;
961
+ }
962
+ }
963
+
964
+ interface PendingRpc {
965
+ readonly resolve: (value: unknown) => void;
966
+ readonly reject: (reason: Error) => void;
967
+ }
968
+
969
+ function subjectFor(target: SubscriptionTarget): string {
970
+ switch (target.target) {
971
+ case 'session':
972
+ assertNonEmpty(target.id, 'session.id');
973
+ return `session:${target.id}/events`;
974
+ case 'agent':
975
+ assertNonEmpty(target.id, 'agent.id');
976
+ assertNonEmpty(target.runId, 'agent.runId');
977
+ return `agent:${target.id}/runs/${target.runId}/events`;
978
+ case 'run':
979
+ assertNonEmpty(target.runId, 'run.runId');
980
+ if (target.sessionId !== undefined) {
981
+ assertNonEmpty(target.sessionId, 'run.sessionId');
982
+ return `session:${target.sessionId}/runs/${target.runId}/events`;
983
+ }
984
+ // periphery-09: the server subject grammar has NO bare `run:`
985
+ // form - the old `run:<id>/events` fallback always failed
986
+ // server-side with an opaque unknown-subject error. Fail fast
987
+ // with an actionable message instead.
988
+ throw new TypeError(
989
+ "subscribe({ target: 'run' }) requires `sessionId` - the server subject grammar has no bare run form. " +
990
+ "Pass { kind: 'run', runId, sessionId } or use the 'agent' target with the agent id.",
991
+ );
992
+ case 'workflow':
993
+ assertNonEmpty(target.id, 'workflow.id');
994
+ return `workflow:${target.id}/events`;
995
+ }
996
+ }
997
+
998
+ function assertNonEmpty(value: string, fieldName: string): void {
999
+ if (typeof value !== 'string' || value.length === 0) {
1000
+ throw new TypeError(`${fieldName} must be a non-empty string.`);
1001
+ }
1002
+ }
1003
+
1004
+ function joinUrl(baseUrl: string, path: string, opts: { readonly ws: boolean }): string {
1005
+ let normalized = baseUrl;
1006
+ if (opts.ws) {
1007
+ if (normalized.startsWith('http://')) normalized = normalized.replace(/^http:\/\//, 'ws://');
1008
+ else if (normalized.startsWith('https://'))
1009
+ normalized = normalized.replace(/^https:\/\//, 'wss://');
1010
+ } else {
1011
+ if (normalized.startsWith('ws://')) normalized = normalized.replace(/^ws:\/\//, 'http://');
1012
+ else if (normalized.startsWith('wss://'))
1013
+ normalized = normalized.replace(/^wss:\/\//, 'https://');
1014
+ }
1015
+ if (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
1016
+ const tail = path.startsWith('/') ? path : `/${path}`;
1017
+ return `${normalized}${tail}`;
1018
+ }
1019
+
1020
+ function buildRpcFrame(
1021
+ method: ClientMessage['method'],
1022
+ id: ClientMessageId,
1023
+ params?: object,
1024
+ ): ClientMessage {
1025
+ switch (method) {
1026
+ case 'initialize':
1027
+ return {
1028
+ v: '1',
1029
+ jsonrpc: '2.0',
1030
+ id,
1031
+ method: 'initialize',
1032
+ params: (params ?? {
1033
+ clientInfo: { name: 'graphorin-client', version: pkg.version },
1034
+ }) as never,
1035
+ };
1036
+ case 'subscription.subscribe':
1037
+ return {
1038
+ v: '1',
1039
+ jsonrpc: '2.0',
1040
+ id,
1041
+ method: 'subscription.subscribe',
1042
+ params: (params ?? { subject: '' }) as never,
1043
+ };
1044
+ case 'subscription.unsubscribe':
1045
+ return {
1046
+ v: '1',
1047
+ jsonrpc: '2.0',
1048
+ id,
1049
+ method: 'subscription.unsubscribe',
1050
+ params: (params ?? { subscriptionId: '' }) as never,
1051
+ };
1052
+ case 'run.cancel':
1053
+ return {
1054
+ v: '1',
1055
+ jsonrpc: '2.0',
1056
+ id,
1057
+ method: 'run.cancel',
1058
+ params: (params ?? { runId: '' }) as never,
1059
+ };
1060
+ case 'ping':
1061
+ return {
1062
+ v: '1',
1063
+ jsonrpc: '2.0',
1064
+ id,
1065
+ method: 'ping',
1066
+ ...(params !== undefined ? { params: params as never } : {}),
1067
+ };
1068
+ case 'notifications/cancelled':
1069
+ throw new TypeError(
1070
+ "buildRpcFrame: 'notifications/cancelled' is a notification, not an RPC.",
1071
+ );
1072
+ }
1073
+ // Exhaustiveness check; should be unreachable.
1074
+ throw new TypeError(`Unknown RPC method '${method}'.`);
1075
+ }