@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.
- package/CHANGELOG.md +24 -1
- package/README.md +162 -3
- package/dist/browser/index.js +219 -15
- package/dist/cjs/index.cjs +231 -14
- package/dist/esm/index.js +231 -14
- package/dist/iife/index.js +219 -15
- package/dist/index.d.ts +63 -1
- package/package.json +8 -5
package/dist/cjs/index.cjs
CHANGED
|
@@ -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" &&
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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(), {
|
|
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" &&
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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(), {
|
|
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
|
|