@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/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 89a662e: ### Adopt existing WebSocket connections
|
|
8
|
+
|
|
9
|
+
New static method `WebSocketClient.fromSocket(socket, options?)` wraps an already-open WebSocket (e.g. from a `WebSocketServer` connection event) into a ready-to-use `WebSocketClient`. Node.js only.
|
|
10
|
+
|
|
11
|
+
## 1.0.0
|
|
12
|
+
|
|
13
|
+
### Major Changes
|
|
14
|
+
|
|
15
|
+
- 6b68e4c: ### Connect timeout and abort signal
|
|
16
|
+
|
|
17
|
+
`connect()` now accepts `timeout` (milliseconds) and `signal` (AbortSignal) options to cancel connection attempts.
|
|
18
|
+
|
|
19
|
+
### Keep-alive ping/pong (Node.js)
|
|
20
|
+
|
|
21
|
+
New `keepAlive` constructor option sends periodic pings and terminates the connection if no pong is received. Not available in browsers.
|
|
22
|
+
|
|
23
|
+
### Exposed WebSocket properties
|
|
24
|
+
|
|
25
|
+
Added read-only `protocol`, `url`, `bufferedAmount`, and `extensions` properties that delegate to the underlying socket.
|
|
26
|
+
|
|
3
27
|
## 0.2.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
|
6
30
|
|
|
7
31
|
- 9453a7c: Initial release
|
|
8
|
-
|
|
9
32
|
- Cross-platform WebSocket client for Node.js and browsers
|
|
10
33
|
- Promise-based `connect()`, `send()`, `receive()`, `close()` API
|
|
11
34
|
- Async iteration with `for await...of`
|
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
- Async iteration support with `for await...of`
|
|
16
16
|
- Message buffering for messages that arrive before `receive()` is called
|
|
17
17
|
- Configurable `maxBufferSize` with oldest-message eviction when full
|
|
18
|
+
- Connect timeout and `AbortSignal` support
|
|
19
|
+
- Keep-alive with automatic ping/pong (Node.js)
|
|
20
|
+
- Server-side socket adoption with `fromSocket()` (Node.js)
|
|
21
|
+
- Exposed WebSocket properties (`protocol`, `url`, `bufferedAmount`, `extensions`)
|
|
18
22
|
- Clean close information via `lastCloseInfo`
|
|
19
23
|
- TypeScript-first with bundled type definitions
|
|
20
24
|
- Binary and text message support
|
|
@@ -45,7 +49,7 @@ await client.connect("wss://echo.websocket.events");
|
|
|
45
49
|
await client.send("hello");
|
|
46
50
|
|
|
47
51
|
const message = await client.receive();
|
|
48
|
-
console.log(message.data);
|
|
52
|
+
console.log(message.data); // string | ArrayBuffer
|
|
49
53
|
console.log(message.binary); // boolean
|
|
50
54
|
|
|
51
55
|
await client.close();
|
|
@@ -69,6 +73,9 @@ Creates a new client instance.
|
|
|
69
73
|
- Maximum number of incoming messages to keep buffered before they are consumed
|
|
70
74
|
- Default: `0` (unlimited)
|
|
71
75
|
- When the limit is reached, the oldest buffered message is dropped
|
|
76
|
+
- `keepAlive?: KeepAliveOptions`
|
|
77
|
+
- Enables automatic ping/pong keep-alive (Node.js only)
|
|
78
|
+
- Throws if used in a browser environment
|
|
72
79
|
|
|
73
80
|
#### Properties
|
|
74
81
|
|
|
@@ -87,6 +94,38 @@ Returns the current client state:
|
|
|
87
94
|
- `"closed"`
|
|
88
95
|
- `"errored"`
|
|
89
96
|
|
|
97
|
+
#### `client.protocol`
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
readonly protocol: string
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Returns the negotiated subprotocol, or `""` when not connected.
|
|
104
|
+
|
|
105
|
+
#### `client.url`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
readonly url: string
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Returns the URL of the WebSocket connection, or `""` when not connected.
|
|
112
|
+
|
|
113
|
+
#### `client.bufferedAmount`
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
readonly bufferedAmount: number
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Returns the number of bytes queued for transmission, or `0` when not connected.
|
|
120
|
+
|
|
121
|
+
#### `client.extensions`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
readonly extensions: string
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Returns the negotiated extensions, or `""` when not connected.
|
|
128
|
+
|
|
90
129
|
#### `client.lastCloseInfo`
|
|
91
130
|
|
|
92
131
|
```ts
|
|
@@ -95,7 +134,39 @@ readonly lastCloseInfo: WebSocketCloseInfo | null
|
|
|
95
134
|
|
|
96
135
|
Returns close metadata from the most recent close event, or `null` if the socket has not closed yet.
|
|
97
136
|
|
|
98
|
-
#### Methods
|
|
137
|
+
#### Static Methods
|
|
138
|
+
|
|
139
|
+
#### `fromSocket()`
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
static fromSocket(rawSocket: unknown, options?: ClientOptions): WebSocketClient
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Wraps an already-open WebSocket into a `WebSocketClient` in the `"open"` state, ready to send and receive. Intended for server scenarios where a `WebSocketServer` hands you an established connection.
|
|
146
|
+
|
|
147
|
+
**Node.js only.** Throws in browser builds.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { WebSocketServer } from "ws";
|
|
151
|
+
import { WebSocketClient } from "@culpeo/async-ws";
|
|
152
|
+
|
|
153
|
+
const wss = new WebSocketServer({ port: 8080 });
|
|
154
|
+
|
|
155
|
+
wss.on("connection", async (socket) => {
|
|
156
|
+
const client = WebSocketClient.fromSocket(socket);
|
|
157
|
+
|
|
158
|
+
for await (const msg of client) {
|
|
159
|
+
console.log("received:", msg.data);
|
|
160
|
+
await client.send("echo: " + msg.data);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The client takes ownership of the socket lifecycle — calling `close()` will close the underlying socket. Call `fromSocket()` immediately in the `connection` handler to avoid missing messages.
|
|
166
|
+
|
|
167
|
+
Accepts any WebSocket-compatible object (validated structurally, not via `instanceof`), so it works even when multiple copies of the `ws` package are installed.
|
|
168
|
+
|
|
169
|
+
#### Instance Methods
|
|
99
170
|
|
|
100
171
|
#### `connect()`
|
|
101
172
|
|
|
@@ -116,6 +187,8 @@ Rejects when:
|
|
|
116
187
|
|
|
117
188
|
- `protocols?: string | string[]` — WebSocket subprotocols to request
|
|
118
189
|
- `headers?: Record<string, string>` — custom handshake headers in Node.js
|
|
190
|
+
- `timeout?: number` — connection timeout in milliseconds; rejects if the connection is not established within this time
|
|
191
|
+
- `signal?: AbortSignal` — an abort signal to cancel the connection attempt
|
|
119
192
|
|
|
120
193
|
> In browsers, passing `headers` throws because the native WebSocket API does not support custom headers.
|
|
121
194
|
|
|
@@ -182,6 +255,8 @@ Behavior:
|
|
|
182
255
|
interface ConnectOptions {
|
|
183
256
|
protocols?: string | string[];
|
|
184
257
|
headers?: Record<string, string>;
|
|
258
|
+
timeout?: number;
|
|
259
|
+
signal?: AbortSignal;
|
|
185
260
|
}
|
|
186
261
|
```
|
|
187
262
|
|
|
@@ -192,11 +267,24 @@ Connection-time options.
|
|
|
192
267
|
```ts
|
|
193
268
|
interface ClientOptions {
|
|
194
269
|
maxBufferSize?: number;
|
|
270
|
+
keepAlive?: KeepAliveOptions;
|
|
195
271
|
}
|
|
196
272
|
```
|
|
197
273
|
|
|
198
274
|
Client-level configuration.
|
|
199
275
|
|
|
276
|
+
### `KeepAliveOptions`
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
interface KeepAliveOptions {
|
|
280
|
+
interval: number;
|
|
281
|
+
timeout?: number;
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
- `interval` — milliseconds between pings
|
|
286
|
+
- `timeout` — milliseconds to wait for a pong before terminating the connection (default: `interval`)
|
|
287
|
+
|
|
200
288
|
### `WebSocketMessage`
|
|
201
289
|
|
|
202
290
|
```ts
|
|
@@ -276,7 +364,7 @@ This is useful when you want a stream-like consumer loop without manually callin
|
|
|
276
364
|
|
|
277
365
|
All core operations are async and communicate failure by rejecting:
|
|
278
366
|
|
|
279
|
-
- `connect()` rejects on invalid state, connection failure,
|
|
367
|
+
- `connect()` rejects on invalid state, connection failure, early close, timeout, or abort
|
|
280
368
|
- `send()` rejects when called before the socket is open or when the adapter fails to send
|
|
281
369
|
- `receive()` rejects when the client is not in a receivable state and no buffered messages remain
|
|
282
370
|
- `close()` rejects for invalid close codes
|
|
@@ -324,6 +412,77 @@ When the buffer is full:
|
|
|
324
412
|
|
|
325
413
|
This makes buffering predictable for bursty message streams while keeping the public API simple.
|
|
326
414
|
|
|
415
|
+
## Connection Timeout and Abort
|
|
416
|
+
|
|
417
|
+
Use `timeout` to reject if the connection isn't established within a deadline:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
await client.connect("wss://example.com/ws", { timeout: 5000 });
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Use `signal` to cancel a connection attempt at any time:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
setTimeout(() => controller.abort(), 3000);
|
|
428
|
+
|
|
429
|
+
await client.connect("wss://example.com/ws", { signal: controller.signal });
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Both can be combined:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
await client.connect("wss://example.com/ws", {
|
|
436
|
+
timeout: 10000,
|
|
437
|
+
signal: controller.signal,
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Server-Side Socket Adoption (Node.js)
|
|
442
|
+
|
|
443
|
+
Use `fromSocket()` to wrap connections from a `WebSocketServer`:
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
import { WebSocketServer } from "ws";
|
|
447
|
+
import { WebSocketClient } from "@culpeo/async-ws";
|
|
448
|
+
|
|
449
|
+
const wss = new WebSocketServer({ port: 8080 });
|
|
450
|
+
|
|
451
|
+
wss.on("connection", async (socket) => {
|
|
452
|
+
const client = WebSocketClient.fromSocket(socket);
|
|
453
|
+
|
|
454
|
+
const msg = await client.receive();
|
|
455
|
+
await client.send("got: " + msg.data);
|
|
456
|
+
await client.close();
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The same `ClientOptions` are supported:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const client = WebSocketClient.fromSocket(socket, {
|
|
464
|
+
maxBufferSize: 100,
|
|
465
|
+
keepAlive: { interval: 30000 },
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## Keep-Alive (Node.js)
|
|
470
|
+
|
|
471
|
+
Enable automatic ping/pong to detect dead connections:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
const client = new WebSocketClient({
|
|
475
|
+
keepAlive: { interval: 30000, timeout: 5000 },
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await client.connect("wss://example.com/ws");
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- Sends a ping every `interval` milliseconds
|
|
482
|
+
- If no pong is received within `timeout` milliseconds, the connection is terminated
|
|
483
|
+
- `timeout` defaults to `interval` if omitted
|
|
484
|
+
- **Not available in browsers** — the constructor throws if `keepAlive` is configured in a browser environment
|
|
485
|
+
|
|
327
486
|
## Building from Source
|
|
328
487
|
|
|
329
488
|
```bash
|
package/dist/browser/index.js
CHANGED
|
@@ -10,7 +10,17 @@ function socketSend(socket, data) {
|
|
|
10
10
|
return Promise.reject(new Error("WebSocket is not open"));
|
|
11
11
|
}
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
if (ArrayBuffer.isView(data)) {
|
|
14
|
+
if (data.buffer instanceof SharedArrayBuffer) {
|
|
15
|
+
throw new Error("SharedArrayBuffer-backed views are not supported. " +
|
|
16
|
+
"Copy into a regular ArrayBuffer before sending.");
|
|
17
|
+
}
|
|
18
|
+
// Zero-copy: create a Uint8Array view over the same ArrayBuffer
|
|
19
|
+
socket.send(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
socket.send(data);
|
|
23
|
+
}
|
|
14
24
|
return Promise.resolve();
|
|
15
25
|
}
|
|
16
26
|
catch (err) {
|
|
@@ -42,6 +52,19 @@ function attachListeners(socket, onOpen, onMessage, onClose, onError) {
|
|
|
42
52
|
function socketClose(socket, code, reason) {
|
|
43
53
|
socket.close(code, reason);
|
|
44
54
|
}
|
|
55
|
+
function socketTerminate(socket) {
|
|
56
|
+
socket.close();
|
|
57
|
+
}
|
|
58
|
+
function socketPing(_socket) {
|
|
59
|
+
throw new Error("Ping is not supported in browsers.");
|
|
60
|
+
}
|
|
61
|
+
function attachPongListener(_socket, _onPong) {
|
|
62
|
+
return () => { };
|
|
63
|
+
}
|
|
64
|
+
function adoptSocket(_rawSocket) {
|
|
65
|
+
throw new Error("fromSocket() is not supported in browsers. " +
|
|
66
|
+
"Browsers cannot accept server-side WebSocket connections.");
|
|
67
|
+
}
|
|
45
68
|
|
|
46
69
|
/**
|
|
47
70
|
* Imperative WebSocket client that works in both browser and Node.js.
|
|
@@ -62,7 +85,71 @@ class WebSocketClient {
|
|
|
62
85
|
this.terminalError = null;
|
|
63
86
|
this.closeInfo = null;
|
|
64
87
|
this.removeListeners = null;
|
|
88
|
+
this.keepAliveTimer = null;
|
|
89
|
+
this.pongTimer = null;
|
|
90
|
+
this.removePongListener = null;
|
|
91
|
+
this.connectionId = 0;
|
|
65
92
|
this.maxBufferSize = options?.maxBufferSize ?? 0;
|
|
93
|
+
if (options?.keepAlive) {
|
|
94
|
+
{
|
|
95
|
+
throw new Error("keepAlive is not supported in browsers. " +
|
|
96
|
+
"The browser handles WebSocket ping/pong at the protocol level automatically.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
|
|
102
|
+
*
|
|
103
|
+
* Returns a `WebSocketClient` in the "open" state, ready to send/receive.
|
|
104
|
+
* The client takes ownership of the socket lifecycle: calling `close()`
|
|
105
|
+
* will close the underlying socket.
|
|
106
|
+
*
|
|
107
|
+
* **Node.js only.** Throws in browser builds.
|
|
108
|
+
*
|
|
109
|
+
* Call this immediately in the server's `connection` handler to avoid
|
|
110
|
+
* missing messages:
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* wss.on("connection", (socket) => {
|
|
114
|
+
* const client = WebSocketClient.fromSocket(socket);
|
|
115
|
+
* const msg = await client.receive();
|
|
116
|
+
* });
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
static fromSocket(rawSocket, options) {
|
|
120
|
+
const client = new WebSocketClient(options);
|
|
121
|
+
const socket = adoptSocket();
|
|
122
|
+
client.socket = socket;
|
|
123
|
+
setBinaryType(socket);
|
|
124
|
+
client.state = "open";
|
|
125
|
+
const currentConnectionId = ++client.connectionId;
|
|
126
|
+
client.removeListeners = attachListeners(socket,
|
|
127
|
+
// onOpen — already open, won't fire
|
|
128
|
+
() => { },
|
|
129
|
+
// onMessage
|
|
130
|
+
(data, binary) => {
|
|
131
|
+
client.enqueueMessage({ data, binary });
|
|
132
|
+
},
|
|
133
|
+
// onClose
|
|
134
|
+
(code, reason, wasClean) => {
|
|
135
|
+
if (currentConnectionId !== client.connectionId)
|
|
136
|
+
return;
|
|
137
|
+
client.closeInfo = { code, reason, wasClean };
|
|
138
|
+
client.state = "closed";
|
|
139
|
+
client.cleanup();
|
|
140
|
+
if (client.buffer.length === 0) {
|
|
141
|
+
client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
// onError
|
|
145
|
+
(error) => {
|
|
146
|
+
if (currentConnectionId !== client.connectionId)
|
|
147
|
+
return;
|
|
148
|
+
client.terminalError = error;
|
|
149
|
+
client.rejectAllWaiters(error);
|
|
150
|
+
});
|
|
151
|
+
client.startKeepAlive();
|
|
152
|
+
return client;
|
|
66
153
|
}
|
|
67
154
|
/** Current connection state. */
|
|
68
155
|
get readyState() {
|
|
@@ -72,16 +159,41 @@ class WebSocketClient {
|
|
|
72
159
|
get lastCloseInfo() {
|
|
73
160
|
return this.closeInfo;
|
|
74
161
|
}
|
|
162
|
+
/** The negotiated subprotocol, or empty string if none. */
|
|
163
|
+
get protocol() {
|
|
164
|
+
return this.socket?.protocol ?? "";
|
|
165
|
+
}
|
|
166
|
+
/** The URL of the WebSocket connection. */
|
|
167
|
+
get url() {
|
|
168
|
+
return this.socket?.url ?? "";
|
|
169
|
+
}
|
|
170
|
+
/** The number of bytes of data queued for sending. */
|
|
171
|
+
get bufferedAmount() {
|
|
172
|
+
return this.socket?.bufferedAmount ?? 0;
|
|
173
|
+
}
|
|
174
|
+
/** The extensions negotiated by the server. */
|
|
175
|
+
get extensions() {
|
|
176
|
+
return this.socket?.extensions ?? "";
|
|
177
|
+
}
|
|
75
178
|
/**
|
|
76
179
|
* Connect to a WebSocket server.
|
|
77
180
|
* Resolves when the connection is open. Rejects on error.
|
|
78
181
|
*/
|
|
79
182
|
connect(url, options) {
|
|
80
|
-
if (this.state !== "idle" &&
|
|
183
|
+
if (this.state !== "idle" &&
|
|
184
|
+
this.state !== "closed" &&
|
|
185
|
+
this.state !== "errored") {
|
|
81
186
|
return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
|
|
82
187
|
}
|
|
188
|
+
if (options?.timeout !== undefined && options.timeout <= 0) {
|
|
189
|
+
return Promise.reject(new Error("timeout must be greater than 0."));
|
|
190
|
+
}
|
|
191
|
+
if (options?.signal?.aborted) {
|
|
192
|
+
return Promise.reject(new Error("Connection aborted."));
|
|
193
|
+
}
|
|
83
194
|
this.reset();
|
|
84
195
|
this.state = "connecting";
|
|
196
|
+
const currentConnectionId = ++this.connectionId;
|
|
85
197
|
return new Promise((resolve, reject) => {
|
|
86
198
|
try {
|
|
87
199
|
this.socket = createWebSocket(url, options);
|
|
@@ -89,19 +201,63 @@ class WebSocketClient {
|
|
|
89
201
|
}
|
|
90
202
|
catch (err) {
|
|
91
203
|
this.state = "errored";
|
|
92
|
-
this.terminalError =
|
|
204
|
+
this.terminalError =
|
|
205
|
+
err instanceof Error ? err : new Error(String(err));
|
|
93
206
|
reject(this.terminalError);
|
|
94
207
|
return;
|
|
95
208
|
}
|
|
96
209
|
let settled = false;
|
|
210
|
+
let timeoutId = null;
|
|
211
|
+
const settle = (fn) => {
|
|
212
|
+
if (settled)
|
|
213
|
+
return;
|
|
214
|
+
settled = true;
|
|
215
|
+
if (timeoutId !== null) {
|
|
216
|
+
clearTimeout(timeoutId);
|
|
217
|
+
timeoutId = null;
|
|
218
|
+
}
|
|
219
|
+
if (options?.signal) {
|
|
220
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
221
|
+
}
|
|
222
|
+
fn();
|
|
223
|
+
};
|
|
224
|
+
const onAbort = () => {
|
|
225
|
+
settle(() => {
|
|
226
|
+
this.state = "closed";
|
|
227
|
+
this.terminalError = new Error("Connection aborted.");
|
|
228
|
+
if (this.socket) {
|
|
229
|
+
socketTerminate(this.socket);
|
|
230
|
+
}
|
|
231
|
+
// Don't call cleanup() here — socketTerminate() emits
|
|
232
|
+
// error/close asynchronously; let onClose handle cleanup.
|
|
233
|
+
reject(this.terminalError);
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
if (options?.timeout !== undefined) {
|
|
237
|
+
timeoutId = setTimeout(() => {
|
|
238
|
+
settle(() => {
|
|
239
|
+
this.state = "closed";
|
|
240
|
+
this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
|
|
241
|
+
if (this.socket) {
|
|
242
|
+
socketTerminate(this.socket);
|
|
243
|
+
}
|
|
244
|
+
// Don't call cleanup() here — socketTerminate() emits
|
|
245
|
+
// error/close asynchronously; let onClose handle cleanup.
|
|
246
|
+
reject(this.terminalError);
|
|
247
|
+
});
|
|
248
|
+
}, options.timeout);
|
|
249
|
+
}
|
|
250
|
+
if (options?.signal) {
|
|
251
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
252
|
+
}
|
|
97
253
|
this.removeListeners = attachListeners(this.socket,
|
|
98
254
|
// onOpen
|
|
99
255
|
() => {
|
|
100
|
-
|
|
101
|
-
settled = true;
|
|
256
|
+
settle(() => {
|
|
102
257
|
this.state = "open";
|
|
258
|
+
this.startKeepAlive();
|
|
103
259
|
resolve();
|
|
104
|
-
}
|
|
260
|
+
});
|
|
105
261
|
},
|
|
106
262
|
// onMessage
|
|
107
263
|
(data, binary) => {
|
|
@@ -109,14 +265,16 @@ class WebSocketClient {
|
|
|
109
265
|
},
|
|
110
266
|
// onClose
|
|
111
267
|
(code, reason, wasClean) => {
|
|
268
|
+
// Ignore close events from a stale connection (e.g., after
|
|
269
|
+
// timeout/abort triggered a reconnect on the same client).
|
|
270
|
+
if (currentConnectionId !== this.connectionId)
|
|
271
|
+
return;
|
|
112
272
|
this.closeInfo = { code, reason, wasClean };
|
|
113
|
-
this.state;
|
|
114
273
|
this.state = "closed";
|
|
115
274
|
this.cleanup();
|
|
116
|
-
|
|
117
|
-
settled = true;
|
|
275
|
+
settle(() => {
|
|
118
276
|
reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
|
|
119
|
-
}
|
|
277
|
+
});
|
|
120
278
|
// Only reject pending waiters once buffer is drained
|
|
121
279
|
if (this.buffer.length === 0) {
|
|
122
280
|
this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
|
|
@@ -124,11 +282,13 @@ class WebSocketClient {
|
|
|
124
282
|
},
|
|
125
283
|
// onError
|
|
126
284
|
(error) => {
|
|
285
|
+
// Ignore error events from a stale connection.
|
|
286
|
+
if (currentConnectionId !== this.connectionId)
|
|
287
|
+
return;
|
|
127
288
|
this.terminalError = error;
|
|
128
|
-
|
|
129
|
-
settled = true;
|
|
289
|
+
settle(() => {
|
|
130
290
|
reject(error);
|
|
131
|
-
}
|
|
291
|
+
});
|
|
132
292
|
// Reject any pending receive() waiters immediately
|
|
133
293
|
this.rejectAllWaiters(error);
|
|
134
294
|
// Don't call cleanup() here — per spec, a close event always
|
|
@@ -176,7 +336,9 @@ class WebSocketClient {
|
|
|
176
336
|
* Resolves when the close handshake completes.
|
|
177
337
|
*/
|
|
178
338
|
close(code, reason) {
|
|
179
|
-
if (this.state === "closed" ||
|
|
339
|
+
if (this.state === "closed" ||
|
|
340
|
+
this.state === "idle" ||
|
|
341
|
+
this.state === "errored") {
|
|
180
342
|
return Promise.resolve();
|
|
181
343
|
}
|
|
182
344
|
if (!this.socket) {
|
|
@@ -186,7 +348,9 @@ class WebSocketClient {
|
|
|
186
348
|
// Already closing — wait for the close event via a one-shot listener
|
|
187
349
|
return new Promise((resolve) => {
|
|
188
350
|
if (this.socket) {
|
|
189
|
-
this.socket.addEventListener("close", () => resolve(), {
|
|
351
|
+
this.socket.addEventListener("close", () => resolve(), {
|
|
352
|
+
once: true,
|
|
353
|
+
});
|
|
190
354
|
}
|
|
191
355
|
else {
|
|
192
356
|
resolve();
|
|
@@ -251,11 +415,50 @@ class WebSocketClient {
|
|
|
251
415
|
waiter.reject(error);
|
|
252
416
|
}
|
|
253
417
|
}
|
|
418
|
+
startKeepAlive() {
|
|
419
|
+
if (!this.keepAliveConfig || !this.socket)
|
|
420
|
+
return;
|
|
421
|
+
const { interval, timeout } = this.keepAliveConfig;
|
|
422
|
+
const pongTimeout = timeout ?? interval;
|
|
423
|
+
this.removePongListener = attachPongListener(this.socket);
|
|
424
|
+
this.keepAliveTimer = setInterval(() => {
|
|
425
|
+
if (this.state !== "open" || !this.socket)
|
|
426
|
+
return;
|
|
427
|
+
socketPing(this.socket);
|
|
428
|
+
// Clear any existing pong watchdog before starting a new one
|
|
429
|
+
// to prevent multiple timers when timeout > interval.
|
|
430
|
+
if (this.pongTimer !== null) {
|
|
431
|
+
clearTimeout(this.pongTimer);
|
|
432
|
+
}
|
|
433
|
+
this.pongTimer = setTimeout(() => {
|
|
434
|
+
if (this.state === "open" && this.socket) {
|
|
435
|
+
this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
|
|
436
|
+
socketTerminate(this.socket);
|
|
437
|
+
}
|
|
438
|
+
}, pongTimeout);
|
|
439
|
+
}, interval);
|
|
440
|
+
}
|
|
441
|
+
stopKeepAlive() {
|
|
442
|
+
if (this.keepAliveTimer !== null) {
|
|
443
|
+
clearInterval(this.keepAliveTimer);
|
|
444
|
+
this.keepAliveTimer = null;
|
|
445
|
+
}
|
|
446
|
+
if (this.pongTimer !== null) {
|
|
447
|
+
clearTimeout(this.pongTimer);
|
|
448
|
+
this.pongTimer = null;
|
|
449
|
+
}
|
|
450
|
+
if (this.removePongListener) {
|
|
451
|
+
this.removePongListener();
|
|
452
|
+
this.removePongListener = null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
254
455
|
cleanup() {
|
|
456
|
+
this.stopKeepAlive();
|
|
255
457
|
if (this.removeListeners) {
|
|
256
458
|
this.removeListeners();
|
|
257
459
|
this.removeListeners = null;
|
|
258
460
|
}
|
|
461
|
+
this.socket = null;
|
|
259
462
|
}
|
|
260
463
|
reset() {
|
|
261
464
|
this.socket = null;
|
|
@@ -264,6 +467,7 @@ class WebSocketClient {
|
|
|
264
467
|
this.terminalError = null;
|
|
265
468
|
this.closeInfo = null;
|
|
266
469
|
this.removeListeners = null;
|
|
470
|
+
this.stopKeepAlive();
|
|
267
471
|
}
|
|
268
472
|
}
|
|
269
473
|
|