@culpeo/async-ws 0.2.0 → 1.1.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,33 @@ 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
+ }
78
+ function adoptSocket(rawSocket) {
79
+ const s = rawSocket;
80
+ if (!s ||
81
+ typeof s !== "object" ||
82
+ typeof s.send !== "function" ||
83
+ typeof s.close !== "function" ||
84
+ typeof s.addEventListener !== "function" ||
85
+ typeof s.removeEventListener !== "function") {
86
+ throw new Error("Expected a WebSocket-compatible object (must have send, close, addEventListener, removeEventListener).");
87
+ }
88
+ if (s.readyState !== ws.WebSocket.OPEN) {
89
+ throw new Error("Socket must be in the OPEN state to be adopted. " +
90
+ "Call fromSocket() immediately in the server's connection handler.");
91
+ }
92
+ return rawSocket;
93
+ }
67
94
  ws.WebSocket.OPEN;
68
95
 
69
96
  /**
@@ -85,7 +112,75 @@ class WebSocketClient {
85
112
  this.terminalError = null;
86
113
  this.closeInfo = null;
87
114
  this.removeListeners = null;
115
+ this.keepAliveTimer = null;
116
+ this.pongTimer = null;
117
+ this.removePongListener = null;
118
+ this.connectionId = 0;
88
119
  this.maxBufferSize = options?.maxBufferSize ?? 0;
120
+ if (options?.keepAlive) {
121
+ if (options.keepAlive.interval <= 0) {
122
+ throw new Error("keepAlive.interval must be greater than 0.");
123
+ }
124
+ if (options.keepAlive.timeout !== undefined &&
125
+ options.keepAlive.timeout <= 0) {
126
+ throw new Error("keepAlive.timeout must be greater than 0.");
127
+ }
128
+ this.keepAliveConfig = options.keepAlive;
129
+ }
130
+ }
131
+ /**
132
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
133
+ *
134
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
135
+ * The client takes ownership of the socket lifecycle: calling `close()`
136
+ * will close the underlying socket.
137
+ *
138
+ * **Node.js only.** Throws in browser builds.
139
+ *
140
+ * Call this immediately in the server's `connection` handler to avoid
141
+ * missing messages:
142
+ *
143
+ * ```ts
144
+ * wss.on("connection", (socket) => {
145
+ * const client = WebSocketClient.fromSocket(socket);
146
+ * const msg = await client.receive();
147
+ * });
148
+ * ```
149
+ */
150
+ static fromSocket(rawSocket, options) {
151
+ const client = new WebSocketClient(options);
152
+ const socket = adoptSocket(rawSocket);
153
+ client.socket = socket;
154
+ setBinaryType(socket);
155
+ client.state = "open";
156
+ const currentConnectionId = ++client.connectionId;
157
+ client.removeListeners = attachListeners(socket,
158
+ // onOpen — already open, won't fire
159
+ () => { },
160
+ // onMessage
161
+ (data, binary) => {
162
+ client.enqueueMessage({ data, binary });
163
+ },
164
+ // onClose
165
+ (code, reason, wasClean) => {
166
+ if (currentConnectionId !== client.connectionId)
167
+ return;
168
+ client.closeInfo = { code, reason, wasClean };
169
+ client.state = "closed";
170
+ client.cleanup();
171
+ if (client.buffer.length === 0) {
172
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
173
+ }
174
+ },
175
+ // onError
176
+ (error) => {
177
+ if (currentConnectionId !== client.connectionId)
178
+ return;
179
+ client.terminalError = error;
180
+ client.rejectAllWaiters(error);
181
+ });
182
+ client.startKeepAlive();
183
+ return client;
89
184
  }
90
185
  /** Current connection state. */
91
186
  get readyState() {
@@ -95,16 +190,41 @@ class WebSocketClient {
95
190
  get lastCloseInfo() {
96
191
  return this.closeInfo;
97
192
  }
193
+ /** The negotiated subprotocol, or empty string if none. */
194
+ get protocol() {
195
+ return this.socket?.protocol ?? "";
196
+ }
197
+ /** The URL of the WebSocket connection. */
198
+ get url() {
199
+ return this.socket?.url ?? "";
200
+ }
201
+ /** The number of bytes of data queued for sending. */
202
+ get bufferedAmount() {
203
+ return this.socket?.bufferedAmount ?? 0;
204
+ }
205
+ /** The extensions negotiated by the server. */
206
+ get extensions() {
207
+ return this.socket?.extensions ?? "";
208
+ }
98
209
  /**
99
210
  * Connect to a WebSocket server.
100
211
  * Resolves when the connection is open. Rejects on error.
101
212
  */
102
213
  connect(url, options) {
103
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
214
+ if (this.state !== "idle" &&
215
+ this.state !== "closed" &&
216
+ this.state !== "errored") {
104
217
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
105
218
  }
219
+ if (options?.timeout !== undefined && options.timeout <= 0) {
220
+ return Promise.reject(new Error("timeout must be greater than 0."));
221
+ }
222
+ if (options?.signal?.aborted) {
223
+ return Promise.reject(new Error("Connection aborted."));
224
+ }
106
225
  this.reset();
107
226
  this.state = "connecting";
227
+ const currentConnectionId = ++this.connectionId;
108
228
  return new Promise((resolve, reject) => {
109
229
  try {
110
230
  this.socket = createWebSocket(url, options);
@@ -112,19 +232,63 @@ class WebSocketClient {
112
232
  }
113
233
  catch (err) {
114
234
  this.state = "errored";
115
- this.terminalError = err instanceof Error ? err : new Error(String(err));
235
+ this.terminalError =
236
+ err instanceof Error ? err : new Error(String(err));
116
237
  reject(this.terminalError);
117
238
  return;
118
239
  }
119
240
  let settled = false;
241
+ let timeoutId = null;
242
+ const settle = (fn) => {
243
+ if (settled)
244
+ return;
245
+ settled = true;
246
+ if (timeoutId !== null) {
247
+ clearTimeout(timeoutId);
248
+ timeoutId = null;
249
+ }
250
+ if (options?.signal) {
251
+ options.signal.removeEventListener("abort", onAbort);
252
+ }
253
+ fn();
254
+ };
255
+ const onAbort = () => {
256
+ settle(() => {
257
+ this.state = "closed";
258
+ this.terminalError = new Error("Connection aborted.");
259
+ if (this.socket) {
260
+ socketTerminate(this.socket);
261
+ }
262
+ // Don't call cleanup() here — socketTerminate() emits
263
+ // error/close asynchronously; let onClose handle cleanup.
264
+ reject(this.terminalError);
265
+ });
266
+ };
267
+ if (options?.timeout !== undefined) {
268
+ timeoutId = setTimeout(() => {
269
+ settle(() => {
270
+ this.state = "closed";
271
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
272
+ if (this.socket) {
273
+ socketTerminate(this.socket);
274
+ }
275
+ // Don't call cleanup() here — socketTerminate() emits
276
+ // error/close asynchronously; let onClose handle cleanup.
277
+ reject(this.terminalError);
278
+ });
279
+ }, options.timeout);
280
+ }
281
+ if (options?.signal) {
282
+ options.signal.addEventListener("abort", onAbort, { once: true });
283
+ }
120
284
  this.removeListeners = attachListeners(this.socket,
121
285
  // onOpen
122
286
  () => {
123
- if (!settled) {
124
- settled = true;
287
+ settle(() => {
125
288
  this.state = "open";
289
+ this.startKeepAlive();
126
290
  resolve();
127
- }
291
+ });
128
292
  },
129
293
  // onMessage
130
294
  (data, binary) => {
@@ -132,14 +296,16 @@ class WebSocketClient {
132
296
  },
133
297
  // onClose
134
298
  (code, reason, wasClean) => {
299
+ // Ignore close events from a stale connection (e.g., after
300
+ // timeout/abort triggered a reconnect on the same client).
301
+ if (currentConnectionId !== this.connectionId)
302
+ return;
135
303
  this.closeInfo = { code, reason, wasClean };
136
- this.state;
137
304
  this.state = "closed";
138
305
  this.cleanup();
139
- if (!settled) {
140
- settled = true;
306
+ settle(() => {
141
307
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
142
- }
308
+ });
143
309
  // Only reject pending waiters once buffer is drained
144
310
  if (this.buffer.length === 0) {
145
311
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -147,11 +313,13 @@ class WebSocketClient {
147
313
  },
148
314
  // onError
149
315
  (error) => {
316
+ // Ignore error events from a stale connection.
317
+ if (currentConnectionId !== this.connectionId)
318
+ return;
150
319
  this.terminalError = error;
151
- if (!settled) {
152
- settled = true;
320
+ settle(() => {
153
321
  reject(error);
154
- }
322
+ });
155
323
  // Reject any pending receive() waiters immediately
156
324
  this.rejectAllWaiters(error);
157
325
  // Don't call cleanup() here — per spec, a close event always
@@ -199,7 +367,9 @@ class WebSocketClient {
199
367
  * Resolves when the close handshake completes.
200
368
  */
201
369
  close(code, reason) {
202
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
370
+ if (this.state === "closed" ||
371
+ this.state === "idle" ||
372
+ this.state === "errored") {
203
373
  return Promise.resolve();
204
374
  }
205
375
  if (!this.socket) {
@@ -209,7 +379,9 @@ class WebSocketClient {
209
379
  // Already closing — wait for the close event via a one-shot listener
210
380
  return new Promise((resolve) => {
211
381
  if (this.socket) {
212
- this.socket.addEventListener("close", () => resolve(), { once: true });
382
+ this.socket.addEventListener("close", () => resolve(), {
383
+ once: true,
384
+ });
213
385
  }
214
386
  else {
215
387
  resolve();
@@ -274,11 +446,55 @@ class WebSocketClient {
274
446
  waiter.reject(error);
275
447
  }
276
448
  }
449
+ startKeepAlive() {
450
+ if (!this.keepAliveConfig || !this.socket)
451
+ return;
452
+ const { interval, timeout } = this.keepAliveConfig;
453
+ const pongTimeout = timeout ?? interval;
454
+ this.removePongListener = attachPongListener(this.socket, () => {
455
+ if (this.pongTimer !== null) {
456
+ clearTimeout(this.pongTimer);
457
+ this.pongTimer = null;
458
+ }
459
+ });
460
+ this.keepAliveTimer = setInterval(() => {
461
+ if (this.state !== "open" || !this.socket)
462
+ return;
463
+ socketPing(this.socket);
464
+ // Clear any existing pong watchdog before starting a new one
465
+ // to prevent multiple timers when timeout > interval.
466
+ if (this.pongTimer !== null) {
467
+ clearTimeout(this.pongTimer);
468
+ }
469
+ this.pongTimer = setTimeout(() => {
470
+ if (this.state === "open" && this.socket) {
471
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
472
+ socketTerminate(this.socket);
473
+ }
474
+ }, pongTimeout);
475
+ }, interval);
476
+ }
477
+ stopKeepAlive() {
478
+ if (this.keepAliveTimer !== null) {
479
+ clearInterval(this.keepAliveTimer);
480
+ this.keepAliveTimer = null;
481
+ }
482
+ if (this.pongTimer !== null) {
483
+ clearTimeout(this.pongTimer);
484
+ this.pongTimer = null;
485
+ }
486
+ if (this.removePongListener) {
487
+ this.removePongListener();
488
+ this.removePongListener = null;
489
+ }
490
+ }
277
491
  cleanup() {
492
+ this.stopKeepAlive();
278
493
  if (this.removeListeners) {
279
494
  this.removeListeners();
280
495
  this.removeListeners = null;
281
496
  }
497
+ this.socket = null;
282
498
  }
283
499
  reset() {
284
500
  this.socket = null;
@@ -287,6 +503,7 @@ class WebSocketClient {
287
503
  this.terminalError = null;
288
504
  this.closeInfo = null;
289
505
  this.removeListeners = null;
506
+ this.stopKeepAlive();
290
507
  }
291
508
  }
292
509
 
package/dist/esm/index.js CHANGED
@@ -62,6 +62,33 @@ 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
+ }
76
+ function adoptSocket(rawSocket) {
77
+ const s = rawSocket;
78
+ if (!s ||
79
+ typeof s !== "object" ||
80
+ typeof s.send !== "function" ||
81
+ typeof s.close !== "function" ||
82
+ typeof s.addEventListener !== "function" ||
83
+ typeof s.removeEventListener !== "function") {
84
+ throw new Error("Expected a WebSocket-compatible object (must have send, close, addEventListener, removeEventListener).");
85
+ }
86
+ if (s.readyState !== WebSocket.OPEN) {
87
+ throw new Error("Socket must be in the OPEN state to be adopted. " +
88
+ "Call fromSocket() immediately in the server's connection handler.");
89
+ }
90
+ return rawSocket;
91
+ }
65
92
  WebSocket.OPEN;
66
93
 
67
94
  /**
@@ -83,7 +110,75 @@ class WebSocketClient {
83
110
  this.terminalError = null;
84
111
  this.closeInfo = null;
85
112
  this.removeListeners = null;
113
+ this.keepAliveTimer = null;
114
+ this.pongTimer = null;
115
+ this.removePongListener = null;
116
+ this.connectionId = 0;
86
117
  this.maxBufferSize = options?.maxBufferSize ?? 0;
118
+ if (options?.keepAlive) {
119
+ if (options.keepAlive.interval <= 0) {
120
+ throw new Error("keepAlive.interval must be greater than 0.");
121
+ }
122
+ if (options.keepAlive.timeout !== undefined &&
123
+ options.keepAlive.timeout <= 0) {
124
+ throw new Error("keepAlive.timeout must be greater than 0.");
125
+ }
126
+ this.keepAliveConfig = options.keepAlive;
127
+ }
128
+ }
129
+ /**
130
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
131
+ *
132
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
133
+ * The client takes ownership of the socket lifecycle: calling `close()`
134
+ * will close the underlying socket.
135
+ *
136
+ * **Node.js only.** Throws in browser builds.
137
+ *
138
+ * Call this immediately in the server's `connection` handler to avoid
139
+ * missing messages:
140
+ *
141
+ * ```ts
142
+ * wss.on("connection", (socket) => {
143
+ * const client = WebSocketClient.fromSocket(socket);
144
+ * const msg = await client.receive();
145
+ * });
146
+ * ```
147
+ */
148
+ static fromSocket(rawSocket, options) {
149
+ const client = new WebSocketClient(options);
150
+ const socket = adoptSocket(rawSocket);
151
+ client.socket = socket;
152
+ setBinaryType(socket);
153
+ client.state = "open";
154
+ const currentConnectionId = ++client.connectionId;
155
+ client.removeListeners = attachListeners(socket,
156
+ // onOpen — already open, won't fire
157
+ () => { },
158
+ // onMessage
159
+ (data, binary) => {
160
+ client.enqueueMessage({ data, binary });
161
+ },
162
+ // onClose
163
+ (code, reason, wasClean) => {
164
+ if (currentConnectionId !== client.connectionId)
165
+ return;
166
+ client.closeInfo = { code, reason, wasClean };
167
+ client.state = "closed";
168
+ client.cleanup();
169
+ if (client.buffer.length === 0) {
170
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
171
+ }
172
+ },
173
+ // onError
174
+ (error) => {
175
+ if (currentConnectionId !== client.connectionId)
176
+ return;
177
+ client.terminalError = error;
178
+ client.rejectAllWaiters(error);
179
+ });
180
+ client.startKeepAlive();
181
+ return client;
87
182
  }
88
183
  /** Current connection state. */
89
184
  get readyState() {
@@ -93,16 +188,41 @@ class WebSocketClient {
93
188
  get lastCloseInfo() {
94
189
  return this.closeInfo;
95
190
  }
191
+ /** The negotiated subprotocol, or empty string if none. */
192
+ get protocol() {
193
+ return this.socket?.protocol ?? "";
194
+ }
195
+ /** The URL of the WebSocket connection. */
196
+ get url() {
197
+ return this.socket?.url ?? "";
198
+ }
199
+ /** The number of bytes of data queued for sending. */
200
+ get bufferedAmount() {
201
+ return this.socket?.bufferedAmount ?? 0;
202
+ }
203
+ /** The extensions negotiated by the server. */
204
+ get extensions() {
205
+ return this.socket?.extensions ?? "";
206
+ }
96
207
  /**
97
208
  * Connect to a WebSocket server.
98
209
  * Resolves when the connection is open. Rejects on error.
99
210
  */
100
211
  connect(url, options) {
101
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
212
+ if (this.state !== "idle" &&
213
+ this.state !== "closed" &&
214
+ this.state !== "errored") {
102
215
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
103
216
  }
217
+ if (options?.timeout !== undefined && options.timeout <= 0) {
218
+ return Promise.reject(new Error("timeout must be greater than 0."));
219
+ }
220
+ if (options?.signal?.aborted) {
221
+ return Promise.reject(new Error("Connection aborted."));
222
+ }
104
223
  this.reset();
105
224
  this.state = "connecting";
225
+ const currentConnectionId = ++this.connectionId;
106
226
  return new Promise((resolve, reject) => {
107
227
  try {
108
228
  this.socket = createWebSocket(url, options);
@@ -110,19 +230,63 @@ class WebSocketClient {
110
230
  }
111
231
  catch (err) {
112
232
  this.state = "errored";
113
- this.terminalError = err instanceof Error ? err : new Error(String(err));
233
+ this.terminalError =
234
+ err instanceof Error ? err : new Error(String(err));
114
235
  reject(this.terminalError);
115
236
  return;
116
237
  }
117
238
  let settled = false;
239
+ let timeoutId = null;
240
+ const settle = (fn) => {
241
+ if (settled)
242
+ return;
243
+ settled = true;
244
+ if (timeoutId !== null) {
245
+ clearTimeout(timeoutId);
246
+ timeoutId = null;
247
+ }
248
+ if (options?.signal) {
249
+ options.signal.removeEventListener("abort", onAbort);
250
+ }
251
+ fn();
252
+ };
253
+ const onAbort = () => {
254
+ settle(() => {
255
+ this.state = "closed";
256
+ this.terminalError = new Error("Connection aborted.");
257
+ if (this.socket) {
258
+ socketTerminate(this.socket);
259
+ }
260
+ // Don't call cleanup() here — socketTerminate() emits
261
+ // error/close asynchronously; let onClose handle cleanup.
262
+ reject(this.terminalError);
263
+ });
264
+ };
265
+ if (options?.timeout !== undefined) {
266
+ timeoutId = setTimeout(() => {
267
+ settle(() => {
268
+ this.state = "closed";
269
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
270
+ if (this.socket) {
271
+ socketTerminate(this.socket);
272
+ }
273
+ // Don't call cleanup() here — socketTerminate() emits
274
+ // error/close asynchronously; let onClose handle cleanup.
275
+ reject(this.terminalError);
276
+ });
277
+ }, options.timeout);
278
+ }
279
+ if (options?.signal) {
280
+ options.signal.addEventListener("abort", onAbort, { once: true });
281
+ }
118
282
  this.removeListeners = attachListeners(this.socket,
119
283
  // onOpen
120
284
  () => {
121
- if (!settled) {
122
- settled = true;
285
+ settle(() => {
123
286
  this.state = "open";
287
+ this.startKeepAlive();
124
288
  resolve();
125
- }
289
+ });
126
290
  },
127
291
  // onMessage
128
292
  (data, binary) => {
@@ -130,14 +294,16 @@ class WebSocketClient {
130
294
  },
131
295
  // onClose
132
296
  (code, reason, wasClean) => {
297
+ // Ignore close events from a stale connection (e.g., after
298
+ // timeout/abort triggered a reconnect on the same client).
299
+ if (currentConnectionId !== this.connectionId)
300
+ return;
133
301
  this.closeInfo = { code, reason, wasClean };
134
- this.state;
135
302
  this.state = "closed";
136
303
  this.cleanup();
137
- if (!settled) {
138
- settled = true;
304
+ settle(() => {
139
305
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
140
- }
306
+ });
141
307
  // Only reject pending waiters once buffer is drained
142
308
  if (this.buffer.length === 0) {
143
309
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -145,11 +311,13 @@ class WebSocketClient {
145
311
  },
146
312
  // onError
147
313
  (error) => {
314
+ // Ignore error events from a stale connection.
315
+ if (currentConnectionId !== this.connectionId)
316
+ return;
148
317
  this.terminalError = error;
149
- if (!settled) {
150
- settled = true;
318
+ settle(() => {
151
319
  reject(error);
152
- }
320
+ });
153
321
  // Reject any pending receive() waiters immediately
154
322
  this.rejectAllWaiters(error);
155
323
  // Don't call cleanup() here — per spec, a close event always
@@ -197,7 +365,9 @@ class WebSocketClient {
197
365
  * Resolves when the close handshake completes.
198
366
  */
199
367
  close(code, reason) {
200
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
368
+ if (this.state === "closed" ||
369
+ this.state === "idle" ||
370
+ this.state === "errored") {
201
371
  return Promise.resolve();
202
372
  }
203
373
  if (!this.socket) {
@@ -207,7 +377,9 @@ class WebSocketClient {
207
377
  // Already closing — wait for the close event via a one-shot listener
208
378
  return new Promise((resolve) => {
209
379
  if (this.socket) {
210
- this.socket.addEventListener("close", () => resolve(), { once: true });
380
+ this.socket.addEventListener("close", () => resolve(), {
381
+ once: true,
382
+ });
211
383
  }
212
384
  else {
213
385
  resolve();
@@ -272,11 +444,55 @@ class WebSocketClient {
272
444
  waiter.reject(error);
273
445
  }
274
446
  }
447
+ startKeepAlive() {
448
+ if (!this.keepAliveConfig || !this.socket)
449
+ return;
450
+ const { interval, timeout } = this.keepAliveConfig;
451
+ const pongTimeout = timeout ?? interval;
452
+ this.removePongListener = attachPongListener(this.socket, () => {
453
+ if (this.pongTimer !== null) {
454
+ clearTimeout(this.pongTimer);
455
+ this.pongTimer = null;
456
+ }
457
+ });
458
+ this.keepAliveTimer = setInterval(() => {
459
+ if (this.state !== "open" || !this.socket)
460
+ return;
461
+ socketPing(this.socket);
462
+ // Clear any existing pong watchdog before starting a new one
463
+ // to prevent multiple timers when timeout > interval.
464
+ if (this.pongTimer !== null) {
465
+ clearTimeout(this.pongTimer);
466
+ }
467
+ this.pongTimer = setTimeout(() => {
468
+ if (this.state === "open" && this.socket) {
469
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
470
+ socketTerminate(this.socket);
471
+ }
472
+ }, pongTimeout);
473
+ }, interval);
474
+ }
475
+ stopKeepAlive() {
476
+ if (this.keepAliveTimer !== null) {
477
+ clearInterval(this.keepAliveTimer);
478
+ this.keepAliveTimer = null;
479
+ }
480
+ if (this.pongTimer !== null) {
481
+ clearTimeout(this.pongTimer);
482
+ this.pongTimer = null;
483
+ }
484
+ if (this.removePongListener) {
485
+ this.removePongListener();
486
+ this.removePongListener = null;
487
+ }
488
+ }
275
489
  cleanup() {
490
+ this.stopKeepAlive();
276
491
  if (this.removeListeners) {
277
492
  this.removeListeners();
278
493
  this.removeListeners = null;
279
494
  }
495
+ this.socket = null;
280
496
  }
281
497
  reset() {
282
498
  this.socket = null;
@@ -285,6 +501,7 @@ class WebSocketClient {
285
501
  this.terminalError = null;
286
502
  this.closeInfo = null;
287
503
  this.removeListeners = null;
504
+ this.stopKeepAlive();
288
505
  }
289
506
  }
290
507