@culpeo/async-ws 0.1.0 → 1.0.0

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.
@@ -64,6 +64,17 @@ function attachListeners(socket, onOpen, onMessage, onClose, onError) {
64
64
  function socketClose(socket, code, reason) {
65
65
  socket.close(code, reason);
66
66
  }
67
+ function socketTerminate(socket) {
68
+ socket.terminate();
69
+ }
70
+ function socketPing(socket) {
71
+ socket.ping();
72
+ }
73
+ function attachPongListener(socket, onPong) {
74
+ const handler = () => onPong();
75
+ socket.on("pong", handler);
76
+ return () => socket.off("pong", handler);
77
+ }
67
78
  ws.WebSocket.OPEN;
68
79
 
69
80
  /**
@@ -85,7 +96,21 @@ class WebSocketClient {
85
96
  this.terminalError = null;
86
97
  this.closeInfo = null;
87
98
  this.removeListeners = null;
99
+ this.keepAliveTimer = null;
100
+ this.pongTimer = null;
101
+ this.removePongListener = null;
102
+ this.connectionId = 0;
88
103
  this.maxBufferSize = options?.maxBufferSize ?? 0;
104
+ if (options?.keepAlive) {
105
+ if (options.keepAlive.interval <= 0) {
106
+ throw new Error("keepAlive.interval must be greater than 0.");
107
+ }
108
+ if (options.keepAlive.timeout !== undefined &&
109
+ options.keepAlive.timeout <= 0) {
110
+ throw new Error("keepAlive.timeout must be greater than 0.");
111
+ }
112
+ this.keepAliveConfig = options.keepAlive;
113
+ }
89
114
  }
90
115
  /** Current connection state. */
91
116
  get readyState() {
@@ -95,16 +120,41 @@ class WebSocketClient {
95
120
  get lastCloseInfo() {
96
121
  return this.closeInfo;
97
122
  }
123
+ /** The negotiated subprotocol, or empty string if none. */
124
+ get protocol() {
125
+ return this.socket?.protocol ?? "";
126
+ }
127
+ /** The URL of the WebSocket connection. */
128
+ get url() {
129
+ return this.socket?.url ?? "";
130
+ }
131
+ /** The number of bytes of data queued for sending. */
132
+ get bufferedAmount() {
133
+ return this.socket?.bufferedAmount ?? 0;
134
+ }
135
+ /** The extensions negotiated by the server. */
136
+ get extensions() {
137
+ return this.socket?.extensions ?? "";
138
+ }
98
139
  /**
99
140
  * Connect to a WebSocket server.
100
141
  * Resolves when the connection is open. Rejects on error.
101
142
  */
102
143
  connect(url, options) {
103
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
144
+ if (this.state !== "idle" &&
145
+ this.state !== "closed" &&
146
+ this.state !== "errored") {
104
147
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
105
148
  }
149
+ if (options?.timeout !== undefined && options.timeout <= 0) {
150
+ return Promise.reject(new Error("timeout must be greater than 0."));
151
+ }
152
+ if (options?.signal?.aborted) {
153
+ return Promise.reject(new Error("Connection aborted."));
154
+ }
106
155
  this.reset();
107
156
  this.state = "connecting";
157
+ const currentConnectionId = ++this.connectionId;
108
158
  return new Promise((resolve, reject) => {
109
159
  try {
110
160
  this.socket = createWebSocket(url, options);
@@ -112,19 +162,63 @@ class WebSocketClient {
112
162
  }
113
163
  catch (err) {
114
164
  this.state = "errored";
115
- this.terminalError = err instanceof Error ? err : new Error(String(err));
165
+ this.terminalError =
166
+ err instanceof Error ? err : new Error(String(err));
116
167
  reject(this.terminalError);
117
168
  return;
118
169
  }
119
170
  let settled = false;
171
+ let timeoutId = null;
172
+ const settle = (fn) => {
173
+ if (settled)
174
+ return;
175
+ settled = true;
176
+ if (timeoutId !== null) {
177
+ clearTimeout(timeoutId);
178
+ timeoutId = null;
179
+ }
180
+ if (options?.signal) {
181
+ options.signal.removeEventListener("abort", onAbort);
182
+ }
183
+ fn();
184
+ };
185
+ const onAbort = () => {
186
+ settle(() => {
187
+ this.state = "closed";
188
+ this.terminalError = new Error("Connection aborted.");
189
+ if (this.socket) {
190
+ socketTerminate(this.socket);
191
+ }
192
+ // Don't call cleanup() here — socketTerminate() emits
193
+ // error/close asynchronously; let onClose handle cleanup.
194
+ reject(this.terminalError);
195
+ });
196
+ };
197
+ if (options?.timeout !== undefined) {
198
+ timeoutId = setTimeout(() => {
199
+ settle(() => {
200
+ this.state = "closed";
201
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
202
+ if (this.socket) {
203
+ socketTerminate(this.socket);
204
+ }
205
+ // Don't call cleanup() here — socketTerminate() emits
206
+ // error/close asynchronously; let onClose handle cleanup.
207
+ reject(this.terminalError);
208
+ });
209
+ }, options.timeout);
210
+ }
211
+ if (options?.signal) {
212
+ options.signal.addEventListener("abort", onAbort, { once: true });
213
+ }
120
214
  this.removeListeners = attachListeners(this.socket,
121
215
  // onOpen
122
216
  () => {
123
- if (!settled) {
124
- settled = true;
217
+ settle(() => {
125
218
  this.state = "open";
219
+ this.startKeepAlive();
126
220
  resolve();
127
- }
221
+ });
128
222
  },
129
223
  // onMessage
130
224
  (data, binary) => {
@@ -132,14 +226,16 @@ class WebSocketClient {
132
226
  },
133
227
  // onClose
134
228
  (code, reason, wasClean) => {
229
+ // Ignore close events from a stale connection (e.g., after
230
+ // timeout/abort triggered a reconnect on the same client).
231
+ if (currentConnectionId !== this.connectionId)
232
+ return;
135
233
  this.closeInfo = { code, reason, wasClean };
136
- this.state;
137
234
  this.state = "closed";
138
235
  this.cleanup();
139
- if (!settled) {
140
- settled = true;
236
+ settle(() => {
141
237
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
142
- }
238
+ });
143
239
  // Only reject pending waiters once buffer is drained
144
240
  if (this.buffer.length === 0) {
145
241
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -147,11 +243,13 @@ class WebSocketClient {
147
243
  },
148
244
  // onError
149
245
  (error) => {
246
+ // Ignore error events from a stale connection.
247
+ if (currentConnectionId !== this.connectionId)
248
+ return;
150
249
  this.terminalError = error;
151
- if (!settled) {
152
- settled = true;
250
+ settle(() => {
153
251
  reject(error);
154
- }
252
+ });
155
253
  // Reject any pending receive() waiters immediately
156
254
  this.rejectAllWaiters(error);
157
255
  // Don't call cleanup() here — per spec, a close event always
@@ -199,7 +297,9 @@ class WebSocketClient {
199
297
  * Resolves when the close handshake completes.
200
298
  */
201
299
  close(code, reason) {
202
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
300
+ if (this.state === "closed" ||
301
+ this.state === "idle" ||
302
+ this.state === "errored") {
203
303
  return Promise.resolve();
204
304
  }
205
305
  if (!this.socket) {
@@ -209,7 +309,9 @@ class WebSocketClient {
209
309
  // Already closing — wait for the close event via a one-shot listener
210
310
  return new Promise((resolve) => {
211
311
  if (this.socket) {
212
- this.socket.addEventListener("close", () => resolve(), { once: true });
312
+ this.socket.addEventListener("close", () => resolve(), {
313
+ once: true,
314
+ });
213
315
  }
214
316
  else {
215
317
  resolve();
@@ -274,11 +376,55 @@ class WebSocketClient {
274
376
  waiter.reject(error);
275
377
  }
276
378
  }
379
+ startKeepAlive() {
380
+ if (!this.keepAliveConfig || !this.socket)
381
+ return;
382
+ const { interval, timeout } = this.keepAliveConfig;
383
+ const pongTimeout = timeout ?? interval;
384
+ this.removePongListener = attachPongListener(this.socket, () => {
385
+ if (this.pongTimer !== null) {
386
+ clearTimeout(this.pongTimer);
387
+ this.pongTimer = null;
388
+ }
389
+ });
390
+ this.keepAliveTimer = setInterval(() => {
391
+ if (this.state !== "open" || !this.socket)
392
+ return;
393
+ socketPing(this.socket);
394
+ // Clear any existing pong watchdog before starting a new one
395
+ // to prevent multiple timers when timeout > interval.
396
+ if (this.pongTimer !== null) {
397
+ clearTimeout(this.pongTimer);
398
+ }
399
+ this.pongTimer = setTimeout(() => {
400
+ if (this.state === "open" && this.socket) {
401
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
402
+ socketTerminate(this.socket);
403
+ }
404
+ }, pongTimeout);
405
+ }, interval);
406
+ }
407
+ stopKeepAlive() {
408
+ if (this.keepAliveTimer !== null) {
409
+ clearInterval(this.keepAliveTimer);
410
+ this.keepAliveTimer = null;
411
+ }
412
+ if (this.pongTimer !== null) {
413
+ clearTimeout(this.pongTimer);
414
+ this.pongTimer = null;
415
+ }
416
+ if (this.removePongListener) {
417
+ this.removePongListener();
418
+ this.removePongListener = null;
419
+ }
420
+ }
277
421
  cleanup() {
422
+ this.stopKeepAlive();
278
423
  if (this.removeListeners) {
279
424
  this.removeListeners();
280
425
  this.removeListeners = null;
281
426
  }
427
+ this.socket = null;
282
428
  }
283
429
  reset() {
284
430
  this.socket = null;
@@ -287,6 +433,7 @@ class WebSocketClient {
287
433
  this.terminalError = null;
288
434
  this.closeInfo = null;
289
435
  this.removeListeners = null;
436
+ this.stopKeepAlive();
290
437
  }
291
438
  }
292
439
 
package/dist/esm/index.js CHANGED
@@ -62,6 +62,17 @@ function attachListeners(socket, onOpen, onMessage, onClose, onError) {
62
62
  function socketClose(socket, code, reason) {
63
63
  socket.close(code, reason);
64
64
  }
65
+ function socketTerminate(socket) {
66
+ socket.terminate();
67
+ }
68
+ function socketPing(socket) {
69
+ socket.ping();
70
+ }
71
+ function attachPongListener(socket, onPong) {
72
+ const handler = () => onPong();
73
+ socket.on("pong", handler);
74
+ return () => socket.off("pong", handler);
75
+ }
65
76
  WebSocket.OPEN;
66
77
 
67
78
  /**
@@ -83,7 +94,21 @@ class WebSocketClient {
83
94
  this.terminalError = null;
84
95
  this.closeInfo = null;
85
96
  this.removeListeners = null;
97
+ this.keepAliveTimer = null;
98
+ this.pongTimer = null;
99
+ this.removePongListener = null;
100
+ this.connectionId = 0;
86
101
  this.maxBufferSize = options?.maxBufferSize ?? 0;
102
+ if (options?.keepAlive) {
103
+ if (options.keepAlive.interval <= 0) {
104
+ throw new Error("keepAlive.interval must be greater than 0.");
105
+ }
106
+ if (options.keepAlive.timeout !== undefined &&
107
+ options.keepAlive.timeout <= 0) {
108
+ throw new Error("keepAlive.timeout must be greater than 0.");
109
+ }
110
+ this.keepAliveConfig = options.keepAlive;
111
+ }
87
112
  }
88
113
  /** Current connection state. */
89
114
  get readyState() {
@@ -93,16 +118,41 @@ class WebSocketClient {
93
118
  get lastCloseInfo() {
94
119
  return this.closeInfo;
95
120
  }
121
+ /** The negotiated subprotocol, or empty string if none. */
122
+ get protocol() {
123
+ return this.socket?.protocol ?? "";
124
+ }
125
+ /** The URL of the WebSocket connection. */
126
+ get url() {
127
+ return this.socket?.url ?? "";
128
+ }
129
+ /** The number of bytes of data queued for sending. */
130
+ get bufferedAmount() {
131
+ return this.socket?.bufferedAmount ?? 0;
132
+ }
133
+ /** The extensions negotiated by the server. */
134
+ get extensions() {
135
+ return this.socket?.extensions ?? "";
136
+ }
96
137
  /**
97
138
  * Connect to a WebSocket server.
98
139
  * Resolves when the connection is open. Rejects on error.
99
140
  */
100
141
  connect(url, options) {
101
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
142
+ if (this.state !== "idle" &&
143
+ this.state !== "closed" &&
144
+ this.state !== "errored") {
102
145
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
103
146
  }
147
+ if (options?.timeout !== undefined && options.timeout <= 0) {
148
+ return Promise.reject(new Error("timeout must be greater than 0."));
149
+ }
150
+ if (options?.signal?.aborted) {
151
+ return Promise.reject(new Error("Connection aborted."));
152
+ }
104
153
  this.reset();
105
154
  this.state = "connecting";
155
+ const currentConnectionId = ++this.connectionId;
106
156
  return new Promise((resolve, reject) => {
107
157
  try {
108
158
  this.socket = createWebSocket(url, options);
@@ -110,19 +160,63 @@ class WebSocketClient {
110
160
  }
111
161
  catch (err) {
112
162
  this.state = "errored";
113
- this.terminalError = err instanceof Error ? err : new Error(String(err));
163
+ this.terminalError =
164
+ err instanceof Error ? err : new Error(String(err));
114
165
  reject(this.terminalError);
115
166
  return;
116
167
  }
117
168
  let settled = false;
169
+ let timeoutId = null;
170
+ const settle = (fn) => {
171
+ if (settled)
172
+ return;
173
+ settled = true;
174
+ if (timeoutId !== null) {
175
+ clearTimeout(timeoutId);
176
+ timeoutId = null;
177
+ }
178
+ if (options?.signal) {
179
+ options.signal.removeEventListener("abort", onAbort);
180
+ }
181
+ fn();
182
+ };
183
+ const onAbort = () => {
184
+ settle(() => {
185
+ this.state = "closed";
186
+ this.terminalError = new Error("Connection aborted.");
187
+ if (this.socket) {
188
+ socketTerminate(this.socket);
189
+ }
190
+ // Don't call cleanup() here — socketTerminate() emits
191
+ // error/close asynchronously; let onClose handle cleanup.
192
+ reject(this.terminalError);
193
+ });
194
+ };
195
+ if (options?.timeout !== undefined) {
196
+ timeoutId = setTimeout(() => {
197
+ settle(() => {
198
+ this.state = "closed";
199
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
200
+ if (this.socket) {
201
+ socketTerminate(this.socket);
202
+ }
203
+ // Don't call cleanup() here — socketTerminate() emits
204
+ // error/close asynchronously; let onClose handle cleanup.
205
+ reject(this.terminalError);
206
+ });
207
+ }, options.timeout);
208
+ }
209
+ if (options?.signal) {
210
+ options.signal.addEventListener("abort", onAbort, { once: true });
211
+ }
118
212
  this.removeListeners = attachListeners(this.socket,
119
213
  // onOpen
120
214
  () => {
121
- if (!settled) {
122
- settled = true;
215
+ settle(() => {
123
216
  this.state = "open";
217
+ this.startKeepAlive();
124
218
  resolve();
125
- }
219
+ });
126
220
  },
127
221
  // onMessage
128
222
  (data, binary) => {
@@ -130,14 +224,16 @@ class WebSocketClient {
130
224
  },
131
225
  // onClose
132
226
  (code, reason, wasClean) => {
227
+ // Ignore close events from a stale connection (e.g., after
228
+ // timeout/abort triggered a reconnect on the same client).
229
+ if (currentConnectionId !== this.connectionId)
230
+ return;
133
231
  this.closeInfo = { code, reason, wasClean };
134
- this.state;
135
232
  this.state = "closed";
136
233
  this.cleanup();
137
- if (!settled) {
138
- settled = true;
234
+ settle(() => {
139
235
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
140
- }
236
+ });
141
237
  // Only reject pending waiters once buffer is drained
142
238
  if (this.buffer.length === 0) {
143
239
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -145,11 +241,13 @@ class WebSocketClient {
145
241
  },
146
242
  // onError
147
243
  (error) => {
244
+ // Ignore error events from a stale connection.
245
+ if (currentConnectionId !== this.connectionId)
246
+ return;
148
247
  this.terminalError = error;
149
- if (!settled) {
150
- settled = true;
248
+ settle(() => {
151
249
  reject(error);
152
- }
250
+ });
153
251
  // Reject any pending receive() waiters immediately
154
252
  this.rejectAllWaiters(error);
155
253
  // Don't call cleanup() here — per spec, a close event always
@@ -197,7 +295,9 @@ class WebSocketClient {
197
295
  * Resolves when the close handshake completes.
198
296
  */
199
297
  close(code, reason) {
200
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
298
+ if (this.state === "closed" ||
299
+ this.state === "idle" ||
300
+ this.state === "errored") {
201
301
  return Promise.resolve();
202
302
  }
203
303
  if (!this.socket) {
@@ -207,7 +307,9 @@ class WebSocketClient {
207
307
  // Already closing — wait for the close event via a one-shot listener
208
308
  return new Promise((resolve) => {
209
309
  if (this.socket) {
210
- this.socket.addEventListener("close", () => resolve(), { once: true });
310
+ this.socket.addEventListener("close", () => resolve(), {
311
+ once: true,
312
+ });
211
313
  }
212
314
  else {
213
315
  resolve();
@@ -272,11 +374,55 @@ class WebSocketClient {
272
374
  waiter.reject(error);
273
375
  }
274
376
  }
377
+ startKeepAlive() {
378
+ if (!this.keepAliveConfig || !this.socket)
379
+ return;
380
+ const { interval, timeout } = this.keepAliveConfig;
381
+ const pongTimeout = timeout ?? interval;
382
+ this.removePongListener = attachPongListener(this.socket, () => {
383
+ if (this.pongTimer !== null) {
384
+ clearTimeout(this.pongTimer);
385
+ this.pongTimer = null;
386
+ }
387
+ });
388
+ this.keepAliveTimer = setInterval(() => {
389
+ if (this.state !== "open" || !this.socket)
390
+ return;
391
+ socketPing(this.socket);
392
+ // Clear any existing pong watchdog before starting a new one
393
+ // to prevent multiple timers when timeout > interval.
394
+ if (this.pongTimer !== null) {
395
+ clearTimeout(this.pongTimer);
396
+ }
397
+ this.pongTimer = setTimeout(() => {
398
+ if (this.state === "open" && this.socket) {
399
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
400
+ socketTerminate(this.socket);
401
+ }
402
+ }, pongTimeout);
403
+ }, interval);
404
+ }
405
+ stopKeepAlive() {
406
+ if (this.keepAliveTimer !== null) {
407
+ clearInterval(this.keepAliveTimer);
408
+ this.keepAliveTimer = null;
409
+ }
410
+ if (this.pongTimer !== null) {
411
+ clearTimeout(this.pongTimer);
412
+ this.pongTimer = null;
413
+ }
414
+ if (this.removePongListener) {
415
+ this.removePongListener();
416
+ this.removePongListener = null;
417
+ }
418
+ }
275
419
  cleanup() {
420
+ this.stopKeepAlive();
276
421
  if (this.removeListeners) {
277
422
  this.removeListeners();
278
423
  this.removeListeners = null;
279
424
  }
425
+ this.socket = null;
280
426
  }
281
427
  reset() {
282
428
  this.socket = null;
@@ -285,6 +431,7 @@ class WebSocketClient {
285
431
  this.terminalError = null;
286
432
  this.closeInfo = null;
287
433
  this.removeListeners = null;
434
+ this.stopKeepAlive();
288
435
  }
289
436
  }
290
437