@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.
- package/CHANGELOG.md +28 -0
- package/README.md +100 -2
- package/dist/browser/index.js +161 -15
- package/dist/cjs/index.cjs +161 -14
- package/dist/esm/index.js +161 -14
- package/dist/iife/index.js +161 -15
- package/dist/index.d.ts +43 -1
- package/package.json +8 -5
package/dist/cjs/index.cjs
CHANGED
|
@@ -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" &&
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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(), {
|
|
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" &&
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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(), {
|
|
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
|
|