@milaboratories/pl-client 2.16.12 → 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 (33) 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/ll_client.cjs +17 -7
  6. package/dist/core/ll_client.cjs.map +1 -1
  7. package/dist/core/ll_client.d.ts.map +1 -1
  8. package/dist/core/ll_client.js +17 -7
  9. package/dist/core/ll_client.js.map +1 -1
  10. package/dist/core/websocket_stream.cjs +126 -129
  11. package/dist/core/websocket_stream.cjs.map +1 -1
  12. package/dist/core/websocket_stream.d.ts +29 -22
  13. package/dist/core/websocket_stream.d.ts.map +1 -1
  14. package/dist/core/websocket_stream.js +127 -130
  15. package/dist/core/websocket_stream.js.map +1 -1
  16. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs +136 -0
  17. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs.map +1 -1
  18. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts +75 -1
  19. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts.map +1 -1
  20. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js +135 -1
  21. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js.map +1 -1
  22. package/dist/proto-rest/index.cjs +16 -2
  23. package/dist/proto-rest/index.cjs.map +1 -1
  24. package/dist/proto-rest/index.d.ts.map +1 -1
  25. package/dist/proto-rest/index.js +16 -2
  26. package/dist/proto-rest/index.js.map +1 -1
  27. package/package.json +6 -6
  28. package/src/core/driver.ts +1 -1
  29. package/src/core/ll_client.ts +25 -8
  30. package/src/core/websocket_stream.test.ts +19 -8
  31. package/src/core/websocket_stream.ts +154 -166
  32. package/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts +179 -1
  33. package/src/proto-rest/index.ts +17 -2
@@ -1,233 +1,210 @@
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';
1
+ import { WebSocket, type WebSocketInit, type Dispatcher, ErrorEvent } from 'undici';
6
2
  import type { BiDiStream } from './abstract_stream';
7
3
  import Denque from 'denque';
8
4
  import type { RetryConfig } from '../helpers/retry_strategy';
9
5
  import { RetryStrategy } from '../helpers/retry_strategy';
10
6
 
11
- type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'closing' | 'closed';
12
-
13
- interface QueuedMessage {
14
- message: ClientMessageType;
7
+ interface QueuedMessage<InType extends object> {
8
+ message: InType;
15
9
  resolve: () => void;
16
10
  reject: (error: Error) => void;
17
11
  }
18
12
 
19
- interface ResponseResolver {
20
- resolve: (value: IteratorResult<ServerMessageType>) => void;
13
+ interface ResponseResolver<OutType extends object> {
14
+ resolve: (value: IteratorResult<OutType>) => void;
21
15
  reject: (error: Error) => void;
22
16
  }
23
17
 
18
+ enum ConnectionState {
19
+ NEW = 0,
20
+ CONNECTING = 1,
21
+ CONNECTED = 2,
22
+ CLOSING = 3,
23
+ CLOSED = 4,
24
+ }
25
+
26
+ export type WSStreamOptions<ClientMsg extends object, ServerMsg extends object> = {
27
+ abortSignal?: AbortSignal;
28
+
29
+ dispatcher?: Dispatcher;
30
+ jwtToken?: string;
31
+ retryConfig?: Partial<RetryConfig>;
32
+
33
+ onComplete?: (stream: WebSocketBiDiStream<ClientMsg, ServerMsg>) => void | Promise<void>;
34
+ };
35
+
24
36
  /**
25
37
  * WebSocket-based bidirectional stream implementation for LLTransaction.
26
38
  * Implements BiDiStream interface which is compatible with DuplexStreamingCall.
27
39
  */
28
- export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, ServerMessageType> {
40
+ export class WebSocketBiDiStream<ClientMsg extends object, ServerMsg extends object> implements BiDiStream<ClientMsg, ServerMsg> {
29
41
  // Connection
30
42
  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;
43
+ private connectionState: ConnectionState = ConnectionState.NEW;
35
44
  private readonly reconnection: RetryStrategy;
36
45
 
37
46
  // Send management
38
- private readonly sendQueue = new Denque<QueuedMessage>();
47
+ private readonly sendQueue = new Denque<QueuedMessage<ClientMsg>>();
39
48
  private sendCompleted = false;
49
+ private readonly onComplete: (stream: WebSocketBiDiStream<ClientMsg, ServerMsg>) => void | Promise<void>;
40
50
 
41
51
  // Response management
42
- private readonly responseQueue = new Denque<ServerMessageType>();
43
- private responseResolvers: ResponseResolver[] = [];
52
+ private readonly responseQueue = new Denque<ServerMsg>();
53
+ private responseResolvers: ResponseResolver<ServerMsg>[] = [];
44
54
 
45
55
  // Error tracking
46
- private connectionError: Error | null = null;
56
+ private lastError?: Error;
47
57
 
48
58
  // === Public API ===
49
59
 
50
60
  public readonly requests = {
51
- send: async (message: ClientMessageType): Promise<void> => {
52
- this.validateSendState();
53
- return this.enqueueSend(message);
61
+ send: async (message: ClientMsg): Promise<void> => {
62
+ return await this.enqueueSend(message);
54
63
  },
55
64
 
56
65
  complete: async (): Promise<void> => {
57
66
  if (this.sendCompleted) return;
58
67
 
68
+ await this.drainSendQueue(); // ensure we sent all already queued messages before closing the stream
69
+ try {
70
+ await this.onComplete(this); // custom onComplete may send additional messages
71
+ } catch (_: unknown) {
72
+ // When 'complete' gets called concurrently with connection break or over a broken
73
+ // transaction stream (server decided it should drop transaction), server would close
74
+ // connection anyway on its end. We can safely ignore error here and just continue working.
75
+ }
59
76
  this.sendCompleted = true;
60
- await this.drainSendQueue();
61
- this.closeConnection();
62
77
  },
63
78
  };
64
79
 
65
- public readonly responses: AsyncIterable<ServerMessageType> = {
80
+ public readonly responses: AsyncIterable<ServerMsg> = {
66
81
  [Symbol.asyncIterator]: () => this.createResponseIterator(),
67
82
  };
68
83
 
84
+ public close(): void {
85
+ this.reconnection.cancel();
86
+
87
+ if (this.connectionState < ConnectionState.CONNECTED) {
88
+ // Never reached CONNECTED state. ws.close() will never trigger 'close' event.
89
+ this.ws?.close();
90
+ this.onClose();
91
+ return;
92
+ }
93
+
94
+ if (!this.progressConnectionState(ConnectionState.CLOSING)) return;
95
+ this.ws!.close();
96
+ }
97
+
69
98
  constructor(
70
- url: string,
71
- abortSignal: AbortSignal,
72
- jwtToken?: string,
73
- retryConfig: Partial<RetryConfig> = {},
99
+ private readonly url: string,
100
+ private readonly serializeClientMessage: (message: ClientMsg) => Uint8Array,
101
+ private readonly parseServerMessage: (data: Uint8Array) => ServerMsg,
102
+ private readonly options: WSStreamOptions<ClientMsg, ServerMsg> = {},
74
103
  ) {
75
- this.url = url;
76
- this.jwtToken = jwtToken;
77
- this.abortSignal = abortSignal;
104
+ this.onComplete = this.options.onComplete ?? ((stream) => stream.close());
78
105
 
106
+ const retryConfig = this.options.retryConfig ?? {};
79
107
  this.reconnection = new RetryStrategy(retryConfig, {
80
108
  onRetry: () => { void this.connect(); },
81
109
  onMaxAttemptsReached: (error) => this.handleError(error),
82
110
  });
83
111
 
84
- if (abortSignal.aborted) {
85
- this.connectionState = 'closed';
112
+ if (this.options.abortSignal?.aborted) {
113
+ this.progressConnectionState(ConnectionState.CLOSED);
86
114
  return;
87
115
  }
88
116
 
89
- this.attachAbortSignalHandler();
90
- void this.connect();
117
+ this.options.abortSignal?.addEventListener('abort', () => this.close());
118
+ this.connect();
91
119
  }
92
120
 
93
121
  // === Connection Lifecycle ===
94
122
 
95
123
  private connect(): void {
96
- if (this.isConnectingOrConnected() || this.abortSignal.aborted) return;
124
+ if (this.options.abortSignal?.aborted) return;
97
125
 
98
- this.connectionState = 'connecting';
99
- this.connectionError = null;
126
+ // Prevent reconnecting after first successful connection.
127
+ if (!this.progressConnectionState(ConnectionState.CONNECTING)) return;
100
128
 
101
129
  try {
102
130
  this.ws = this.createWebSocket();
103
- this.attachWebSocketHandlers();
131
+
132
+ this.ws.addEventListener('open', () => this.onOpen());
133
+ this.ws.addEventListener('message', (event) => this.onMessage(event.data));
134
+ this.ws.addEventListener('error', (error) => this.onError(error));
135
+ this.ws.addEventListener('close', () => this.onClose());
104
136
  } catch (error) {
105
- this.connectionError = this.toError(error);
106
- this.connectionState = 'disconnected';
137
+ this.lastError = this.toError(error);
107
138
  this.reconnection.schedule();
108
139
  }
109
140
  }
110
141
 
111
142
  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
- }
143
+ const options: WebSocketInit = {};
122
144
 
123
- private attachWebSocketHandlers(): void {
124
- if (!this.ws) return;
145
+ if (this.options.jwtToken) options.headers = { authorization: `Bearer ${this.options.jwtToken}` };
146
+ if (this.options.dispatcher) options.dispatcher = this.options.dispatcher;
125
147
 
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());
148
+ const ws = new WebSocket(this.url, options);
149
+ ws.binaryType = 'arraybuffer';
150
+ return ws;
134
151
  }
135
152
 
136
153
  private onOpen(): void {
137
- this.connectionState = 'connected';
138
- this.reconnection.reset();
139
- void this.processSendQueue();
154
+ this.progressConnectionState(ConnectionState.CONNECTED);
155
+ this.processSendQueue();
140
156
  }
141
157
 
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();
158
+ private onMessage(data: unknown): void {
159
+ if (!(data instanceof ArrayBuffer)) {
160
+ this.handleError(new Error(`Unexpected WS message format: ${typeof data}`));
161
+ return;
152
162
  }
153
- }
154
163
 
155
- private onError(error: unknown): void {
156
- this.handleError(this.toError(error));
157
- }
158
-
159
- private onMessage(data: unknown): void {
160
164
  try {
161
- const message = this.parseMessage(data);
165
+ const message = this.parseServerMessage(new Uint8Array(data));
162
166
  this.deliverResponse(message);
163
167
  } catch (error) {
164
168
  this.handleError(this.toError(error));
165
169
  }
166
170
  }
167
171
 
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
172
+ private onError(error: unknown): void {
173
+ if (this.connectionState < ConnectionState.CONNECTED) {
174
+ // Try to connect several times until we succeed or run out of attempts.
175
+ this.lastError = this.toError(error);
176
+ this.reconnection.schedule();
177
+ return;
190
178
  }
191
179
 
192
- this.ws = null;
193
- }
194
-
195
- private finalizeStream(): void {
196
- this.connectionState = 'closed';
197
- this.resolveAllPendingResponses();
180
+ this.handleError(this.toError(error));
198
181
  }
199
182
 
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
- }
183
+ private onClose(): void {
184
+ this.progressConnectionState(ConnectionState.CLOSED);
206
185
 
207
- private parseMessage(data: unknown): ServerMessageType {
208
- if (data instanceof ArrayBuffer) {
209
- return ServerMessageType.fromBinary(new Uint8Array(data));
186
+ if (!this.lastError) {
187
+ this.rejectAllSendOperations(this.createStreamClosedError());
188
+ this.resolveAllPendingResponses(); // unblock active async iterator
189
+ } else {
190
+ this.rejectAllPendingOperations(this.lastError);
210
191
  }
211
-
212
- throw new Error(`Unsupported message format: ${typeof data}`);
213
192
  }
214
193
 
215
194
  // === Send Queue Management ===
216
195
 
217
- private validateSendState(): void {
196
+ private enqueueSend(message: ClientMsg): Promise<void> {
218
197
  if (this.sendCompleted) {
219
198
  throw new Error('Cannot send: stream already completed');
220
199
  }
221
200
 
222
- if (this.abortSignal.aborted) {
201
+ if (this.options.abortSignal?.aborted) {
223
202
  throw new Error('Cannot send: stream aborted');
224
203
  }
225
- }
226
204
 
227
- private enqueueSend(message: ClientMessageType): Promise<void> {
228
205
  return new Promise<void>((resolve, reject) => {
229
206
  this.sendQueue.push({ message, resolve, reject });
230
- void this.processSendQueue();
207
+ this.processSendQueue();
231
208
  });
232
209
  }
233
210
 
@@ -241,10 +218,10 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
241
218
  }
242
219
 
243
220
  private canSendMessages(): boolean {
244
- return this.connectionState === 'connected' && this.ws !== null;
221
+ return this.connectionState === ConnectionState.CONNECTED;
245
222
  }
246
223
 
247
- private sendQueuedMessage(queued: QueuedMessage): void {
224
+ private sendQueuedMessage(queued: QueuedMessage<ClientMsg>): void {
248
225
  try {
249
226
  const ws = this.ws;
250
227
  if (!ws) {
@@ -256,7 +233,7 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
256
233
  throw new Error(`WebSocket is not open (readyState: ${ws.readyState})`);
257
234
  }
258
235
 
259
- const binary = ClientMessageType.toBinary(queued.message);
236
+ const binary = this.serializeClientMessage(queued.message);
260
237
  ws.send(binary);
261
238
  queued.resolve();
262
239
  } catch (error) {
@@ -265,7 +242,7 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
265
242
  }
266
243
 
267
244
  private async drainSendQueue(): Promise<void> {
268
- const POLL_INTERVAL_MS = 10;
245
+ const POLL_INTERVAL_MS = 5;
269
246
 
270
247
  while (this.sendQueue.length > 0) {
271
248
  await this.waitForCondition(
@@ -280,21 +257,21 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
280
257
  intervalMs: number,
281
258
  ): Promise<void> {
282
259
  return new Promise<void>((resolve, reject) => {
283
- if (this.abortSignal.aborted) {
284
- return reject(this.toError(this.abortSignal.reason) ?? new Error('Stream aborted'));
260
+ if (this.options.abortSignal?.aborted) {
261
+ return reject(this.toError(this.options.abortSignal.reason) ?? new Error('Stream aborted'));
285
262
  }
286
263
 
287
264
  let timeoutId: ReturnType<typeof setTimeout>;
288
265
  const onAbort = () => {
289
266
  clearTimeout(timeoutId);
290
- reject(this.toError(this.abortSignal.reason) ?? new Error('Stream aborted'));
267
+ reject(this.toError(this.options.abortSignal?.reason) ?? new Error('Stream aborted'));
291
268
  };
292
269
 
293
- this.abortSignal.addEventListener('abort', onAbort, { once: true });
270
+ this.options.abortSignal?.addEventListener('abort', onAbort, { once: true });
294
271
 
295
272
  const check = () => {
296
273
  if (condition() || this.isStreamEnded()) {
297
- this.abortSignal.removeEventListener('abort', onAbort);
274
+ this.options.abortSignal?.removeEventListener('abort', onAbort);
298
275
  resolve();
299
276
  } else {
300
277
  timeoutId = setTimeout(check, intervalMs);
@@ -307,7 +284,7 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
307
284
 
308
285
  // === Response Delivery ===
309
286
 
310
- private deliverResponse(message: ServerMessageType): void {
287
+ private deliverResponse(message: ServerMsg): void {
311
288
  if (this.responseResolvers.length > 0) {
312
289
  const resolver = this.responseResolvers.shift()!;
313
290
  resolver.resolve({ value: message, done: false });
@@ -316,7 +293,7 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
316
293
  }
317
294
  }
318
295
 
319
- private async *createResponseIterator(): AsyncIterator<ServerMessageType> {
296
+ private async *createResponseIterator(): AsyncIterator<ServerMsg> {
320
297
  while (true) {
321
298
  const result = await this.nextResponse();
322
299
 
@@ -326,8 +303,8 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
326
303
  }
327
304
  }
328
305
 
329
- private nextResponse(): Promise<IteratorResult<ServerMessageType>> {
330
- return new Promise<IteratorResult<ServerMessageType>>((resolve, reject) => {
306
+ private nextResponse(): Promise<IteratorResult<ServerMsg>> {
307
+ return new Promise<IteratorResult<ServerMsg>>((resolve, reject) => {
331
308
  // Fast path: message already available
332
309
  if (this.responseQueue.length > 0) {
333
310
  const message = this.responseQueue.shift()!;
@@ -337,8 +314,8 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
337
314
 
338
315
  // Stream ended
339
316
  if (this.isStreamEnded()) {
340
- if (this.connectionError) {
341
- reject(this.connectionError);
317
+ if (this.lastError) {
318
+ reject(this.lastError);
342
319
  } else {
343
320
  resolve({ value: undefined as any, done: true });
344
321
  }
@@ -350,21 +327,23 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
350
327
  });
351
328
  }
352
329
 
330
+ private resolveAllPendingResponses(): void {
331
+ while (this.responseResolvers.length > 0) {
332
+ const resolver = this.responseResolvers.shift()!;
333
+ resolver.resolve({ value: undefined as any, done: true });
334
+ }
335
+ }
336
+
353
337
  // === Error Handling ===
354
- private handleError(error: Error): void {
355
- if (this.isClosed()) return;
356
338
 
357
- this.connectionState = 'closed';
358
- this.connectionError = error;
359
- this.reconnection.cancel();
360
- this.closeWebSocket();
361
- this.rejectAllPendingOperations(error);
339
+ private handleError(error: Error): void {
340
+ this.lastError = error;
341
+ this.close();
362
342
  }
363
343
 
364
- private rejectAllPendingOperations(error?: Error): void {
365
- const err = error ?? this.createStreamClosedError();
366
- this.rejectAllSendOperations(err);
367
- this.rejectAllResponseResolvers(err);
344
+ private rejectAllPendingOperations(error: Error): void {
345
+ this.rejectAllSendOperations(error);
346
+ this.rejectAllResponseResolvers(error);
368
347
  }
369
348
 
370
349
  private rejectAllSendOperations(error: Error): void {
@@ -382,31 +361,40 @@ export class WebSocketBiDiStream implements BiDiStream<ClientMessageType, Server
382
361
  }
383
362
 
384
363
  private createStreamClosedError(): Error {
385
- if (this.abortSignal.aborted) {
386
- const reason = this.abortSignal.reason;
364
+ if (this.options.abortSignal?.aborted) {
365
+ const reason = this.options.abortSignal.reason;
387
366
  if (reason instanceof Error) {
388
367
  return reason;
389
368
  }
390
369
  return new Error('Stream aborted', { cause: reason });
391
370
  }
392
- return new Error('Stream closed');
393
- }
394
- // === State Checks ===
395
371
 
396
- private isConnectingOrConnected(): boolean {
397
- return this.connectionState === 'connecting'
398
- || this.connectionState === 'connected';
372
+ return new Error('Stream closed');
399
373
  }
400
374
 
401
- private isClosed(): boolean {
402
- return this.connectionState === 'closed';
403
- }
375
+ // === Helpers ===
404
376
 
405
377
  private isStreamEnded(): boolean {
406
- return this.isClosed() || this.abortSignal.aborted;
378
+ return this.connectionState === ConnectionState.CLOSED || this.options.abortSignal?.aborted || false;
407
379
  }
408
380
 
409
381
  private toError(error: unknown): Error {
410
- return error instanceof Error ? error : new Error(String(error));
382
+ if (error instanceof Error) return error;
383
+ if (error instanceof ErrorEvent) return error.error;
384
+ return new Error(String(error));
385
+ }
386
+
387
+ /**
388
+ * Connection state progresses linearly from NEW to CLOSED and never goes back.
389
+ * This internal contract dramatically simplifies the internal stream state management.
390
+ *
391
+ * If you ever feel the need to make this contract less strict, think twice.
392
+ */
393
+ private progressConnectionState(newState: ConnectionState): boolean {
394
+ if (newState < this.connectionState) {
395
+ return false;
396
+ }
397
+ this.connectionState = newState;
398
+ return true;
411
399
  }
412
400
  }