@sayknow-cli/bridge-client 0.3.16 → 0.4.2

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/src/client.ts ADDED
@@ -0,0 +1,684 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export type SdkErrorCode =
4
+ | "invalid_input"
5
+ | "unknown_operation"
6
+ | "not_found"
7
+ | "unavailable"
8
+ | "timeout"
9
+ | "connection_closed"
10
+ | "endpoint_credential_forbidden"
11
+ | (string & {});
12
+
13
+ export class SdkClientError extends Error {
14
+ readonly code: SdkErrorCode;
15
+ readonly details: unknown;
16
+ constructor(code: SdkErrorCode, message: string, details?: unknown) {
17
+ super(message);
18
+ this.name = "SdkClientError";
19
+ this.code = code;
20
+ this.details = details;
21
+ }
22
+ }
23
+
24
+ export interface SdkClientOptions {
25
+ timeoutMs?: number;
26
+ /** Absolute wall-clock deadline shared by connect, hello, retry, and request work. */
27
+ deadline?: number;
28
+
29
+ reconnectAttempts?: number;
30
+ reconnectBackoffMs?: number;
31
+ }
32
+
33
+ export interface SdkRequestOptions {
34
+ timeoutMs?: number;
35
+ idempotencyKey?: string;
36
+ confirm?: boolean;
37
+ }
38
+
39
+ export type SdkFrame = Record<string, unknown>;
40
+ export type SdkFrameHandler = (frame: SdkFrame) => void;
41
+ export type SdkReconnectHandler = () => void;
42
+ export type SdkReconnectFailedHandler = (error: SdkClientError) => void;
43
+
44
+ type Frame = SdkFrame;
45
+ type Cycle = {
46
+ readonly generation: number;
47
+ phase: "opening" | "backoff" | "complete" | "aborted";
48
+ candidate: Incarnation | null;
49
+ promise?: Promise<Incarnation>;
50
+ backoffTimer?: ReturnType<typeof setTimeout>;
51
+ rejectBackoff?: (error: Error) => void;
52
+ };
53
+ type Incarnation = {
54
+ readonly generation: number;
55
+ readonly cycle: Cycle;
56
+ readonly socket: WebSocket;
57
+ phase: "opening" | "hello" | "active" | "retired";
58
+ tornDown: boolean;
59
+ openTimer?: ReturnType<typeof setTimeout>;
60
+ failure?: Error;
61
+ helloTimer?: ReturnType<typeof setTimeout>;
62
+ resolveOpen?: () => void;
63
+ rejectOpen?: (error: Error) => void;
64
+ resolveHello?: () => void;
65
+ rejectHello?: (error: Error) => void;
66
+ listeners: Array<["open" | "error" | "close" | "message", EventListener]>;
67
+ };
68
+ type Pending = {
69
+ readonly incarnation: Incarnation;
70
+ resolve: (value: unknown) => void;
71
+ reject: (error: Error) => void;
72
+ timer: ReturnType<typeof setTimeout>;
73
+ };
74
+
75
+ function errorFrom(frame: Frame): SdkClientError {
76
+ const error = frame.error;
77
+ if (error && typeof error === "object") {
78
+ const detail = error as { code?: unknown; message?: unknown };
79
+ return new SdkClientError(
80
+ typeof detail.code === "string" ? detail.code : "unavailable",
81
+ typeof detail.message === "string" ? detail.message : "SDK request failed",
82
+ error,
83
+ );
84
+ }
85
+ return new SdkClientError("unavailable", "SDK request failed", error);
86
+ }
87
+
88
+ function parseFrame(value: unknown): Frame {
89
+ try {
90
+ const frame = JSON.parse(String(value));
91
+ if (frame && typeof frame === "object" && !Array.isArray(frame)) return frame as Frame;
92
+ } catch (error) {
93
+ throw new SdkClientError("protocol_error", "SDK server sent malformed JSON.", error);
94
+ }
95
+ throw new SdkClientError("protocol_error", "SDK server sent a malformed frame.");
96
+ }
97
+
98
+ /** A transport-only v3 SDK WebSocket client with no host or session authority. */
99
+ export class SdkClient {
100
+ readonly #url: string;
101
+ readonly #token: string;
102
+ readonly #timeoutMs: number;
103
+ readonly #reconnectAttempts: number;
104
+ readonly #reconnectBackoffMs: number;
105
+ /**
106
+ * Bounded grace for best-effort transport close, independent of the request
107
+ * deadline. Close teardown must never be gated by an already-elapsed operation
108
+ * deadline, or the socket leaks.
109
+ */
110
+ readonly #closeGraceMs: number;
111
+ readonly #deadline?: number;
112
+ #currentSocketRecord: Incarnation | null = null;
113
+ #opening: Cycle | null = null;
114
+ #cycleGeneration = 0;
115
+ #incarnationGeneration = 0;
116
+ #pending = new Map<string, Pending>();
117
+ #frameHandlers = new Set<SdkFrameHandler>();
118
+ #reconnectHandlers = new Set<SdkReconnectHandler>();
119
+ #reconnectFailedHandlers = new Set<SdkReconnectFailedHandler>();
120
+ #closePromise: Promise<void> | undefined;
121
+
122
+ #closed = false;
123
+ connectionId?: string;
124
+
125
+ constructor(url: string, token: string, options: SdkClientOptions = {}) {
126
+ this.#url = url;
127
+ this.#token = token;
128
+ this.#timeoutMs = options.timeoutMs ?? 10_000;
129
+ this.#closeGraceMs = Math.max(1, Math.min(this.#timeoutMs, 1_000));
130
+ this.#deadline =
131
+ typeof options.deadline === "number" && Number.isFinite(options.deadline) ? options.deadline : undefined;
132
+
133
+ this.#reconnectAttempts = options.reconnectAttempts ?? 3;
134
+ this.#reconnectBackoffMs = options.reconnectBackoffMs ?? 25;
135
+ }
136
+
137
+ static async connect(url: string, token: string, options: SdkClientOptions = {}): Promise<SdkClient> {
138
+ const client = new SdkClient(url, token, options);
139
+ await client.connect();
140
+ return client;
141
+ }
142
+
143
+ async connect(): Promise<void> {
144
+ await this.#connect();
145
+ }
146
+
147
+ /** Resolves once the current WebSocket has received its server hello frame. */
148
+ async awaitHello(): Promise<void> {
149
+ await this.#connect();
150
+ }
151
+
152
+ onFrame(handler: SdkFrameHandler): () => void {
153
+ this.#frameHandlers.add(handler);
154
+ return () => this.#frameHandlers.delete(handler);
155
+ }
156
+
157
+ onReconnect(handler: SdkReconnectHandler): () => void {
158
+ this.#reconnectHandlers.add(handler);
159
+ return () => this.#reconnectHandlers.delete(handler);
160
+ }
161
+
162
+ onReconnectFailed(handler: SdkReconnectFailedHandler): () => void {
163
+ this.#reconnectFailedHandlers.add(handler);
164
+ return () => this.#reconnectFailedHandlers.delete(handler);
165
+ }
166
+
167
+ send(frame: SdkFrame): void {
168
+ if (this.#closed) throw new SdkClientError("connection_closed", "SDK client closed");
169
+ this.#throwIfDeadlineElapsed();
170
+ const current = this.#currentSocketRecord ?? this.#opening?.candidate;
171
+ const authoritative =
172
+ this.#isActive(current ?? null) ||
173
+ (!!current && current.phase === "hello" && this.#isCandidate(current.cycle, current));
174
+ if (!current || !authoritative || current.socket.readyState !== WebSocket.OPEN)
175
+ throw new SdkClientError("connection_closed", "SDK WebSocket is not connected");
176
+ try {
177
+ current.socket.send(JSON.stringify(frame));
178
+ } catch (error) {
179
+ throw new SdkClientError("unavailable", "SDK WebSocket send failed", error);
180
+ }
181
+ }
182
+
183
+ request(frame: SdkFrame, timeout?: number | { timeoutMs?: number; idempotencyKey?: string }): Promise<SdkFrame> {
184
+ const options = typeof timeout === "number" ? { timeoutMs: timeout } : (timeout ?? {});
185
+ return this.#request(frame, options) as Promise<SdkFrame>;
186
+ }
187
+
188
+ close(): Promise<void> {
189
+ this.#closePromise ??= this.#close();
190
+ return this.#closePromise;
191
+ }
192
+ async #close(): Promise<void> {
193
+ this.#closed = true;
194
+ const transports = new Set<Incarnation>();
195
+ const cycle = this.#opening;
196
+ if (cycle) {
197
+ cycle.phase = "aborted";
198
+ if (cycle.backoffTimer) clearTimeout(cycle.backoffTimer);
199
+ if (cycle.candidate) {
200
+ transports.add(cycle.candidate);
201
+ this.#retire(cycle.candidate, new SdkClientError("connection_closed", "SDK client closed"), false);
202
+ }
203
+ cycle.rejectBackoff?.(new SdkClientError("connection_closed", "SDK client closed"));
204
+ cycle.rejectBackoff = undefined;
205
+ if (this.#opening === cycle) this.#opening = null;
206
+ }
207
+ const current = this.#currentSocketRecord;
208
+ if (current) {
209
+ transports.add(current);
210
+ this.#retire(current, new SdkClientError("connection_closed", "SDK client closed"), false);
211
+ }
212
+ for (const [id, pending] of this.#pending)
213
+ this.#settlePending(id, pending, new SdkClientError("connection_closed", "SDK client closed"));
214
+ await Promise.all([...transports].map(incarnation => this.#closeTransport(incarnation)));
215
+ }
216
+
217
+ async control(
218
+ operation: string,
219
+ input: Record<string, unknown> = {},
220
+ options: SdkRequestOptions = {},
221
+ ): Promise<unknown> {
222
+ return await this.#request(
223
+ {
224
+ type: "control_request",
225
+ operation,
226
+ input,
227
+ ...(options.confirm === undefined ? {} : { confirm: options.confirm }),
228
+ },
229
+ options,
230
+ );
231
+ }
232
+
233
+ async query(
234
+ query: string,
235
+ input: Record<string, unknown> = {},
236
+ cursor?: string,
237
+ options: SdkRequestOptions = {},
238
+ ): Promise<unknown> {
239
+ return await this.#request(
240
+ { type: "query_request", query, input, ...(cursor === undefined ? {} : { cursor }) },
241
+ options,
242
+ );
243
+ }
244
+
245
+ async global(
246
+ operation: string,
247
+ input: Record<string, unknown> = {},
248
+ options: SdkRequestOptions = {},
249
+ ): Promise<unknown> {
250
+ return await this.#request({ type: "broker_request", operation, input }, options);
251
+ }
252
+
253
+ async #request(frame: Frame, options: SdkRequestOptions): Promise<unknown> {
254
+ if (this.#closed) throw new SdkClientError("connection_closed", "SDK client closed");
255
+ this.#throwIfDeadlineElapsed();
256
+ const incarnation = await this.#connect();
257
+ const timeoutMs = this.#remainingTimeout(options.timeoutMs ?? this.#timeoutMs);
258
+ if (timeoutMs <= 0) throw this.#deadlineError();
259
+ const id = randomUUID();
260
+ return await new Promise<unknown>((resolve, reject) => {
261
+ const pending: Pending = {
262
+ incarnation,
263
+ resolve,
264
+ reject,
265
+ timer: setTimeout(
266
+ () =>
267
+ this.#settlePending(
268
+ id,
269
+ pending,
270
+ new SdkClientError("timeout", `SDK request timed out after ${timeoutMs}ms`),
271
+ ),
272
+ timeoutMs,
273
+ ),
274
+ };
275
+ this.#pending.set(id, pending);
276
+ if (!this.#isActive(incarnation) || incarnation.socket.readyState !== WebSocket.OPEN) {
277
+ this.#settlePending(id, pending, new SdkClientError("unavailable", "SDK WebSocket is not connected"));
278
+ return;
279
+ }
280
+ try {
281
+ incarnation.socket.send(
282
+ JSON.stringify({
283
+ ...frame,
284
+ id,
285
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
286
+ }),
287
+ );
288
+ } catch (error) {
289
+ this.#settlePending(
290
+ id,
291
+ pending,
292
+ error instanceof SdkClientError
293
+ ? error
294
+ : new SdkClientError("unavailable", "SDK WebSocket send failed", error),
295
+ );
296
+ }
297
+ });
298
+ }
299
+
300
+ #deadlineError(): SdkClientError {
301
+ return new SdkClientError("timeout", "SDK client deadline elapsed.");
302
+ }
303
+
304
+ #remainingTimeout(limit = this.#timeoutMs): number {
305
+ if (this.#deadline === undefined) return limit;
306
+ return Math.min(limit, Math.max(0, this.#deadline - Date.now()));
307
+ }
308
+
309
+ #throwIfDeadlineElapsed(): void {
310
+ if (this.#deadline !== undefined && Date.now() >= this.#deadline) throw this.#deadlineError();
311
+ }
312
+
313
+ async #connect(): Promise<Incarnation> {
314
+ this.#throwIfDeadlineElapsed();
315
+ const current = this.#currentSocketRecord;
316
+ if (current && this.#isActive(current) && current.socket.readyState === WebSocket.OPEN) return current;
317
+ if (current)
318
+ this.#retire(current, new SdkClientError("connection_closed", "SDK WebSocket connection closed"), true);
319
+ let cycle = this.#opening;
320
+ if (!cycle) {
321
+ cycle = { generation: ++this.#cycleGeneration, phase: "opening", candidate: null };
322
+ this.#opening = cycle;
323
+ cycle.promise = this.#openWithRetry(cycle);
324
+ }
325
+ return await cycle.promise!;
326
+ }
327
+
328
+ async #openWithRetry(cycle: Cycle): Promise<Incarnation> {
329
+ let lastError: unknown;
330
+ for (let attempt = 0; attempt <= this.#reconnectAttempts; attempt++) {
331
+ if (this.#deadline !== undefined && Date.now() >= this.#deadline) {
332
+ const error = this.#deadlineError();
333
+ this.#completeCycle(cycle, error);
334
+ throw error;
335
+ }
336
+ if (!this.#isOpening(cycle)) throw new SdkClientError("connection_closed", "SDK client closed");
337
+ try {
338
+ const incarnation = await this.#open(cycle);
339
+ if (!this.#isActive(incarnation) && (!this.#isOpening(cycle) || cycle.candidate !== incarnation))
340
+ throw new SdkClientError("connection_closed", "SDK WebSocket is not connected");
341
+ await this.#waitForHello(incarnation);
342
+ if (this.#isActive(incarnation)) return incarnation;
343
+ throw new SdkClientError("connection_closed", "SDK WebSocket is not connected");
344
+ } catch (error) {
345
+ lastError = error;
346
+ if (!this.#isOpening(cycle)) throw error;
347
+ const candidate = cycle.candidate;
348
+ if (candidate && candidate.phase !== "active")
349
+ this.#retire(
350
+ candidate,
351
+ error instanceof SdkClientError
352
+ ? error
353
+ : new SdkClientError("unavailable", "SDK WebSocket connection failed", error),
354
+ true,
355
+ );
356
+ if (attempt < this.#reconnectAttempts) {
357
+ const backoffMs = this.#remainingTimeout(this.#reconnectBackoffMs * 2 ** attempt);
358
+ if (backoffMs <= 0) break;
359
+ cycle.phase = "backoff";
360
+ await new Promise<void>((resolve, reject) => {
361
+ cycle.rejectBackoff = reject;
362
+ cycle.backoffTimer = setTimeout(resolve, backoffMs);
363
+ });
364
+ cycle.rejectBackoff = undefined;
365
+ cycle.backoffTimer = undefined;
366
+ if (!this.#isOpening(cycle)) throw new SdkClientError("connection_closed", "SDK client closed");
367
+ cycle.phase = "opening";
368
+ }
369
+ }
370
+ }
371
+ if (!this.#isOpening(cycle)) throw new SdkClientError("connection_closed", "SDK client closed");
372
+ if (this.#deadline !== undefined && Date.now() >= this.#deadline) {
373
+ const error = this.#deadlineError();
374
+ this.#completeCycle(cycle, error);
375
+ throw error;
376
+ }
377
+ cycle.phase = "complete";
378
+ if (this.#opening === cycle) this.#opening = null;
379
+ const error = new SdkClientError("reconnect_exhausted", "SDK WebSocket reconnect attempts exhausted", lastError);
380
+ this.#notifyReconnectFailedHandlers(error);
381
+ throw error;
382
+ }
383
+
384
+ #completeCycle(cycle: Cycle, error: SdkClientError): void {
385
+ if (cycle.backoffTimer) clearTimeout(cycle.backoffTimer);
386
+ cycle.rejectBackoff?.(error);
387
+ cycle.rejectBackoff = undefined;
388
+ cycle.backoffTimer = undefined;
389
+ const candidate = cycle.candidate;
390
+ if (candidate) this.#retire(candidate, error, true);
391
+ cycle.candidate = null;
392
+ cycle.phase = "complete";
393
+ if (this.#opening === cycle) this.#opening = null;
394
+ }
395
+
396
+ #open(cycle: Cycle): Promise<Incarnation> {
397
+ const timeoutMs = this.#remainingTimeout();
398
+ if (timeoutMs <= 0) return Promise.reject(this.#deadlineError());
399
+ return new Promise((resolve, reject) => {
400
+ const url = new URL(this.#url);
401
+ url.searchParams.set("token", this.#token);
402
+ const socket = new WebSocket(url);
403
+ const incarnation: Incarnation = {
404
+ generation: ++this.#incarnationGeneration,
405
+ cycle,
406
+ socket,
407
+ phase: "opening",
408
+ tornDown: false,
409
+ listeners: [],
410
+ resolveOpen: () => resolve(incarnation),
411
+ rejectOpen: reject,
412
+ };
413
+ cycle.candidate = incarnation;
414
+ const add = (type: "open" | "error" | "close" | "message", listener: EventListener, once = false) => {
415
+ incarnation.listeners.push([type, listener]);
416
+ socket.addEventListener(type, listener, once ? { once: true } : undefined);
417
+ };
418
+ add(
419
+ "open",
420
+ (() => {
421
+ if (!this.#isCandidate(cycle, incarnation) || incarnation.phase !== "opening") return;
422
+ if (incarnation.openTimer) clearTimeout(incarnation.openTimer);
423
+ incarnation.phase = "hello";
424
+ incarnation.resolveOpen?.();
425
+ incarnation.resolveOpen = undefined;
426
+ incarnation.rejectOpen = undefined;
427
+ this.#beginHello(incarnation);
428
+ }) as EventListener,
429
+ true,
430
+ );
431
+ add("error", ((event: Event) => this.#onSocketFailure(incarnation, event)) as EventListener);
432
+ add("close", (() => this.#onSocketFailure(incarnation)) as EventListener);
433
+ add("message", ((event: MessageEvent) => this.#onMessage(event.data, incarnation)) as EventListener);
434
+ incarnation.openTimer = setTimeout(() => this.#onOpenTimeout(incarnation, timeoutMs), timeoutMs);
435
+ incarnation.openTimer.unref?.();
436
+ });
437
+ }
438
+
439
+ #beginHello(incarnation: Incarnation): void {
440
+ const timeoutMs = this.#remainingTimeout();
441
+ if (timeoutMs <= 0) {
442
+ this.#retire(incarnation, this.#deadlineError(), true);
443
+ return;
444
+ }
445
+ incarnation.helloTimer = setTimeout(() => {
446
+ if (!this.#isCandidate(incarnation.cycle, incarnation) || incarnation.phase !== "hello") return;
447
+ const error =
448
+ this.#deadline !== undefined && Date.now() >= this.#deadline
449
+ ? this.#deadlineError()
450
+ : new SdkClientError("protocol_error", "SDK server did not send a hello frame.");
451
+ incarnation.rejectHello?.(error);
452
+ this.#retire(incarnation, error, true);
453
+ }, timeoutMs);
454
+ incarnation.helloTimer.unref?.();
455
+ }
456
+
457
+ #waitForHello(incarnation: Incarnation): Promise<void> {
458
+ if (incarnation.failure) return Promise.reject(incarnation.failure);
459
+ if (this.#isActive(incarnation)) return Promise.resolve();
460
+ if (!this.#isCandidate(incarnation.cycle, incarnation) || incarnation.phase !== "hello")
461
+ return Promise.reject(new SdkClientError("connection_closed", "SDK WebSocket is not connected"));
462
+ return new Promise((resolve, reject) => {
463
+ incarnation.resolveHello = resolve;
464
+ incarnation.rejectHello = reject;
465
+ });
466
+ }
467
+
468
+ #onOpenTimeout(incarnation: Incarnation, timeoutMs: number): void {
469
+ if (!this.#isCandidate(incarnation.cycle, incarnation) || incarnation.phase !== "opening") return;
470
+ const error =
471
+ this.#deadline !== undefined && Date.now() >= this.#deadline
472
+ ? this.#deadlineError()
473
+ : new SdkClientError("timeout", `SDK WebSocket connection timed out after ${timeoutMs}ms`);
474
+ incarnation.rejectOpen?.(error);
475
+ this.#retire(incarnation, error, true);
476
+ }
477
+
478
+ #onSocketFailure(incarnation: Incarnation, event?: Event): void {
479
+ if (!this.#isCandidate(incarnation.cycle, incarnation) && !this.#isActive(incarnation)) return;
480
+ const detail = event as (Event & { error?: unknown; message?: unknown }) | undefined;
481
+ const error =
482
+ detail?.error instanceof Error
483
+ ? detail.error
484
+ : new SdkClientError(
485
+ "connection_closed",
486
+ typeof detail?.message === "string" ? detail.message : "SDK WebSocket connection closed",
487
+ );
488
+ if (incarnation.phase === "opening") incarnation.rejectOpen?.(error);
489
+ if (incarnation.phase === "hello") incarnation.rejectHello?.(error);
490
+ this.#retire(
491
+ incarnation,
492
+ error instanceof SdkClientError
493
+ ? error
494
+ : new SdkClientError("unavailable", "SDK WebSocket connection failed", error),
495
+ true,
496
+ );
497
+ }
498
+
499
+ #onMessage(value: unknown, incarnation: Incarnation): void {
500
+ if (!this.#isCandidate(incarnation.cycle, incarnation) && !this.#isActive(incarnation)) return;
501
+ let frame: Frame;
502
+ try {
503
+ frame = parseFrame(value);
504
+ if (frame.type === "control_command_result" && typeof frame.message === "string")
505
+ frame = parseFrame(frame.message);
506
+ } catch (error) {
507
+ this.#rejectPendingFor(
508
+ incarnation,
509
+ error instanceof SdkClientError
510
+ ? error
511
+ : new SdkClientError("protocol_error", "SDK server sent malformed frame.", error),
512
+ );
513
+ return;
514
+ }
515
+ if (frame.type === "hello" || frame.type === "server_hello" || frame.type === "broker_hello") {
516
+ if (incarnation.phase === "hello" && this.#isCandidate(incarnation.cycle, incarnation)) {
517
+ this.#acceptHello(incarnation, frame);
518
+ if (this.#isActive(incarnation)) this.#notifyFrameHandlers(frame);
519
+ return;
520
+ }
521
+ if (!this.#isActive(incarnation)) return;
522
+ if (
523
+ typeof frame.connectionId !== "string" ||
524
+ frame.connectionId.length === 0 ||
525
+ frame.connectionId === this.connectionId
526
+ )
527
+ return;
528
+ this.connectionId = frame.connectionId;
529
+ this.#notifyReconnectHandlers();
530
+ }
531
+ if (!this.#isActive(incarnation)) return;
532
+ const id =
533
+ typeof frame.id === "string" ? frame.id : typeof frame.requestId === "string" ? frame.requestId : undefined;
534
+ if (id) {
535
+ const pending = this.#pending.get(id);
536
+ if (pending?.incarnation === incarnation) {
537
+ this.#settlePending(id, pending, frame.ok === false || frame.status === "error" ? errorFrom(frame) : frame);
538
+ }
539
+ }
540
+ this.#notifyFrameHandlers(frame);
541
+ }
542
+
543
+ #notifyFrameHandlers(frame: Frame): void {
544
+ for (const handler of [...this.#frameHandlers]) {
545
+ try {
546
+ handler(frame);
547
+ } catch {
548
+ // Observers cannot change transport settlement or prevent later observers.
549
+ }
550
+ }
551
+ }
552
+
553
+ #notifyReconnectHandlers(): void {
554
+ for (const handler of [...this.#reconnectHandlers]) {
555
+ try {
556
+ handler();
557
+ } catch {
558
+ // Reconnect observers cannot change transport state or prevent later observers.
559
+ }
560
+ }
561
+ }
562
+
563
+ #notifyReconnectFailedHandlers(error: SdkClientError): void {
564
+ for (const handler of [...this.#reconnectFailedHandlers]) {
565
+ try {
566
+ handler(error);
567
+ } catch {
568
+ // Failure observers cannot replace the typed transport error or prevent later observers.
569
+ }
570
+ }
571
+ }
572
+
573
+ #acceptHello(incarnation: Incarnation, frame: Frame): void {
574
+ if (!this.#isCandidate(incarnation.cycle, incarnation) || incarnation.phase !== "hello") return;
575
+ if (incarnation.helloTimer) clearTimeout(incarnation.helloTimer);
576
+ const reconnecting =
577
+ typeof frame.connectionId === "string" &&
578
+ frame.connectionId.length > 0 &&
579
+ this.connectionId !== undefined &&
580
+ this.connectionId !== frame.connectionId;
581
+ if (typeof frame.connectionId === "string" && frame.connectionId.length > 0)
582
+ this.connectionId = frame.connectionId;
583
+ incarnation.phase = "active";
584
+ this.#currentSocketRecord = incarnation;
585
+ incarnation.cycle.phase = "complete";
586
+ if (this.#opening === incarnation.cycle) this.#opening = null;
587
+ const resolveHello = incarnation.resolveHello;
588
+ incarnation.resolveHello = undefined;
589
+ incarnation.rejectHello = undefined;
590
+ resolveHello?.();
591
+ if (reconnecting) this.#notifyReconnectHandlers();
592
+ }
593
+
594
+ #settlePending(id: string, pending: Pending, result: unknown): void {
595
+ if (this.#pending.get(id) !== pending) return;
596
+ this.#pending.delete(id);
597
+ clearTimeout(pending.timer);
598
+ if (result instanceof Error) pending.reject(result);
599
+ else pending.resolve(result);
600
+ }
601
+ #rejectPendingFor(incarnation: Incarnation, error: SdkClientError): void {
602
+ for (const [id, pending] of this.#pending)
603
+ if (pending.incarnation === incarnation) this.#settlePending(id, pending, error);
604
+ }
605
+ #retire(incarnation: Incarnation, error: SdkClientError, closeSocket: boolean): void {
606
+ if (incarnation.tornDown) return;
607
+ const phase = incarnation.phase;
608
+ incarnation.phase = "retired";
609
+ incarnation.failure = error;
610
+ if (phase === "opening") incarnation.rejectOpen?.(error);
611
+ if (phase === "hello") incarnation.rejectHello?.(error);
612
+ incarnation.resolveOpen = undefined;
613
+ incarnation.rejectOpen = undefined;
614
+ incarnation.resolveHello = undefined;
615
+ incarnation.rejectHello = undefined;
616
+ this.#rejectPendingFor(incarnation, error);
617
+ if (this.#currentSocketRecord === incarnation) this.#currentSocketRecord = null;
618
+ if (incarnation.cycle.candidate === incarnation) incarnation.cycle.candidate = null;
619
+ this.#teardown(incarnation, closeSocket);
620
+ }
621
+ #teardown(incarnation: Incarnation, closeSocket: boolean): void {
622
+ if (incarnation.tornDown) return;
623
+ incarnation.tornDown = true;
624
+ if (incarnation.openTimer) clearTimeout(incarnation.openTimer);
625
+ if (incarnation.helloTimer) clearTimeout(incarnation.helloTimer);
626
+ for (const [type, listener] of incarnation.listeners) incarnation.socket.removeEventListener(type, listener);
627
+ incarnation.listeners = [];
628
+ if (closeSocket)
629
+ try {
630
+ incarnation.socket.close();
631
+ } catch {}
632
+ }
633
+ async #closeTransport(incarnation: Incarnation): Promise<void> {
634
+ const socket = incarnation.socket;
635
+ if (socket.readyState === WebSocket.CLOSED) return;
636
+ // Close teardown must always issue socket.close() and be bounded by a
637
+ // dedicated close grace, never by the (possibly elapsed) request deadline —
638
+ // gating on an expired deadline would throw before close and leak the socket.
639
+ const timeoutMs = this.#closeGraceMs;
640
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
641
+ const onClose = (): void => resolve();
642
+ socket.addEventListener("close", onClose, { once: true });
643
+ const timer = setTimeout(
644
+ () => reject(new SdkClientError("timeout", `SDK WebSocket close timed out after ${timeoutMs}ms`)),
645
+ timeoutMs,
646
+ );
647
+ timer.unref?.();
648
+ try {
649
+ socket.close();
650
+ if (Number(socket.readyState) === WebSocket.CLOSED) resolve();
651
+ await promise;
652
+ } catch (error) {
653
+ if (error instanceof SdkClientError) throw error;
654
+ if (Number(socket.readyState) !== WebSocket.CLOSED)
655
+ throw new SdkClientError("connection_closed", "SDK WebSocket close failed", error);
656
+ } finally {
657
+ clearTimeout(timer);
658
+ socket.removeEventListener("close", onClose);
659
+ }
660
+ }
661
+ #isCandidate(cycle: Cycle, incarnation: Incarnation): boolean {
662
+ return (
663
+ !this.#closed &&
664
+ this.#opening === cycle &&
665
+ cycle.candidate === incarnation &&
666
+ cycle.generation > 0 &&
667
+ incarnation.generation > 0 &&
668
+ incarnation.cycle === cycle &&
669
+ (cycle.phase === "opening" || cycle.phase === "backoff")
670
+ );
671
+ }
672
+ #isOpening(cycle: Cycle): boolean {
673
+ return !this.#closed && this.#opening === cycle && (cycle.phase === "opening" || cycle.phase === "backoff");
674
+ }
675
+ #isActive(incarnation: Incarnation | null): boolean {
676
+ return (
677
+ !!incarnation &&
678
+ incarnation.generation > 0 &&
679
+ !this.#closed &&
680
+ this.#currentSocketRecord === incarnation &&
681
+ incarnation.phase === "active"
682
+ );
683
+ }
684
+ }