@milaboratories/pl-client 2.16.11 → 2.16.13

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 (54) hide show
  1. package/dist/core/driver.cjs +1 -1
  2. package/dist/core/driver.cjs.map +1 -1
  3. package/dist/core/driver.js +1 -1
  4. package/dist/core/driver.js.map +1 -1
  5. package/dist/core/errors.cjs +2 -0
  6. package/dist/core/errors.cjs.map +1 -1
  7. package/dist/core/errors.d.ts.map +1 -1
  8. package/dist/core/errors.js +2 -0
  9. package/dist/core/errors.js.map +1 -1
  10. package/dist/core/ll_client.cjs +32 -9
  11. package/dist/core/ll_client.cjs.map +1 -1
  12. package/dist/core/ll_client.d.ts.map +1 -1
  13. package/dist/core/ll_client.js +32 -9
  14. package/dist/core/ll_client.js.map +1 -1
  15. package/dist/core/ll_transaction.cjs +10 -0
  16. package/dist/core/ll_transaction.cjs.map +1 -1
  17. package/dist/core/ll_transaction.d.ts +1 -0
  18. package/dist/core/ll_transaction.d.ts.map +1 -1
  19. package/dist/core/ll_transaction.js +10 -0
  20. package/dist/core/ll_transaction.js.map +1 -1
  21. package/dist/core/websocket_stream.cjs +330 -0
  22. package/dist/core/websocket_stream.cjs.map +1 -0
  23. package/dist/core/websocket_stream.d.ts +67 -0
  24. package/dist/core/websocket_stream.d.ts.map +1 -0
  25. package/dist/core/websocket_stream.js +328 -0
  26. package/dist/core/websocket_stream.js.map +1 -0
  27. package/dist/helpers/retry_strategy.cjs +92 -0
  28. package/dist/helpers/retry_strategy.cjs.map +1 -0
  29. package/dist/helpers/retry_strategy.d.ts +24 -0
  30. package/dist/helpers/retry_strategy.d.ts.map +1 -0
  31. package/dist/helpers/retry_strategy.js +89 -0
  32. package/dist/helpers/retry_strategy.js.map +1 -0
  33. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs +136 -0
  34. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs.map +1 -1
  35. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts +75 -1
  36. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts.map +1 -1
  37. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js +135 -1
  38. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js.map +1 -1
  39. package/dist/proto-rest/index.cjs +16 -2
  40. package/dist/proto-rest/index.cjs.map +1 -1
  41. package/dist/proto-rest/index.d.ts.map +1 -1
  42. package/dist/proto-rest/index.js +16 -2
  43. package/dist/proto-rest/index.js.map +1 -1
  44. package/package.json +6 -6
  45. package/src/core/driver.ts +1 -1
  46. package/src/core/errors.ts +1 -0
  47. package/src/core/ll_client.ts +42 -9
  48. package/src/core/ll_transaction.test.ts +18 -0
  49. package/src/core/ll_transaction.ts +12 -0
  50. package/src/core/websocket_stream.test.ts +423 -0
  51. package/src/core/websocket_stream.ts +400 -0
  52. package/src/helpers/retry_strategy.ts +123 -0
  53. package/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts +179 -1
  54. package/src/proto-rest/index.ts +17 -2
@@ -0,0 +1,423 @@
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<ClientMessageType, ServerMessageType>;
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
+ (message: ClientMessageType) => ClientMessageType.toBinary(message),
95
+ (data) => ServerMessageType.fromBinary(data),
96
+ {
97
+ abortSignal: controller.signal,
98
+ jwtToken: token,
99
+ retryConfig: retryConfig,
100
+ },
101
+ );
102
+ const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
103
+ return { stream, ws, controller };
104
+ }
105
+
106
+ async function openConnection(ws: MockWS): Promise<void> {
107
+ ws.simulateOpen();
108
+ await vi.runAllTimersAsync();
109
+ }
110
+
111
+ function createServerMessageBuffer(): ArrayBuffer {
112
+ const message = ServerMessageType.create({});
113
+ const binary = ServerMessageType.toBinary(message);
114
+ return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength) as ArrayBuffer;
115
+ }
116
+
117
+ function createClientMessage(): ClientMessageType {
118
+ return ClientMessageType.create({});
119
+ }
120
+
121
+ async function collectMessages(
122
+ stream: WebSocketBiDiStream<ClientMessageType, ServerMessageType>,
123
+ count: number,
124
+ ): Promise<ServerMessageType[]> {
125
+ const messages: ServerMessageType[] = [];
126
+ for await (const msg of stream.responses) {
127
+ messages.push(msg);
128
+ if (messages.length >= count) break;
129
+ }
130
+ return messages;
131
+ }
132
+
133
+ describe('WebSocketBiDiStream', () => {
134
+ beforeEach(() => {
135
+ vi.useFakeTimers();
136
+ MockWebSocket.reset();
137
+ });
138
+
139
+ afterEach(() => {
140
+ vi.useRealTimers();
141
+ });
142
+
143
+ describe('constructor', () => {
144
+ test('should pass JWT token in authorization header', () => {
145
+ createStream('test-token');
146
+
147
+ expect(MockWebSocket.instances[0].options?.headers?.authorization).toBe(
148
+ 'Bearer test-token',
149
+ );
150
+ });
151
+
152
+ test('should not create WebSocket if already aborted', () => {
153
+ const controller = new AbortController();
154
+ controller.abort();
155
+
156
+ new WebSocketBiDiStream(
157
+ 'ws://localhost:8080',
158
+ (message: ClientMessageType) => ClientMessageType.toBinary(message),
159
+ (data) => ServerMessageType.fromBinary(data),
160
+ {
161
+ abortSignal: controller.signal,
162
+ },
163
+ );
164
+
165
+ expect(MockWebSocket.instances).toHaveLength(0);
166
+ });
167
+ });
168
+
169
+ describe('send messages', () => {
170
+ test('should queue message and send when connected', async () => {
171
+ const { stream, ws } = createStream();
172
+
173
+ const sendPromise = stream.requests.send(createClientMessage());
174
+ expect(ws.send).not.toHaveBeenCalled();
175
+
176
+ await openConnection(ws);
177
+ await sendPromise;
178
+
179
+ expect(ws.send).toHaveBeenCalledTimes(1);
180
+ });
181
+
182
+ test('should throw error when sending after complete', async () => {
183
+ const { stream, ws } = createStream();
184
+
185
+ await openConnection(ws);
186
+ await stream.requests.complete();
187
+
188
+ await expect(stream.requests.send(createClientMessage())).rejects.toThrow(
189
+ 'Cannot send: stream already completed',
190
+ );
191
+ });
192
+
193
+ test('should throw error when sending after abort', async () => {
194
+ const { stream, ws, controller } = createStream();
195
+
196
+ await openConnection(ws);
197
+ controller.abort();
198
+
199
+ await expect(stream.requests.send(createClientMessage())).rejects.toThrow(
200
+ 'Cannot send: stream aborted',
201
+ );
202
+ });
203
+ });
204
+
205
+ describe('receive messages', () => {
206
+ test('should receive messages via async iterator', async () => {
207
+ const { stream, ws } = createStream();
208
+
209
+ await openConnection(ws);
210
+
211
+ const buffer = createServerMessageBuffer();
212
+ const responsePromise = collectMessages(stream, 2);
213
+
214
+ ws.simulateMessage(buffer);
215
+ ws.simulateMessage(buffer);
216
+
217
+ const messages = await responsePromise;
218
+ expect(messages).toHaveLength(2);
219
+ });
220
+
221
+ test('should buffer messages when no consumer', async () => {
222
+ const { stream, ws } = createStream();
223
+
224
+ await openConnection(ws);
225
+
226
+ const buffer = createServerMessageBuffer();
227
+ ws.simulateMessage(buffer);
228
+ ws.simulateMessage(buffer);
229
+
230
+ const messages = await collectMessages(stream, 2);
231
+ expect(messages).toHaveLength(2);
232
+ });
233
+
234
+ test('should end iterator when stream completes', async () => {
235
+ const { stream, ws } = createStream();
236
+
237
+ await openConnection(ws);
238
+
239
+ const iteratorPromise = (async () => {
240
+ const messages: ServerMessageType[] = [];
241
+ for await (const msg of stream.responses) {
242
+ messages.push(msg);
243
+ }
244
+ return messages;
245
+ })();
246
+
247
+ await stream.requests.complete();
248
+
249
+ const messages = await iteratorPromise;
250
+ expect(messages).toHaveLength(0);
251
+ });
252
+ });
253
+
254
+ describe('complete', () => {
255
+ test('should close WebSocket after complete', async () => {
256
+ const { stream, ws } = createStream();
257
+
258
+ await openConnection(ws);
259
+ await stream.requests.complete();
260
+
261
+ expect(ws.close).toHaveBeenCalled();
262
+ });
263
+
264
+ test('should be idempotent', async () => {
265
+ const { stream, ws } = createStream();
266
+
267
+ await openConnection(ws);
268
+ await stream.requests.complete();
269
+ await stream.requests.complete();
270
+ await stream.requests.complete();
271
+
272
+ expect(ws.close).toHaveBeenCalledTimes(1);
273
+ });
274
+
275
+ test('should drain send queue before closing', async () => {
276
+ const { stream, ws } = createStream();
277
+
278
+ const sendPromise1 = stream.requests.send(createClientMessage());
279
+ const sendPromise2 = stream.requests.send(createClientMessage());
280
+ const completePromise = stream.requests.complete();
281
+
282
+ await openConnection(ws);
283
+
284
+ await sendPromise1;
285
+ await sendPromise2;
286
+ await completePromise;
287
+
288
+ expect(ws.send).toHaveBeenCalledTimes(2);
289
+ });
290
+ });
291
+
292
+ describe('abort signal', () => {
293
+ test('should close stream when aborted', async () => {
294
+ const { ws, controller } = createStream();
295
+
296
+ await openConnection(ws);
297
+ controller.abort();
298
+
299
+ expect(ws.close).toHaveBeenCalled();
300
+ });
301
+
302
+ test('should reject pending sends when aborted', async () => {
303
+ const { stream, controller } = createStream();
304
+
305
+ const sendPromise = stream.requests.send(createClientMessage());
306
+ sendPromise.catch(() => {});
307
+
308
+ controller.abort();
309
+ await vi.runAllTimersAsync();
310
+
311
+ await expect(sendPromise).rejects.toThrow();
312
+ });
313
+
314
+ test('should end response iterator when aborted', async () => {
315
+ const { stream, ws, controller } = createStream();
316
+
317
+ await openConnection(ws);
318
+
319
+ const iteratorPromise = (async () => {
320
+ const messages: ServerMessageType[] = [];
321
+ try {
322
+ for await (const msg of stream.responses) {
323
+ messages.push(msg);
324
+ }
325
+ } catch {
326
+ // Expected to throw on abort
327
+ }
328
+ return messages;
329
+ })();
330
+
331
+ controller.abort();
332
+ await vi.runAllTimersAsync();
333
+
334
+ const messages = await iteratorPromise;
335
+ expect(messages).toHaveLength(0);
336
+ });
337
+ });
338
+
339
+ describe('reconnection', () => {
340
+ const retryConfig: Partial<RetryConfig> = {
341
+ initialDelay: 50,
342
+ maxDelay: 100,
343
+ maxAttempts: 5,
344
+ };
345
+
346
+ test('should not attempt reconnection on unexpected close', async () => {
347
+ const { ws } = createStream(undefined, retryConfig);
348
+
349
+ await openConnection(ws);
350
+
351
+ ws.readyState = MockWebSocket.CLOSED;
352
+ ws.emit('close');
353
+ await vi.advanceTimersByTimeAsync(150);
354
+
355
+ expect(MockWebSocket.instances.length).toBe(1);
356
+ });
357
+
358
+ test('should stop reconnecting after max attempts', async () => {
359
+ createStream(undefined, { maxAttempts: 3, initialDelay: 10, maxDelay: 100 });
360
+
361
+ for (let i = 0; i < 5; i++) {
362
+ const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
363
+ ws.simulateError(new Error('Connection failed'));
364
+ await vi.advanceTimersByTimeAsync(200);
365
+ }
366
+
367
+ expect(MockWebSocket.instances.length).toBeLessThanOrEqual(4);
368
+ });
369
+
370
+ test('should not reconnect after complete', async () => {
371
+ const { stream, ws } = createStream();
372
+
373
+ await openConnection(ws);
374
+ await stream.requests.complete();
375
+ await vi.advanceTimersByTimeAsync(1000);
376
+
377
+ expect(MockWebSocket.instances).toHaveLength(1);
378
+ });
379
+ });
380
+
381
+ describe('error handling', () => {
382
+ test('should reject response iterator on parse error', async () => {
383
+ const { stream, ws } = createStream();
384
+
385
+ await openConnection(ws);
386
+
387
+ const iterator = stream.responses[Symbol.asyncIterator]();
388
+ const nextPromise = iterator.next();
389
+
390
+ await Promise.resolve();
391
+
392
+ const invalidData = new Uint8Array([
393
+ 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01,
394
+ ]);
395
+ ws.simulateMessage(invalidData.buffer);
396
+
397
+ await expect(nextPromise).rejects.toThrow();
398
+ });
399
+
400
+ test('should throw on unsupported message format', async () => {
401
+ const { stream, ws } = createStream();
402
+
403
+ await openConnection(ws);
404
+
405
+ const iteratorPromise = (async () => {
406
+ try {
407
+ for await (const _ of stream.responses) {
408
+ // Should not reach here
409
+ }
410
+ return 'completed';
411
+ } catch (e) {
412
+ return e;
413
+ }
414
+ })();
415
+
416
+ ws.emit('message', { data: 'not a buffer' });
417
+
418
+ const result = await iteratorPromise;
419
+ expect(result).toBeInstanceOf(Error);
420
+ });
421
+ });
422
+
423
+ });