@milaboratories/pl-client 2.16.10 → 2.16.12

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.
Files changed (36) hide show
  1. package/dist/core/errors.cjs +2 -0
  2. package/dist/core/errors.cjs.map +1 -1
  3. package/dist/core/errors.d.ts.map +1 -1
  4. package/dist/core/errors.js +2 -0
  5. package/dist/core/errors.js.map +1 -1
  6. package/dist/core/ll_client.cjs +22 -8
  7. package/dist/core/ll_client.cjs.map +1 -1
  8. package/dist/core/ll_client.d.ts.map +1 -1
  9. package/dist/core/ll_client.js +22 -8
  10. package/dist/core/ll_client.js.map +1 -1
  11. package/dist/core/ll_transaction.cjs +10 -0
  12. package/dist/core/ll_transaction.cjs.map +1 -1
  13. package/dist/core/ll_transaction.d.ts +1 -0
  14. package/dist/core/ll_transaction.d.ts.map +1 -1
  15. package/dist/core/ll_transaction.js +10 -0
  16. package/dist/core/ll_transaction.js.map +1 -1
  17. package/dist/core/websocket_stream.cjs +333 -0
  18. package/dist/core/websocket_stream.cjs.map +1 -0
  19. package/dist/core/websocket_stream.d.ts +60 -0
  20. package/dist/core/websocket_stream.d.ts.map +1 -0
  21. package/dist/core/websocket_stream.js +331 -0
  22. package/dist/core/websocket_stream.js.map +1 -0
  23. package/dist/helpers/retry_strategy.cjs +92 -0
  24. package/dist/helpers/retry_strategy.cjs.map +1 -0
  25. package/dist/helpers/retry_strategy.d.ts +24 -0
  26. package/dist/helpers/retry_strategy.d.ts.map +1 -0
  27. package/dist/helpers/retry_strategy.js +89 -0
  28. package/dist/helpers/retry_strategy.js.map +1 -0
  29. package/package.json +5 -5
  30. package/src/core/errors.ts +1 -0
  31. package/src/core/ll_client.ts +25 -8
  32. package/src/core/ll_transaction.test.ts +18 -0
  33. package/src/core/ll_transaction.ts +12 -0
  34. package/src/core/websocket_stream.test.ts +412 -0
  35. package/src/core/websocket_stream.ts +412 -0
  36. package/src/helpers/retry_strategy.ts +123 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retry_strategy.js","sources":["../../src/helpers/retry_strategy.ts"],"sourcesContent":["export interface RetryConfig {\n maxAttempts: number;\n initialDelay: number;\n maxDelay: number;\n}\n\nexport const DEFAULT_RETRY_CONFIG: RetryConfig = {\n maxAttempts: 10,\n initialDelay: 100,\n maxDelay: 30000,\n};\n\nexport interface RetryCallbacks {\n onRetry: () => void;\n onMaxAttemptsReached: (error: Error) => void;\n}\n\nexport class RetryStrategy {\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n private readonly config: RetryConfig;\n private readonly callbacks: RetryCallbacks;\n private readonly backoff: ExponentialBackoff;\n\n constructor(config: Partial<RetryConfig>, callbacks: RetryCallbacks) {\n this.config = { ...DEFAULT_RETRY_CONFIG, ...config };\n this.callbacks = callbacks;\n this.backoff = new ExponentialBackoff({\n initialDelay: this.config.initialDelay,\n maxDelay: this.config.maxDelay,\n factor: 2,\n jitter: 0.1,\n });\n }\n\n schedule(): void {\n if (this.timer) return;\n if (this.hasExceededLimit()) {\n this.notifyMaxAttemptsReached();\n return;\n }\n\n this.timer = setTimeout(() => {\n this.timer = null;\n this.attempts++;\n this.callbacks.onRetry();\n }, this.backoff.delay());\n }\n\n cancel(): void {\n if (this.timer) {\n clearTimeout(this.timer);\n this.timer = null;\n }\n }\n\n reset(): void {\n this.attempts = 0;\n this.backoff.reset();\n }\n\n private hasExceededLimit(): boolean {\n return this.attempts >= this.config.maxAttempts;\n }\n\n private notifyMaxAttemptsReached(): void {\n const error = new Error(\n `Max retry attempts (${this.config.maxAttempts}) reached`,\n );\n this.callbacks.onMaxAttemptsReached(error);\n }\n}\n\ninterface ExponentialBackoffConfig {\n initialDelay: number;\n maxDelay: number;\n factor: number;\n jitter: number;\n}\n\nclass ExponentialBackoff {\n private readonly initialDelay: number;\n private readonly maxDelay: number;\n\n private currentDelay: number;\n\n private readonly factor: number;\n private readonly jitter: number;\n\n constructor(config: ExponentialBackoffConfig) {\n this.initialDelay = config.initialDelay;\n this.maxDelay = config.maxDelay;\n this.factor = config.factor;\n this.jitter = config.jitter;\n this.currentDelay = config.initialDelay;\n }\n\n delay(): number {\n if (this.currentDelay >= this.maxDelay) {\n return this.applyJitter(this.maxDelay);\n }\n\n this.currentDelay = this.currentDelay * this.factor;\n\n if (this.currentDelay > this.maxDelay) {\n this.currentDelay = this.maxDelay;\n }\n\n return this.applyJitter(this.currentDelay);\n }\n\n reset(): void {\n this.currentDelay = this.initialDelay;\n }\n\n private applyJitter(delay: number): number {\n if (delay === 0 || this.jitter === 0) {\n return delay;\n }\n const delayFactor = 1 - (this.jitter / 2) + Math.random() * this.jitter;\n return delay * delayFactor;\n }\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-client",
3
- "version": "2.16.10",
3
+ "version": "2.16.12",
4
4
  "engines": {
5
5
  "node": ">=22.19.0"
6
6
  },
@@ -36,8 +36,8 @@
36
36
  "utility-types": "^3.11.0",
37
37
  "yaml": "^2.8.0",
38
38
  "@milaboratories/pl-http": "1.2.0",
39
- "@milaboratories/ts-helpers": "1.5.4",
40
- "@milaboratories/pl-model-common": "1.21.9"
39
+ "@milaboratories/pl-model-common": "1.21.9",
40
+ "@milaboratories/ts-helpers": "1.5.4"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@protobuf-ts/plugin": "2.11.1",
@@ -47,9 +47,9 @@
47
47
  "@vitest/coverage-v8": "^4.0.7",
48
48
  "vitest": "^4.0.7",
49
49
  "typescript": "~5.6.3",
50
- "@milaboratories/build-configs": "1.0.8",
50
+ "@milaboratories/ts-builder": "1.0.5",
51
51
  "@milaboratories/ts-configs": "1.0.6",
52
- "@milaboratories/ts-builder": "1.0.5"
52
+ "@milaboratories/build-configs": "1.0.8"
53
53
  },
54
54
  "scripts": {
55
55
  "type-check": "ts-builder types --target node",
@@ -24,6 +24,7 @@ export function isUnauthenticated(err: unknown, nested: boolean = false): boolea
24
24
 
25
25
  export function isTimeoutOrCancelError(err: unknown, nested: boolean = false): boolean {
26
26
  if (err instanceof Aborted || (err as any).name == 'AbortError') return true;
27
+ if ((err as any).name == 'TimeoutError') return true;
27
28
  if ((err as any).code == 'ABORT_ERR') return true;
28
29
  if ((err as any).code == Code.ABORTED) return true;
29
30
  if (
@@ -28,6 +28,7 @@ import type * as grpcTypes from '../proto-grpc/github.com/milaboratory/pl/plapi/
28
28
  import { type PlApiPaths, type PlRestClientType, createClient, parseResponseError } from '../proto-rest';
29
29
  import { notEmpty } from '@milaboratories/ts-helpers';
30
30
  import { Code } from '../proto-grpc/google/rpc/code';
31
+ import { WebSocketBiDiStream } from './websocket_stream';
31
32
 
32
33
  export interface PlCallOps {
33
34
  timeout?: number;
@@ -172,6 +173,7 @@ export class LLPlClient implements WireClientProviderFactory {
172
173
  private initGrpcConnection(gzip: boolean) {
173
174
  const clientOptions: ClientOptions = {
174
175
  'grpc.keepalive_time_ms': 30_000, // 30 seconds
176
+ 'grpc.service_config_disable_resolution': 1, // Disable DNS TXT lookups for service config
175
177
  'interceptors': this._grpcInterceptors,
176
178
  };
177
179
 
@@ -478,17 +480,32 @@ export class LLPlClient implements WireClientProviderFactory {
478
480
  let totalAbortSignal = abortSignal;
479
481
  if (ops.abortSignal) totalAbortSignal = AbortSignal.any([totalAbortSignal, ops.abortSignal]);
480
482
 
483
+ const timeout = ops.timeout ?? (rw ? this.conf.defaultRWTransactionTimeout : this.conf.defaultROTransactionTimeout);
484
+
481
485
  const cl = this.clientProvider.get();
482
- if (!(cl instanceof GrpcPlApiClient)) {
483
- // TODO: add WebSockets
484
- throw new Error('tx is not supported for REST client');
486
+ if (cl instanceof GrpcPlApiClient) {
487
+ return cl.tx({
488
+ abort: totalAbortSignal,
489
+ timeout,
490
+ });
485
491
  }
486
492
 
487
- return cl.tx({
488
- abort: totalAbortSignal,
489
- timeout: ops.timeout
490
- ?? (rw ? this.conf.defaultRWTransactionTimeout : this.conf.defaultROTransactionTimeout),
491
- });
493
+ if (this._wireProto === 'rest') {
494
+ // For REST/WebSocket protocol, timeout needs to be converted to AbortSignal
495
+ if (timeout !== undefined) {
496
+ totalAbortSignal = AbortSignal.any([totalAbortSignal, AbortSignal.timeout(timeout)]);
497
+ }
498
+ const wsUrl = this.conf.ssl
499
+ ? `wss://${this.conf.hostAndPort}/v1/ws/tx`
500
+ : `ws://${this.conf.hostAndPort}/v1/ws/tx`;
501
+
502
+ // The gRPC transport has the auth interceptor that already handles it, so we need to refresh the auth information here.
503
+ this.refreshAuthInformationIfNeeded();
504
+ const jwtToken = this.authInformation?.jwtToken;
505
+
506
+ return new WebSocketBiDiStream(wsUrl, totalAbortSignal, jwtToken);
507
+ }
508
+ throw new Error('tx is not supported for this wire protocol');
492
509
  });
493
510
  }
494
511
 
@@ -6,6 +6,24 @@ import { test, expect } from 'vitest';
6
6
  import { isTimeoutOrCancelError } from './errors';
7
7
  import { Aborted } from '@milaboratories/ts-helpers';
8
8
 
9
+ test('check successful transaction', async () => {
10
+ const client = await getTestLLClient();
11
+ const tx = client.createTx(true);
12
+
13
+ const openResp = await tx.send({
14
+ oneofKind: 'txOpen',
15
+ txOpen: { name: 'test', writable: TxAPI_Open_Request_WritableTx.WRITABLE, enableFormattedErrors: false }
16
+ }, false);
17
+ const commitResp = await tx.send({
18
+ oneofKind: 'txCommit',
19
+ txCommit: {}
20
+ }, false);
21
+
22
+ expect(openResp.txOpen.tx?.isValid).toBeTruthy();
23
+ expect(commitResp.txCommit.success).toBeTruthy();
24
+ await tx.await();
25
+ });
26
+
9
27
  test('transaction timeout test', async () => {
10
28
  const client = await getTestLLClient();
11
29
  const tx = client.createTx(true, { timeout: 500 });
@@ -236,6 +236,13 @@ export class LLPlTransaction {
236
236
  (currentHandler as AnySingleResponseHandler).resolve(message.response);
237
237
  currentHandler = undefined;
238
238
  }
239
+
240
+ // After receiving a terminal response (txCommit or txDiscard), we proactively close the client stream.
241
+ // This ensures consistent behavior between the gRPC and WebSocket transports,
242
+ // since the server closes the connection automatically upon transaction completion in both cases.
243
+ if (this.isTerminalResponse(message) && this.responseHandlerQueue.length === 0) {
244
+ await this.stream.requests.complete();
245
+ }
239
246
  }
240
247
  } catch (e: any) {
241
248
  return this.assignErrorFactoryIfNotSet(() => {
@@ -332,4 +339,9 @@ export class LLPlTransaction {
332
339
  this._completed = true;
333
340
  await this.stream.requests.complete();
334
341
  }
342
+
343
+ private isTerminalResponse(message: TxAPI_ServerMessage): boolean {
344
+ const kind = message.response.oneofKind;
345
+ return kind === 'txCommit' || kind === 'txDiscard';
346
+ }
335
347
  }
@@ -0,0 +1,412 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ TxAPI_ClientMessage as ClientMessageType,
4
+ TxAPI_ServerMessage as ServerMessageType,
5
+ } from '../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api';
6
+
7
+ // Mock WebSocket - must be hoisted for vi.mock
8
+ const MockWebSocket = vi.hoisted(() => {
9
+ class MockWS {
10
+ static CONNECTING = 0;
11
+ static OPEN = 1;
12
+ static CLOSING = 2;
13
+ static CLOSED = 3;
14
+
15
+ readyState = 0;
16
+ binaryType = 'blob';
17
+
18
+ private listeners: Map<string, Set<Function>> = new Map();
19
+
20
+ constructor(
21
+ public url: string,
22
+ public options?: { headers?: Record<string, string> },
23
+ ) {
24
+ MockWS.instances.push(this);
25
+ }
26
+
27
+ static instances: MockWS[] = [];
28
+
29
+ static reset() {
30
+ MockWS.instances = [];
31
+ }
32
+
33
+ addEventListener(event: string, callback: Function) {
34
+ if (!this.listeners.has(event)) {
35
+ this.listeners.set(event, new Set());
36
+ }
37
+ this.listeners.get(event)!.add(callback);
38
+ }
39
+
40
+ removeEventListener(event: string, callback: Function) {
41
+ this.listeners.get(event)?.delete(callback);
42
+ }
43
+
44
+ emit(event: string, data?: unknown) {
45
+ this.listeners.get(event)?.forEach((cb) => cb(data));
46
+ }
47
+
48
+ send = vi.fn();
49
+ close = vi.fn(() => {
50
+ this.readyState = MockWS.CLOSED;
51
+ this.emit('close');
52
+ });
53
+
54
+ simulateOpen() {
55
+ this.readyState = MockWS.OPEN;
56
+ this.emit('open');
57
+ }
58
+
59
+ simulateMessage(data: ArrayBuffer) {
60
+ this.emit('message', { data });
61
+ }
62
+
63
+ simulateError(error: Error) {
64
+ this.emit('error', error);
65
+ }
66
+
67
+ simulateClose() {
68
+ this.readyState = MockWS.CLOSED;
69
+ this.emit('close');
70
+ }
71
+ }
72
+ return MockWS;
73
+ });
74
+
75
+ vi.mock('undici', () => ({
76
+ WebSocket: MockWebSocket,
77
+ }));
78
+
79
+ import { WebSocketBiDiStream } from './websocket_stream';
80
+ import type { RetryConfig } from '../helpers/retry_strategy';
81
+
82
+ type MockWS = InstanceType<typeof MockWebSocket>;
83
+
84
+ interface StreamContext {
85
+ stream: WebSocketBiDiStream;
86
+ ws: MockWS;
87
+ controller: AbortController;
88
+ }
89
+
90
+ function createStream(token?: string, retryConfig?: Partial<RetryConfig>): StreamContext {
91
+ const controller = new AbortController();
92
+ const stream = new WebSocketBiDiStream(
93
+ 'ws://localhost:8080',
94
+ controller.signal,
95
+ token,
96
+ retryConfig,
97
+ );
98
+ const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
99
+ return { stream, ws, controller };
100
+ }
101
+
102
+ async function openConnection(ws: MockWS): Promise<void> {
103
+ ws.simulateOpen();
104
+ await vi.runAllTimersAsync();
105
+ }
106
+
107
+ function createServerMessageBuffer(): ArrayBuffer {
108
+ const message = ServerMessageType.create({});
109
+ const binary = ServerMessageType.toBinary(message);
110
+ return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength) as ArrayBuffer;
111
+ }
112
+
113
+ function createClientMessage(): ClientMessageType {
114
+ return ClientMessageType.create({});
115
+ }
116
+
117
+ async function collectMessages(
118
+ stream: WebSocketBiDiStream,
119
+ count: number,
120
+ ): Promise<ServerMessageType[]> {
121
+ const messages: ServerMessageType[] = [];
122
+ for await (const msg of stream.responses) {
123
+ messages.push(msg);
124
+ if (messages.length >= count) break;
125
+ }
126
+ return messages;
127
+ }
128
+
129
+ describe('WebSocketBiDiStream', () => {
130
+ beforeEach(() => {
131
+ vi.useFakeTimers();
132
+ MockWebSocket.reset();
133
+ });
134
+
135
+ afterEach(() => {
136
+ vi.useRealTimers();
137
+ });
138
+
139
+ describe('constructor', () => {
140
+ test('should pass JWT token in authorization header', () => {
141
+ createStream('test-token');
142
+
143
+ expect(MockWebSocket.instances[0].options?.headers?.authorization).toBe(
144
+ 'Bearer test-token',
145
+ );
146
+ });
147
+
148
+ test('should not create WebSocket if already aborted', () => {
149
+ const controller = new AbortController();
150
+ controller.abort();
151
+
152
+ new WebSocketBiDiStream('ws://localhost:8080', controller.signal);
153
+
154
+ expect(MockWebSocket.instances).toHaveLength(0);
155
+ });
156
+ });
157
+
158
+ describe('send messages', () => {
159
+ test('should queue message and send when connected', async () => {
160
+ const { stream, ws } = createStream();
161
+
162
+ const sendPromise = stream.requests.send(createClientMessage());
163
+ expect(ws.send).not.toHaveBeenCalled();
164
+
165
+ await openConnection(ws);
166
+ await sendPromise;
167
+
168
+ expect(ws.send).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ test('should throw error when sending after complete', async () => {
172
+ const { stream, ws } = createStream();
173
+
174
+ await openConnection(ws);
175
+ await stream.requests.complete();
176
+
177
+ await expect(stream.requests.send(createClientMessage())).rejects.toThrow(
178
+ 'Cannot send: stream already completed',
179
+ );
180
+ });
181
+
182
+ test('should throw error when sending after abort', async () => {
183
+ const { stream, ws, controller } = createStream();
184
+
185
+ await openConnection(ws);
186
+ controller.abort();
187
+
188
+ await expect(stream.requests.send(createClientMessage())).rejects.toThrow(
189
+ 'Cannot send: stream aborted',
190
+ );
191
+ });
192
+ });
193
+
194
+ describe('receive messages', () => {
195
+ test('should receive messages via async iterator', async () => {
196
+ const { stream, ws } = createStream();
197
+
198
+ await openConnection(ws);
199
+
200
+ const buffer = createServerMessageBuffer();
201
+ const responsePromise = collectMessages(stream, 2);
202
+
203
+ ws.simulateMessage(buffer);
204
+ ws.simulateMessage(buffer);
205
+
206
+ const messages = await responsePromise;
207
+ expect(messages).toHaveLength(2);
208
+ });
209
+
210
+ test('should buffer messages when no consumer', async () => {
211
+ const { stream, ws } = createStream();
212
+
213
+ await openConnection(ws);
214
+
215
+ const buffer = createServerMessageBuffer();
216
+ ws.simulateMessage(buffer);
217
+ ws.simulateMessage(buffer);
218
+
219
+ const messages = await collectMessages(stream, 2);
220
+ expect(messages).toHaveLength(2);
221
+ });
222
+
223
+ test('should end iterator when stream completes', async () => {
224
+ const { stream, ws } = createStream();
225
+
226
+ await openConnection(ws);
227
+
228
+ const iteratorPromise = (async () => {
229
+ const messages: ServerMessageType[] = [];
230
+ for await (const msg of stream.responses) {
231
+ messages.push(msg);
232
+ }
233
+ return messages;
234
+ })();
235
+
236
+ await stream.requests.complete();
237
+
238
+ const messages = await iteratorPromise;
239
+ expect(messages).toHaveLength(0);
240
+ });
241
+ });
242
+
243
+ describe('complete', () => {
244
+ test('should close WebSocket after complete', async () => {
245
+ const { stream, ws } = createStream();
246
+
247
+ await openConnection(ws);
248
+ await stream.requests.complete();
249
+
250
+ expect(ws.close).toHaveBeenCalled();
251
+ });
252
+
253
+ test('should be idempotent', async () => {
254
+ const { stream, ws } = createStream();
255
+
256
+ await openConnection(ws);
257
+ await stream.requests.complete();
258
+ await stream.requests.complete();
259
+ await stream.requests.complete();
260
+
261
+ expect(ws.close).toHaveBeenCalledTimes(1);
262
+ });
263
+
264
+ test('should drain send queue before closing', async () => {
265
+ const { stream, ws } = createStream();
266
+
267
+ const sendPromise1 = stream.requests.send(createClientMessage());
268
+ const sendPromise2 = stream.requests.send(createClientMessage());
269
+ const completePromise = stream.requests.complete();
270
+
271
+ await openConnection(ws);
272
+
273
+ await sendPromise1;
274
+ await sendPromise2;
275
+ await completePromise;
276
+
277
+ expect(ws.send).toHaveBeenCalledTimes(2);
278
+ });
279
+ });
280
+
281
+ describe('abort signal', () => {
282
+ test('should close stream when aborted', async () => {
283
+ const { ws, controller } = createStream();
284
+
285
+ await openConnection(ws);
286
+ controller.abort();
287
+
288
+ expect(ws.close).toHaveBeenCalled();
289
+ });
290
+
291
+ test('should reject pending sends when aborted', async () => {
292
+ const { stream, controller } = createStream();
293
+
294
+ const sendPromise = stream.requests.send(createClientMessage());
295
+ sendPromise.catch(() => {});
296
+
297
+ controller.abort();
298
+ await vi.runAllTimersAsync();
299
+
300
+ await expect(sendPromise).rejects.toThrow();
301
+ });
302
+
303
+ test('should end response iterator when aborted', async () => {
304
+ const { stream, ws, controller } = createStream();
305
+
306
+ await openConnection(ws);
307
+
308
+ const iteratorPromise = (async () => {
309
+ const messages: ServerMessageType[] = [];
310
+ try {
311
+ for await (const msg of stream.responses) {
312
+ messages.push(msg);
313
+ }
314
+ } catch {
315
+ // Expected to throw on abort
316
+ }
317
+ return messages;
318
+ })();
319
+
320
+ controller.abort();
321
+ await vi.runAllTimersAsync();
322
+
323
+ const messages = await iteratorPromise;
324
+ expect(messages).toHaveLength(0);
325
+ });
326
+ });
327
+
328
+ describe('reconnection', () => {
329
+ const retryConfig: Partial<RetryConfig> = {
330
+ initialDelay: 50,
331
+ maxDelay: 100,
332
+ maxAttempts: 5,
333
+ };
334
+
335
+ test('should attempt reconnection on unexpected close', async () => {
336
+ const { ws } = createStream(undefined, retryConfig);
337
+
338
+ await openConnection(ws);
339
+
340
+ ws.readyState = MockWebSocket.CLOSED;
341
+ ws.emit('close');
342
+ await vi.advanceTimersByTimeAsync(150);
343
+
344
+ expect(MockWebSocket.instances.length).toBeGreaterThan(1);
345
+ });
346
+
347
+ test('should stop reconnecting after max attempts', async () => {
348
+ createStream(undefined, { maxAttempts: 3, initialDelay: 10, maxDelay: 100 });
349
+
350
+ for (let i = 0; i < 5; i++) {
351
+ const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
352
+ ws.simulateError(new Error('Connection failed'));
353
+ await vi.advanceTimersByTimeAsync(200);
354
+ }
355
+
356
+ expect(MockWebSocket.instances.length).toBeLessThanOrEqual(4);
357
+ });
358
+
359
+ test('should not reconnect after complete', async () => {
360
+ const { stream, ws } = createStream();
361
+
362
+ await openConnection(ws);
363
+ await stream.requests.complete();
364
+ await vi.advanceTimersByTimeAsync(1000);
365
+
366
+ expect(MockWebSocket.instances).toHaveLength(1);
367
+ });
368
+ });
369
+
370
+ describe('error handling', () => {
371
+ test('should reject response iterator on parse error', async () => {
372
+ const { stream, ws } = createStream();
373
+
374
+ await openConnection(ws);
375
+
376
+ const iterator = stream.responses[Symbol.asyncIterator]();
377
+ const nextPromise = iterator.next();
378
+
379
+ await Promise.resolve();
380
+
381
+ const invalidData = new Uint8Array([
382
+ 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01,
383
+ ]);
384
+ ws.simulateMessage(invalidData.buffer);
385
+
386
+ await expect(nextPromise).rejects.toThrow();
387
+ });
388
+
389
+ test('should throw on unsupported message format', async () => {
390
+ const { stream, ws } = createStream();
391
+
392
+ await openConnection(ws);
393
+
394
+ const iteratorPromise = (async () => {
395
+ try {
396
+ for await (const _ of stream.responses) {
397
+ // Should not reach here
398
+ }
399
+ return 'completed';
400
+ } catch (e) {
401
+ return e;
402
+ }
403
+ })();
404
+
405
+ ws.emit('message', { data: 'not a buffer' });
406
+
407
+ const result = await iteratorPromise;
408
+ expect(result).toBeInstanceOf(Error);
409
+ });
410
+ });
411
+
412
+ });