@milaboratories/pl-client 2.16.12 → 2.16.14

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