@milaboratories/pl-client 2.16.11 → 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 +21 -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 +21 -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 +3 -3
  30. package/src/core/errors.ts +1 -0
  31. package/src/core/ll_client.ts +24 -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,412 @@
1
+ import { WebSocket } from 'undici';
2
+ import {
3
+ TxAPI_ClientMessage as ClientMessageType,
4
+ TxAPI_ServerMessage as ServerMessageType,
5
+ } from '../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api';
6
+ import type { BiDiStream } from './abstract_stream';
7
+ import Denque from 'denque';
8
+ import type { RetryConfig } from '../helpers/retry_strategy';
9
+ import { RetryStrategy } from '../helpers/retry_strategy';
10
+
11
+ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'closing' | 'closed';
12
+
13
+ interface QueuedMessage {
14
+ message: ClientMessageType;
15
+ resolve: () => void;
16
+ reject: (error: Error) => void;
17
+ }
18
+
19
+ interface ResponseResolver {
20
+ resolve: (value: IteratorResult<ServerMessageType>) => void;
21
+ reject: (error: Error) => void;
22
+ }
23
+
24
+ /**
25
+ * WebSocket-based bidirectional stream implementation for LLTransaction.
26
+ * Implements BiDiStream interface which is compatible with DuplexStreamingCall.
27
+ */
28
+ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, ServerMessageType> {
29
+ // Connection
30
+ private ws: WebSocket | null = null;
31
+ private connectionState: ConnectionState = 'disconnected';
32
+ private readonly url: string;
33
+ private readonly jwtToken?: string;
34
+ private readonly abortSignal: AbortSignal;
35
+ private readonly reconnection: RetryStrategy;
36
+
37
+ // Send management
38
+ private readonly sendQueue = new Denque<QueuedMessage>();
39
+ private sendCompleted = false;
40
+
41
+ // Response management
42
+ private readonly responseQueue = new Denque<ServerMessageType>();
43
+ private responseResolvers: ResponseResolver[] = [];
44
+
45
+ // Error tracking
46
+ private connectionError: Error | null = null;
47
+
48
+ // === Public API ===
49
+
50
+ public readonly requests = {
51
+ send: async (message: ClientMessageType): Promise<void> => {
52
+ this.validateSendState();
53
+ return this.enqueueSend(message);
54
+ },
55
+
56
+ complete: async (): Promise<void> => {
57
+ if (this.sendCompleted) return;
58
+
59
+ this.sendCompleted = true;
60
+ await this.drainSendQueue();
61
+ this.closeConnection();
62
+ },
63
+ };
64
+
65
+ public readonly responses: AsyncIterable<ServerMessageType> = {
66
+ [Symbol.asyncIterator]: () => this.createResponseIterator(),
67
+ };
68
+
69
+ constructor(
70
+ url: string,
71
+ abortSignal: AbortSignal,
72
+ jwtToken?: string,
73
+ retryConfig: Partial<RetryConfig> = {},
74
+ ) {
75
+ this.url = url;
76
+ this.jwtToken = jwtToken;
77
+ this.abortSignal = abortSignal;
78
+
79
+ this.reconnection = new RetryStrategy(retryConfig, {
80
+ onRetry: () => { void this.connect(); },
81
+ onMaxAttemptsReached: (error) => this.handleError(error),
82
+ });
83
+
84
+ if (abortSignal.aborted) {
85
+ this.connectionState = 'closed';
86
+ return;
87
+ }
88
+
89
+ this.attachAbortSignalHandler();
90
+ void this.connect();
91
+ }
92
+
93
+ // === Connection Lifecycle ===
94
+
95
+ private connect(): void {
96
+ if (this.isConnectingOrConnected() || this.abortSignal.aborted) return;
97
+
98
+ this.connectionState = 'connecting';
99
+ this.connectionError = null;
100
+
101
+ try {
102
+ this.ws = this.createWebSocket();
103
+ this.attachWebSocketHandlers();
104
+ } catch (error) {
105
+ this.connectionError = this.toError(error);
106
+ this.connectionState = 'disconnected';
107
+ this.reconnection.schedule();
108
+ }
109
+ }
110
+
111
+ private createWebSocket(): WebSocket {
112
+ const options = this.jwtToken
113
+ ? { headers: { authorization: `Bearer ${this.jwtToken}` } }
114
+ : undefined;
115
+
116
+ const ws = new (WebSocket as any)(this.url, options);
117
+ if (ws) {
118
+ ws.binaryType = 'arraybuffer';
119
+ }
120
+ return ws;
121
+ }
122
+
123
+ private attachWebSocketHandlers(): void {
124
+ if (!this.ws) return;
125
+
126
+ this.ws.addEventListener('open', () => this.onOpen());
127
+ this.ws.addEventListener('message', (event) => this.onMessage(event.data));
128
+ this.ws.addEventListener('error', (error) => this.onError(error));
129
+ this.ws.addEventListener('close', () => this.onClose());
130
+ }
131
+
132
+ private attachAbortSignalHandler(): void {
133
+ this.abortSignal.addEventListener('abort', () => this.close());
134
+ }
135
+
136
+ private onOpen(): void {
137
+ this.connectionState = 'connected';
138
+ this.reconnection.reset();
139
+ void this.processSendQueue();
140
+ }
141
+
142
+ private onClose(): void {
143
+ this.ws = null;
144
+
145
+ if (this.isClosed() || this.abortSignal.aborted) return;
146
+
147
+ if (this.sendCompleted) {
148
+ this.finalizeStream();
149
+ } else {
150
+ this.connectionState = 'disconnected';
151
+ this.reconnection.schedule();
152
+ }
153
+ }
154
+
155
+ private onError(error: unknown): void {
156
+ this.handleError(this.toError(error));
157
+ }
158
+
159
+ private onMessage(data: unknown): void {
160
+ try {
161
+ const message = this.parseMessage(data);
162
+ this.deliverResponse(message);
163
+ } catch (error) {
164
+ this.handleError(this.toError(error));
165
+ }
166
+ }
167
+
168
+ private closeConnection(): void {
169
+ if (this.ws?.readyState === WebSocket.OPEN) {
170
+ this.ws.close();
171
+ }
172
+ }
173
+
174
+ private close(): void {
175
+ if (this.isClosed()) return;
176
+
177
+ this.connectionState = 'closed';
178
+ this.reconnection.cancel();
179
+ this.closeWebSocket();
180
+ this.rejectAllPendingOperations();
181
+ }
182
+
183
+ private closeWebSocket(): void {
184
+ if (!this.ws) return;
185
+
186
+ try {
187
+ this.ws.close();
188
+ } catch {
189
+ // Suppress close errors
190
+ }
191
+
192
+ this.ws = null;
193
+ }
194
+
195
+ private finalizeStream(): void {
196
+ this.connectionState = 'closed';
197
+ this.resolveAllPendingResponses();
198
+ }
199
+
200
+ private resolveAllPendingResponses(): void {
201
+ while (this.responseResolvers.length > 0) {
202
+ const resolver = this.responseResolvers.shift()!;
203
+ resolver.resolve({ value: undefined as any, done: true });
204
+ }
205
+ }
206
+
207
+ private parseMessage(data: unknown): ServerMessageType {
208
+ if (data instanceof ArrayBuffer) {
209
+ return ServerMessageType.fromBinary(new Uint8Array(data));
210
+ }
211
+
212
+ throw new Error(`Unsupported message format: ${typeof data}`);
213
+ }
214
+
215
+ // === Send Queue Management ===
216
+
217
+ private validateSendState(): void {
218
+ if (this.sendCompleted) {
219
+ throw new Error('Cannot send: stream already completed');
220
+ }
221
+
222
+ if (this.abortSignal.aborted) {
223
+ throw new Error('Cannot send: stream aborted');
224
+ }
225
+ }
226
+
227
+ private enqueueSend(message: ClientMessageType): Promise<void> {
228
+ return new Promise<void>((resolve, reject) => {
229
+ this.sendQueue.push({ message, resolve, reject });
230
+ void this.processSendQueue();
231
+ });
232
+ }
233
+
234
+ private processSendQueue(): void {
235
+ if (!this.canSendMessages()) return;
236
+
237
+ while (this.sendQueue.length > 0) {
238
+ const queued = this.sendQueue.shift()!;
239
+ this.sendQueuedMessage(queued);
240
+ }
241
+ }
242
+
243
+ private canSendMessages(): boolean {
244
+ return this.connectionState === 'connected' && this.ws !== null;
245
+ }
246
+
247
+ private sendQueuedMessage(queued: QueuedMessage): void {
248
+ try {
249
+ const ws = this.ws;
250
+ if (!ws) {
251
+ throw new Error('WebSocket is not connected');
252
+ }
253
+
254
+ // Check if WebSocket is in a valid state for sending
255
+ if (ws.readyState !== WebSocket.OPEN) {
256
+ throw new Error(`WebSocket is not open (readyState: ${ws.readyState})`);
257
+ }
258
+
259
+ const binary = ClientMessageType.toBinary(queued.message);
260
+ ws.send(binary);
261
+ queued.resolve();
262
+ } catch (error) {
263
+ queued.reject(this.toError(error));
264
+ }
265
+ }
266
+
267
+ private async drainSendQueue(): Promise<void> {
268
+ const POLL_INTERVAL_MS = 10;
269
+
270
+ while (this.sendQueue.length > 0) {
271
+ await this.waitForCondition(
272
+ () => this.sendQueue.length === 0,
273
+ POLL_INTERVAL_MS,
274
+ );
275
+ }
276
+ }
277
+
278
+ private waitForCondition(
279
+ condition: () => boolean,
280
+ intervalMs: number,
281
+ ): Promise<void> {
282
+ return new Promise<void>((resolve, reject) => {
283
+ if (this.abortSignal.aborted) {
284
+ return reject(this.toError(this.abortSignal.reason) ?? new Error('Stream aborted'));
285
+ }
286
+
287
+ let timeoutId: ReturnType<typeof setTimeout>;
288
+ const onAbort = () => {
289
+ clearTimeout(timeoutId);
290
+ reject(this.toError(this.abortSignal.reason) ?? new Error('Stream aborted'));
291
+ };
292
+
293
+ this.abortSignal.addEventListener('abort', onAbort, { once: true });
294
+
295
+ const check = () => {
296
+ if (condition() || this.isStreamEnded()) {
297
+ this.abortSignal.removeEventListener('abort', onAbort);
298
+ resolve();
299
+ } else {
300
+ timeoutId = setTimeout(check, intervalMs);
301
+ }
302
+ };
303
+
304
+ check();
305
+ });
306
+ }
307
+
308
+ // === Response Delivery ===
309
+
310
+ private deliverResponse(message: ServerMessageType): void {
311
+ if (this.responseResolvers.length > 0) {
312
+ const resolver = this.responseResolvers.shift()!;
313
+ resolver.resolve({ value: message, done: false });
314
+ } else {
315
+ this.responseQueue.push(message);
316
+ }
317
+ }
318
+
319
+ private async *createResponseIterator(): AsyncIterator<ServerMessageType> {
320
+ while (true) {
321
+ const result = await this.nextResponse();
322
+
323
+ if (result.done) break;
324
+
325
+ yield result.value;
326
+ }
327
+ }
328
+
329
+ private nextResponse(): Promise<IteratorResult<ServerMessageType>> {
330
+ return new Promise<IteratorResult<ServerMessageType>>((resolve, reject) => {
331
+ // Fast path: message already available
332
+ if (this.responseQueue.length > 0) {
333
+ const message = this.responseQueue.shift()!;
334
+ resolve({ value: message, done: false });
335
+ return;
336
+ }
337
+
338
+ // Stream ended
339
+ if (this.isStreamEnded()) {
340
+ if (this.connectionError) {
341
+ reject(this.connectionError);
342
+ } else {
343
+ resolve({ value: undefined as any, done: true });
344
+ }
345
+ return;
346
+ }
347
+
348
+ // Wait for next message
349
+ this.responseResolvers.push({ resolve, reject });
350
+ });
351
+ }
352
+
353
+ // === Error Handling ===
354
+ private handleError(error: Error): void {
355
+ if (this.isClosed()) return;
356
+
357
+ this.connectionState = 'closed';
358
+ this.connectionError = error;
359
+ this.reconnection.cancel();
360
+ this.closeWebSocket();
361
+ this.rejectAllPendingOperations(error);
362
+ }
363
+
364
+ private rejectAllPendingOperations(error?: Error): void {
365
+ const err = error ?? this.createStreamClosedError();
366
+ this.rejectAllSendOperations(err);
367
+ this.rejectAllResponseResolvers(err);
368
+ }
369
+
370
+ private rejectAllSendOperations(error: Error): void {
371
+ while (this.sendQueue.length > 0) {
372
+ const queued = this.sendQueue.shift()!;
373
+ queued.reject(error);
374
+ }
375
+ }
376
+
377
+ private rejectAllResponseResolvers(error: Error): void {
378
+ while (this.responseResolvers.length > 0) {
379
+ const resolver = this.responseResolvers.shift()!;
380
+ resolver.reject(error);
381
+ }
382
+ }
383
+
384
+ private createStreamClosedError(): Error {
385
+ if (this.abortSignal.aborted) {
386
+ const reason = this.abortSignal.reason;
387
+ if (reason instanceof Error) {
388
+ return reason;
389
+ }
390
+ return new Error('Stream aborted', { cause: reason });
391
+ }
392
+ return new Error('Stream closed');
393
+ }
394
+ // === State Checks ===
395
+
396
+ private isConnectingOrConnected(): boolean {
397
+ return this.connectionState === 'connecting'
398
+ || this.connectionState === 'connected';
399
+ }
400
+
401
+ private isClosed(): boolean {
402
+ return this.connectionState === 'closed';
403
+ }
404
+
405
+ private isStreamEnded(): boolean {
406
+ return this.isClosed() || this.abortSignal.aborted;
407
+ }
408
+
409
+ private toError(error: unknown): Error {
410
+ return error instanceof Error ? error : new Error(String(error));
411
+ }
412
+ }
@@ -0,0 +1,123 @@
1
+ export interface RetryConfig {
2
+ maxAttempts: number;
3
+ initialDelay: number;
4
+ maxDelay: number;
5
+ }
6
+
7
+ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
8
+ maxAttempts: 10,
9
+ initialDelay: 100,
10
+ maxDelay: 30000,
11
+ };
12
+
13
+ export interface RetryCallbacks {
14
+ onRetry: () => void;
15
+ onMaxAttemptsReached: (error: Error) => void;
16
+ }
17
+
18
+ export class RetryStrategy {
19
+ private attempts = 0;
20
+ private timer: ReturnType<typeof setTimeout> | null = null;
21
+ private readonly config: RetryConfig;
22
+ private readonly callbacks: RetryCallbacks;
23
+ private readonly backoff: ExponentialBackoff;
24
+
25
+ constructor(config: Partial<RetryConfig>, callbacks: RetryCallbacks) {
26
+ this.config = { ...DEFAULT_RETRY_CONFIG, ...config };
27
+ this.callbacks = callbacks;
28
+ this.backoff = new ExponentialBackoff({
29
+ initialDelay: this.config.initialDelay,
30
+ maxDelay: this.config.maxDelay,
31
+ factor: 2,
32
+ jitter: 0.1,
33
+ });
34
+ }
35
+
36
+ schedule(): void {
37
+ if (this.timer) return;
38
+ if (this.hasExceededLimit()) {
39
+ this.notifyMaxAttemptsReached();
40
+ return;
41
+ }
42
+
43
+ this.timer = setTimeout(() => {
44
+ this.timer = null;
45
+ this.attempts++;
46
+ this.callbacks.onRetry();
47
+ }, this.backoff.delay());
48
+ }
49
+
50
+ cancel(): void {
51
+ if (this.timer) {
52
+ clearTimeout(this.timer);
53
+ this.timer = null;
54
+ }
55
+ }
56
+
57
+ reset(): void {
58
+ this.attempts = 0;
59
+ this.backoff.reset();
60
+ }
61
+
62
+ private hasExceededLimit(): boolean {
63
+ return this.attempts >= this.config.maxAttempts;
64
+ }
65
+
66
+ private notifyMaxAttemptsReached(): void {
67
+ const error = new Error(
68
+ `Max retry attempts (${this.config.maxAttempts}) reached`,
69
+ );
70
+ this.callbacks.onMaxAttemptsReached(error);
71
+ }
72
+ }
73
+
74
+ interface ExponentialBackoffConfig {
75
+ initialDelay: number;
76
+ maxDelay: number;
77
+ factor: number;
78
+ jitter: number;
79
+ }
80
+
81
+ class ExponentialBackoff {
82
+ private readonly initialDelay: number;
83
+ private readonly maxDelay: number;
84
+
85
+ private currentDelay: number;
86
+
87
+ private readonly factor: number;
88
+ private readonly jitter: number;
89
+
90
+ constructor(config: ExponentialBackoffConfig) {
91
+ this.initialDelay = config.initialDelay;
92
+ this.maxDelay = config.maxDelay;
93
+ this.factor = config.factor;
94
+ this.jitter = config.jitter;
95
+ this.currentDelay = config.initialDelay;
96
+ }
97
+
98
+ delay(): number {
99
+ if (this.currentDelay >= this.maxDelay) {
100
+ return this.applyJitter(this.maxDelay);
101
+ }
102
+
103
+ this.currentDelay = this.currentDelay * this.factor;
104
+
105
+ if (this.currentDelay > this.maxDelay) {
106
+ this.currentDelay = this.maxDelay;
107
+ }
108
+
109
+ return this.applyJitter(this.currentDelay);
110
+ }
111
+
112
+ reset(): void {
113
+ this.currentDelay = this.initialDelay;
114
+ }
115
+
116
+ private applyJitter(delay: number): number {
117
+ if (delay === 0 || this.jitter === 0) {
118
+ return delay;
119
+ }
120
+ const delayFactor = 1 - (this.jitter / 2) + Math.random() * this.jitter;
121
+ return delay * delayFactor;
122
+ }
123
+ }