@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 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); // string | ArrayBuffer
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, or early close
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
@@ -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
- socket.send(data);
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" && this.state !== "closed" && this.state !== "errored") {
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 = err instanceof Error ? err : new Error(String(err));
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
- if (!settled) {
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
- if (!settled) {
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
- if (!settled) {
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" || this.state === "idle" || this.state === "errored") {
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(), { once: true });
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