@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 CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 6b68e4c: ### Connect timeout and abort signal
8
+
9
+ `connect()` now accepts `timeout` (milliseconds) and `signal` (AbortSignal) options to cancel connection attempts.
10
+
11
+ ### Keep-alive ping/pong (Node.js)
12
+
13
+ New `keepAlive` constructor option sends periodic pings and terminates the connection if no pong is received. Not available in browsers.
14
+
15
+ ### Exposed WebSocket properties
16
+
17
+ Added read-only `protocol`, `url`, `bufferedAmount`, and `extensions` properties that delegate to the underlying socket.
18
+
19
+ ## 0.2.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 9453a7c: Initial release
24
+ - Cross-platform WebSocket client for Node.js and browsers
25
+ - Promise-based `connect()`, `send()`, `receive()`, `close()` API
26
+ - Async iteration with `for await...of`
27
+ - Message buffering with configurable `maxBufferSize`
28
+ - Clean close info tracking via `lastCloseInfo`
29
+ - TypeScript-first with bundled type definitions
30
+
3
31
  ## 0.1.0
4
32
 
5
33
  Initial release.
package/README.md CHANGED
@@ -15,6 +15,9 @@
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
+ - Exposed WebSocket properties (`protocol`, `url`, `bufferedAmount`, `extensions`)
18
21
  - Clean close information via `lastCloseInfo`
19
22
  - TypeScript-first with bundled type definitions
20
23
  - Binary and text message support
@@ -45,7 +48,7 @@ await client.connect("wss://echo.websocket.events");
45
48
  await client.send("hello");
46
49
 
47
50
  const message = await client.receive();
48
- console.log(message.data); // string | ArrayBuffer
51
+ console.log(message.data); // string | ArrayBuffer
49
52
  console.log(message.binary); // boolean
50
53
 
51
54
  await client.close();
@@ -69,6 +72,9 @@ Creates a new client instance.
69
72
  - Maximum number of incoming messages to keep buffered before they are consumed
70
73
  - Default: `0` (unlimited)
71
74
  - When the limit is reached, the oldest buffered message is dropped
75
+ - `keepAlive?: KeepAliveOptions`
76
+ - Enables automatic ping/pong keep-alive (Node.js only)
77
+ - Throws if used in a browser environment
72
78
 
73
79
  #### Properties
74
80
 
@@ -87,6 +93,38 @@ Returns the current client state:
87
93
  - `"closed"`
88
94
  - `"errored"`
89
95
 
96
+ #### `client.protocol`
97
+
98
+ ```ts
99
+ readonly protocol: string
100
+ ```
101
+
102
+ Returns the negotiated subprotocol, or `""` when not connected.
103
+
104
+ #### `client.url`
105
+
106
+ ```ts
107
+ readonly url: string
108
+ ```
109
+
110
+ Returns the URL of the WebSocket connection, or `""` when not connected.
111
+
112
+ #### `client.bufferedAmount`
113
+
114
+ ```ts
115
+ readonly bufferedAmount: number
116
+ ```
117
+
118
+ Returns the number of bytes queued for transmission, or `0` when not connected.
119
+
120
+ #### `client.extensions`
121
+
122
+ ```ts
123
+ readonly extensions: string
124
+ ```
125
+
126
+ Returns the negotiated extensions, or `""` when not connected.
127
+
90
128
  #### `client.lastCloseInfo`
91
129
 
92
130
  ```ts
@@ -116,6 +154,8 @@ Rejects when:
116
154
 
117
155
  - `protocols?: string | string[]` — WebSocket subprotocols to request
118
156
  - `headers?: Record<string, string>` — custom handshake headers in Node.js
157
+ - `timeout?: number` — connection timeout in milliseconds; rejects if the connection is not established within this time
158
+ - `signal?: AbortSignal` — an abort signal to cancel the connection attempt
119
159
 
120
160
  > In browsers, passing `headers` throws because the native WebSocket API does not support custom headers.
121
161
 
@@ -182,6 +222,8 @@ Behavior:
182
222
  interface ConnectOptions {
183
223
  protocols?: string | string[];
184
224
  headers?: Record<string, string>;
225
+ timeout?: number;
226
+ signal?: AbortSignal;
185
227
  }
186
228
  ```
187
229
 
@@ -192,11 +234,24 @@ Connection-time options.
192
234
  ```ts
193
235
  interface ClientOptions {
194
236
  maxBufferSize?: number;
237
+ keepAlive?: KeepAliveOptions;
195
238
  }
196
239
  ```
197
240
 
198
241
  Client-level configuration.
199
242
 
243
+ ### `KeepAliveOptions`
244
+
245
+ ```ts
246
+ interface KeepAliveOptions {
247
+ interval: number;
248
+ timeout?: number;
249
+ }
250
+ ```
251
+
252
+ - `interval` — milliseconds between pings
253
+ - `timeout` — milliseconds to wait for a pong before terminating the connection (default: `interval`)
254
+
200
255
  ### `WebSocketMessage`
201
256
 
202
257
  ```ts
@@ -276,7 +331,7 @@ This is useful when you want a stream-like consumer loop without manually callin
276
331
 
277
332
  All core operations are async and communicate failure by rejecting:
278
333
 
279
- - `connect()` rejects on invalid state, connection failure, or early close
334
+ - `connect()` rejects on invalid state, connection failure, early close, timeout, or abort
280
335
  - `send()` rejects when called before the socket is open or when the adapter fails to send
281
336
  - `receive()` rejects when the client is not in a receivable state and no buffered messages remain
282
337
  - `close()` rejects for invalid close codes
@@ -324,6 +379,49 @@ When the buffer is full:
324
379
 
325
380
  This makes buffering predictable for bursty message streams while keeping the public API simple.
326
381
 
382
+ ## Connection Timeout and Abort
383
+
384
+ Use `timeout` to reject if the connection isn't established within a deadline:
385
+
386
+ ```ts
387
+ await client.connect("wss://example.com/ws", { timeout: 5000 });
388
+ ```
389
+
390
+ Use `signal` to cancel a connection attempt at any time:
391
+
392
+ ```ts
393
+ const controller = new AbortController();
394
+ setTimeout(() => controller.abort(), 3000);
395
+
396
+ await client.connect("wss://example.com/ws", { signal: controller.signal });
397
+ ```
398
+
399
+ Both can be combined:
400
+
401
+ ```ts
402
+ await client.connect("wss://example.com/ws", {
403
+ timeout: 10000,
404
+ signal: controller.signal,
405
+ });
406
+ ```
407
+
408
+ ## Keep-Alive (Node.js)
409
+
410
+ Enable automatic ping/pong to detect dead connections:
411
+
412
+ ```ts
413
+ const client = new WebSocketClient({
414
+ keepAlive: { interval: 30000, timeout: 5000 },
415
+ });
416
+
417
+ await client.connect("wss://example.com/ws");
418
+ ```
419
+
420
+ - Sends a ping every `interval` milliseconds
421
+ - If no pong is received within `timeout` milliseconds, the connection is terminated
422
+ - `timeout` defaults to `interval` if omitted
423
+ - **Not available in browsers** — the constructor throws if `keepAlive` is configured in a browser environment
424
+
327
425
  ## Building from Source
328
426
 
329
427
  ```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,15 @@ 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
+ }
45
64
 
46
65
  /**
47
66
  * Imperative WebSocket client that works in both browser and Node.js.
@@ -62,7 +81,17 @@ class WebSocketClient {
62
81
  this.terminalError = null;
63
82
  this.closeInfo = null;
64
83
  this.removeListeners = null;
84
+ this.keepAliveTimer = null;
85
+ this.pongTimer = null;
86
+ this.removePongListener = null;
87
+ this.connectionId = 0;
65
88
  this.maxBufferSize = options?.maxBufferSize ?? 0;
89
+ if (options?.keepAlive) {
90
+ {
91
+ throw new Error("keepAlive is not supported in browsers. " +
92
+ "The browser handles WebSocket ping/pong at the protocol level automatically.");
93
+ }
94
+ }
66
95
  }
67
96
  /** Current connection state. */
68
97
  get readyState() {
@@ -72,16 +101,41 @@ class WebSocketClient {
72
101
  get lastCloseInfo() {
73
102
  return this.closeInfo;
74
103
  }
104
+ /** The negotiated subprotocol, or empty string if none. */
105
+ get protocol() {
106
+ return this.socket?.protocol ?? "";
107
+ }
108
+ /** The URL of the WebSocket connection. */
109
+ get url() {
110
+ return this.socket?.url ?? "";
111
+ }
112
+ /** The number of bytes of data queued for sending. */
113
+ get bufferedAmount() {
114
+ return this.socket?.bufferedAmount ?? 0;
115
+ }
116
+ /** The extensions negotiated by the server. */
117
+ get extensions() {
118
+ return this.socket?.extensions ?? "";
119
+ }
75
120
  /**
76
121
  * Connect to a WebSocket server.
77
122
  * Resolves when the connection is open. Rejects on error.
78
123
  */
79
124
  connect(url, options) {
80
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
125
+ if (this.state !== "idle" &&
126
+ this.state !== "closed" &&
127
+ this.state !== "errored") {
81
128
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
82
129
  }
130
+ if (options?.timeout !== undefined && options.timeout <= 0) {
131
+ return Promise.reject(new Error("timeout must be greater than 0."));
132
+ }
133
+ if (options?.signal?.aborted) {
134
+ return Promise.reject(new Error("Connection aborted."));
135
+ }
83
136
  this.reset();
84
137
  this.state = "connecting";
138
+ const currentConnectionId = ++this.connectionId;
85
139
  return new Promise((resolve, reject) => {
86
140
  try {
87
141
  this.socket = createWebSocket(url, options);
@@ -89,19 +143,63 @@ class WebSocketClient {
89
143
  }
90
144
  catch (err) {
91
145
  this.state = "errored";
92
- this.terminalError = err instanceof Error ? err : new Error(String(err));
146
+ this.terminalError =
147
+ err instanceof Error ? err : new Error(String(err));
93
148
  reject(this.terminalError);
94
149
  return;
95
150
  }
96
151
  let settled = false;
152
+ let timeoutId = null;
153
+ const settle = (fn) => {
154
+ if (settled)
155
+ return;
156
+ settled = true;
157
+ if (timeoutId !== null) {
158
+ clearTimeout(timeoutId);
159
+ timeoutId = null;
160
+ }
161
+ if (options?.signal) {
162
+ options.signal.removeEventListener("abort", onAbort);
163
+ }
164
+ fn();
165
+ };
166
+ const onAbort = () => {
167
+ settle(() => {
168
+ this.state = "closed";
169
+ this.terminalError = new Error("Connection aborted.");
170
+ if (this.socket) {
171
+ socketTerminate(this.socket);
172
+ }
173
+ // Don't call cleanup() here — socketTerminate() emits
174
+ // error/close asynchronously; let onClose handle cleanup.
175
+ reject(this.terminalError);
176
+ });
177
+ };
178
+ if (options?.timeout !== undefined) {
179
+ timeoutId = setTimeout(() => {
180
+ settle(() => {
181
+ this.state = "closed";
182
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
183
+ if (this.socket) {
184
+ socketTerminate(this.socket);
185
+ }
186
+ // Don't call cleanup() here — socketTerminate() emits
187
+ // error/close asynchronously; let onClose handle cleanup.
188
+ reject(this.terminalError);
189
+ });
190
+ }, options.timeout);
191
+ }
192
+ if (options?.signal) {
193
+ options.signal.addEventListener("abort", onAbort, { once: true });
194
+ }
97
195
  this.removeListeners = attachListeners(this.socket,
98
196
  // onOpen
99
197
  () => {
100
- if (!settled) {
101
- settled = true;
198
+ settle(() => {
102
199
  this.state = "open";
200
+ this.startKeepAlive();
103
201
  resolve();
104
- }
202
+ });
105
203
  },
106
204
  // onMessage
107
205
  (data, binary) => {
@@ -109,14 +207,16 @@ class WebSocketClient {
109
207
  },
110
208
  // onClose
111
209
  (code, reason, wasClean) => {
210
+ // Ignore close events from a stale connection (e.g., after
211
+ // timeout/abort triggered a reconnect on the same client).
212
+ if (currentConnectionId !== this.connectionId)
213
+ return;
112
214
  this.closeInfo = { code, reason, wasClean };
113
- this.state;
114
215
  this.state = "closed";
115
216
  this.cleanup();
116
- if (!settled) {
117
- settled = true;
217
+ settle(() => {
118
218
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
119
- }
219
+ });
120
220
  // Only reject pending waiters once buffer is drained
121
221
  if (this.buffer.length === 0) {
122
222
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -124,11 +224,13 @@ class WebSocketClient {
124
224
  },
125
225
  // onError
126
226
  (error) => {
227
+ // Ignore error events from a stale connection.
228
+ if (currentConnectionId !== this.connectionId)
229
+ return;
127
230
  this.terminalError = error;
128
- if (!settled) {
129
- settled = true;
231
+ settle(() => {
130
232
  reject(error);
131
- }
233
+ });
132
234
  // Reject any pending receive() waiters immediately
133
235
  this.rejectAllWaiters(error);
134
236
  // Don't call cleanup() here — per spec, a close event always
@@ -176,7 +278,9 @@ class WebSocketClient {
176
278
  * Resolves when the close handshake completes.
177
279
  */
178
280
  close(code, reason) {
179
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
281
+ if (this.state === "closed" ||
282
+ this.state === "idle" ||
283
+ this.state === "errored") {
180
284
  return Promise.resolve();
181
285
  }
182
286
  if (!this.socket) {
@@ -186,7 +290,9 @@ class WebSocketClient {
186
290
  // Already closing — wait for the close event via a one-shot listener
187
291
  return new Promise((resolve) => {
188
292
  if (this.socket) {
189
- this.socket.addEventListener("close", () => resolve(), { once: true });
293
+ this.socket.addEventListener("close", () => resolve(), {
294
+ once: true,
295
+ });
190
296
  }
191
297
  else {
192
298
  resolve();
@@ -251,11 +357,50 @@ class WebSocketClient {
251
357
  waiter.reject(error);
252
358
  }
253
359
  }
360
+ startKeepAlive() {
361
+ if (!this.keepAliveConfig || !this.socket)
362
+ return;
363
+ const { interval, timeout } = this.keepAliveConfig;
364
+ const pongTimeout = timeout ?? interval;
365
+ this.removePongListener = attachPongListener(this.socket);
366
+ this.keepAliveTimer = setInterval(() => {
367
+ if (this.state !== "open" || !this.socket)
368
+ return;
369
+ socketPing(this.socket);
370
+ // Clear any existing pong watchdog before starting a new one
371
+ // to prevent multiple timers when timeout > interval.
372
+ if (this.pongTimer !== null) {
373
+ clearTimeout(this.pongTimer);
374
+ }
375
+ this.pongTimer = setTimeout(() => {
376
+ if (this.state === "open" && this.socket) {
377
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
378
+ socketTerminate(this.socket);
379
+ }
380
+ }, pongTimeout);
381
+ }, interval);
382
+ }
383
+ stopKeepAlive() {
384
+ if (this.keepAliveTimer !== null) {
385
+ clearInterval(this.keepAliveTimer);
386
+ this.keepAliveTimer = null;
387
+ }
388
+ if (this.pongTimer !== null) {
389
+ clearTimeout(this.pongTimer);
390
+ this.pongTimer = null;
391
+ }
392
+ if (this.removePongListener) {
393
+ this.removePongListener();
394
+ this.removePongListener = null;
395
+ }
396
+ }
254
397
  cleanup() {
398
+ this.stopKeepAlive();
255
399
  if (this.removeListeners) {
256
400
  this.removeListeners();
257
401
  this.removeListeners = null;
258
402
  }
403
+ this.socket = null;
259
404
  }
260
405
  reset() {
261
406
  this.socket = null;
@@ -264,6 +409,7 @@ class WebSocketClient {
264
409
  this.terminalError = null;
265
410
  this.closeInfo = null;
266
411
  this.removeListeners = null;
412
+ this.stopKeepAlive();
267
413
  }
268
414
  }
269
415