@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.
@@ -13,7 +13,17 @@ var AsyncWS = (function (exports) {
13
13
  return Promise.reject(new Error("WebSocket is not open"));
14
14
  }
15
15
  try {
16
- socket.send(data);
16
+ if (ArrayBuffer.isView(data)) {
17
+ if (data.buffer instanceof SharedArrayBuffer) {
18
+ throw new Error("SharedArrayBuffer-backed views are not supported. " +
19
+ "Copy into a regular ArrayBuffer before sending.");
20
+ }
21
+ // Zero-copy: create a Uint8Array view over the same ArrayBuffer
22
+ socket.send(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
23
+ }
24
+ else {
25
+ socket.send(data);
26
+ }
17
27
  return Promise.resolve();
18
28
  }
19
29
  catch (err) {
@@ -45,6 +55,19 @@ var AsyncWS = (function (exports) {
45
55
  function socketClose(socket, code, reason) {
46
56
  socket.close(code, reason);
47
57
  }
58
+ function socketTerminate(socket) {
59
+ socket.close();
60
+ }
61
+ function socketPing(_socket) {
62
+ throw new Error("Ping is not supported in browsers.");
63
+ }
64
+ function attachPongListener(_socket, _onPong) {
65
+ return () => { };
66
+ }
67
+ function adoptSocket(_rawSocket) {
68
+ throw new Error("fromSocket() is not supported in browsers. " +
69
+ "Browsers cannot accept server-side WebSocket connections.");
70
+ }
48
71
 
49
72
  /**
50
73
  * Imperative WebSocket client that works in both browser and Node.js.
@@ -65,7 +88,71 @@ var AsyncWS = (function (exports) {
65
88
  this.terminalError = null;
66
89
  this.closeInfo = null;
67
90
  this.removeListeners = null;
91
+ this.keepAliveTimer = null;
92
+ this.pongTimer = null;
93
+ this.removePongListener = null;
94
+ this.connectionId = 0;
68
95
  this.maxBufferSize = options?.maxBufferSize ?? 0;
96
+ if (options?.keepAlive) {
97
+ {
98
+ throw new Error("keepAlive is not supported in browsers. " +
99
+ "The browser handles WebSocket ping/pong at the protocol level automatically.");
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
105
+ *
106
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
107
+ * The client takes ownership of the socket lifecycle: calling `close()`
108
+ * will close the underlying socket.
109
+ *
110
+ * **Node.js only.** Throws in browser builds.
111
+ *
112
+ * Call this immediately in the server's `connection` handler to avoid
113
+ * missing messages:
114
+ *
115
+ * ```ts
116
+ * wss.on("connection", (socket) => {
117
+ * const client = WebSocketClient.fromSocket(socket);
118
+ * const msg = await client.receive();
119
+ * });
120
+ * ```
121
+ */
122
+ static fromSocket(rawSocket, options) {
123
+ const client = new WebSocketClient(options);
124
+ const socket = adoptSocket();
125
+ client.socket = socket;
126
+ setBinaryType(socket);
127
+ client.state = "open";
128
+ const currentConnectionId = ++client.connectionId;
129
+ client.removeListeners = attachListeners(socket,
130
+ // onOpen — already open, won't fire
131
+ () => { },
132
+ // onMessage
133
+ (data, binary) => {
134
+ client.enqueueMessage({ data, binary });
135
+ },
136
+ // onClose
137
+ (code, reason, wasClean) => {
138
+ if (currentConnectionId !== client.connectionId)
139
+ return;
140
+ client.closeInfo = { code, reason, wasClean };
141
+ client.state = "closed";
142
+ client.cleanup();
143
+ if (client.buffer.length === 0) {
144
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
145
+ }
146
+ },
147
+ // onError
148
+ (error) => {
149
+ if (currentConnectionId !== client.connectionId)
150
+ return;
151
+ client.terminalError = error;
152
+ client.rejectAllWaiters(error);
153
+ });
154
+ client.startKeepAlive();
155
+ return client;
69
156
  }
70
157
  /** Current connection state. */
71
158
  get readyState() {
@@ -75,16 +162,41 @@ var AsyncWS = (function (exports) {
75
162
  get lastCloseInfo() {
76
163
  return this.closeInfo;
77
164
  }
165
+ /** The negotiated subprotocol, or empty string if none. */
166
+ get protocol() {
167
+ return this.socket?.protocol ?? "";
168
+ }
169
+ /** The URL of the WebSocket connection. */
170
+ get url() {
171
+ return this.socket?.url ?? "";
172
+ }
173
+ /** The number of bytes of data queued for sending. */
174
+ get bufferedAmount() {
175
+ return this.socket?.bufferedAmount ?? 0;
176
+ }
177
+ /** The extensions negotiated by the server. */
178
+ get extensions() {
179
+ return this.socket?.extensions ?? "";
180
+ }
78
181
  /**
79
182
  * Connect to a WebSocket server.
80
183
  * Resolves when the connection is open. Rejects on error.
81
184
  */
82
185
  connect(url, options) {
83
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
186
+ if (this.state !== "idle" &&
187
+ this.state !== "closed" &&
188
+ this.state !== "errored") {
84
189
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
85
190
  }
191
+ if (options?.timeout !== undefined && options.timeout <= 0) {
192
+ return Promise.reject(new Error("timeout must be greater than 0."));
193
+ }
194
+ if (options?.signal?.aborted) {
195
+ return Promise.reject(new Error("Connection aborted."));
196
+ }
86
197
  this.reset();
87
198
  this.state = "connecting";
199
+ const currentConnectionId = ++this.connectionId;
88
200
  return new Promise((resolve, reject) => {
89
201
  try {
90
202
  this.socket = createWebSocket(url, options);
@@ -92,19 +204,63 @@ var AsyncWS = (function (exports) {
92
204
  }
93
205
  catch (err) {
94
206
  this.state = "errored";
95
- this.terminalError = err instanceof Error ? err : new Error(String(err));
207
+ this.terminalError =
208
+ err instanceof Error ? err : new Error(String(err));
96
209
  reject(this.terminalError);
97
210
  return;
98
211
  }
99
212
  let settled = false;
213
+ let timeoutId = null;
214
+ const settle = (fn) => {
215
+ if (settled)
216
+ return;
217
+ settled = true;
218
+ if (timeoutId !== null) {
219
+ clearTimeout(timeoutId);
220
+ timeoutId = null;
221
+ }
222
+ if (options?.signal) {
223
+ options.signal.removeEventListener("abort", onAbort);
224
+ }
225
+ fn();
226
+ };
227
+ const onAbort = () => {
228
+ settle(() => {
229
+ this.state = "closed";
230
+ this.terminalError = new Error("Connection aborted.");
231
+ if (this.socket) {
232
+ socketTerminate(this.socket);
233
+ }
234
+ // Don't call cleanup() here — socketTerminate() emits
235
+ // error/close asynchronously; let onClose handle cleanup.
236
+ reject(this.terminalError);
237
+ });
238
+ };
239
+ if (options?.timeout !== undefined) {
240
+ timeoutId = setTimeout(() => {
241
+ settle(() => {
242
+ this.state = "closed";
243
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
244
+ if (this.socket) {
245
+ socketTerminate(this.socket);
246
+ }
247
+ // Don't call cleanup() here — socketTerminate() emits
248
+ // error/close asynchronously; let onClose handle cleanup.
249
+ reject(this.terminalError);
250
+ });
251
+ }, options.timeout);
252
+ }
253
+ if (options?.signal) {
254
+ options.signal.addEventListener("abort", onAbort, { once: true });
255
+ }
100
256
  this.removeListeners = attachListeners(this.socket,
101
257
  // onOpen
102
258
  () => {
103
- if (!settled) {
104
- settled = true;
259
+ settle(() => {
105
260
  this.state = "open";
261
+ this.startKeepAlive();
106
262
  resolve();
107
- }
263
+ });
108
264
  },
109
265
  // onMessage
110
266
  (data, binary) => {
@@ -112,14 +268,16 @@ var AsyncWS = (function (exports) {
112
268
  },
113
269
  // onClose
114
270
  (code, reason, wasClean) => {
271
+ // Ignore close events from a stale connection (e.g., after
272
+ // timeout/abort triggered a reconnect on the same client).
273
+ if (currentConnectionId !== this.connectionId)
274
+ return;
115
275
  this.closeInfo = { code, reason, wasClean };
116
- this.state;
117
276
  this.state = "closed";
118
277
  this.cleanup();
119
- if (!settled) {
120
- settled = true;
278
+ settle(() => {
121
279
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
122
- }
280
+ });
123
281
  // Only reject pending waiters once buffer is drained
124
282
  if (this.buffer.length === 0) {
125
283
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -127,11 +285,13 @@ var AsyncWS = (function (exports) {
127
285
  },
128
286
  // onError
129
287
  (error) => {
288
+ // Ignore error events from a stale connection.
289
+ if (currentConnectionId !== this.connectionId)
290
+ return;
130
291
  this.terminalError = error;
131
- if (!settled) {
132
- settled = true;
292
+ settle(() => {
133
293
  reject(error);
134
- }
294
+ });
135
295
  // Reject any pending receive() waiters immediately
136
296
  this.rejectAllWaiters(error);
137
297
  // Don't call cleanup() here — per spec, a close event always
@@ -179,7 +339,9 @@ var AsyncWS = (function (exports) {
179
339
  * Resolves when the close handshake completes.
180
340
  */
181
341
  close(code, reason) {
182
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
342
+ if (this.state === "closed" ||
343
+ this.state === "idle" ||
344
+ this.state === "errored") {
183
345
  return Promise.resolve();
184
346
  }
185
347
  if (!this.socket) {
@@ -189,7 +351,9 @@ var AsyncWS = (function (exports) {
189
351
  // Already closing — wait for the close event via a one-shot listener
190
352
  return new Promise((resolve) => {
191
353
  if (this.socket) {
192
- this.socket.addEventListener("close", () => resolve(), { once: true });
354
+ this.socket.addEventListener("close", () => resolve(), {
355
+ once: true,
356
+ });
193
357
  }
194
358
  else {
195
359
  resolve();
@@ -254,11 +418,50 @@ var AsyncWS = (function (exports) {
254
418
  waiter.reject(error);
255
419
  }
256
420
  }
421
+ startKeepAlive() {
422
+ if (!this.keepAliveConfig || !this.socket)
423
+ return;
424
+ const { interval, timeout } = this.keepAliveConfig;
425
+ const pongTimeout = timeout ?? interval;
426
+ this.removePongListener = attachPongListener(this.socket);
427
+ this.keepAliveTimer = setInterval(() => {
428
+ if (this.state !== "open" || !this.socket)
429
+ return;
430
+ socketPing(this.socket);
431
+ // Clear any existing pong watchdog before starting a new one
432
+ // to prevent multiple timers when timeout > interval.
433
+ if (this.pongTimer !== null) {
434
+ clearTimeout(this.pongTimer);
435
+ }
436
+ this.pongTimer = setTimeout(() => {
437
+ if (this.state === "open" && this.socket) {
438
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
439
+ socketTerminate(this.socket);
440
+ }
441
+ }, pongTimeout);
442
+ }, interval);
443
+ }
444
+ stopKeepAlive() {
445
+ if (this.keepAliveTimer !== null) {
446
+ clearInterval(this.keepAliveTimer);
447
+ this.keepAliveTimer = null;
448
+ }
449
+ if (this.pongTimer !== null) {
450
+ clearTimeout(this.pongTimer);
451
+ this.pongTimer = null;
452
+ }
453
+ if (this.removePongListener) {
454
+ this.removePongListener();
455
+ this.removePongListener = null;
456
+ }
457
+ }
257
458
  cleanup() {
459
+ this.stopKeepAlive();
258
460
  if (this.removeListeners) {
259
461
  this.removeListeners();
260
462
  this.removeListeners = null;
261
463
  }
464
+ this.socket = null;
262
465
  }
263
466
  reset() {
264
467
  this.socket = null;
@@ -267,6 +470,7 @@ var AsyncWS = (function (exports) {
267
470
  this.terminalError = null;
268
471
  this.closeInfo = null;
269
472
  this.removeListeners = null;
473
+ this.stopKeepAlive();
270
474
  }
271
475
  }
272
476
 
package/dist/index.d.ts CHANGED
@@ -4,6 +4,17 @@ interface ConnectOptions {
4
4
  protocols?: string | string[];
5
5
  /** HTTP headers to send during the handshake (Node.js only). */
6
6
  headers?: Record<string, string>;
7
+ /**
8
+ * Connection timeout in milliseconds.
9
+ * If the connection is not established within this time, it is aborted.
10
+ * Default: no timeout.
11
+ */
12
+ timeout?: number;
13
+ /**
14
+ * An AbortSignal to cancel the connection attempt.
15
+ * If aborted, the connection is terminated and connect() rejects.
16
+ */
17
+ signal?: AbortSignal;
7
18
  }
8
19
  /** Represents a received WebSocket message. */
9
20
  interface WebSocketMessage {
@@ -29,6 +40,22 @@ interface ClientOptions {
29
40
  * Set to 0 for unlimited. Defaults to 0 (unlimited).
30
41
  */
31
42
  maxBufferSize?: number;
43
+ /**
44
+ * Enable automatic keep-alive pings (Node.js only).
45
+ * In browsers, this option throws because the browser handles
46
+ * ping/pong at the protocol level automatically.
47
+ */
48
+ keepAlive?: KeepAliveOptions;
49
+ }
50
+ /** Configuration for automatic keep-alive pings (Node.js only). */
51
+ interface KeepAliveOptions {
52
+ /** Interval in milliseconds between pings. Must be > 0. */
53
+ interval: number;
54
+ /**
55
+ * Time in milliseconds to wait for a pong response before
56
+ * terminating the connection. Default: same as interval.
57
+ */
58
+ timeout?: number;
32
59
  }
33
60
  type WebSocketState = "idle" | "connecting" | "open" | "closing" | "closed" | "errored";
34
61
 
@@ -50,12 +77,45 @@ declare class WebSocketClient {
50
77
  private terminalError;
51
78
  private closeInfo;
52
79
  private removeListeners;
80
+ private keepAliveTimer;
81
+ private pongTimer;
82
+ private removePongListener;
83
+ private connectionId;
53
84
  private readonly maxBufferSize;
85
+ private readonly keepAliveConfig;
54
86
  constructor(options?: ClientOptions);
87
+ /**
88
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
89
+ *
90
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
91
+ * The client takes ownership of the socket lifecycle: calling `close()`
92
+ * will close the underlying socket.
93
+ *
94
+ * **Node.js only.** Throws in browser builds.
95
+ *
96
+ * Call this immediately in the server's `connection` handler to avoid
97
+ * missing messages:
98
+ *
99
+ * ```ts
100
+ * wss.on("connection", (socket) => {
101
+ * const client = WebSocketClient.fromSocket(socket);
102
+ * const msg = await client.receive();
103
+ * });
104
+ * ```
105
+ */
106
+ static fromSocket(rawSocket: unknown, options?: ClientOptions): WebSocketClient;
55
107
  /** Current connection state. */
56
108
  get readyState(): WebSocketState;
57
109
  /** Close info from the last close event, if any. */
58
110
  get lastCloseInfo(): WebSocketCloseInfo | null;
111
+ /** The negotiated subprotocol, or empty string if none. */
112
+ get protocol(): string;
113
+ /** The URL of the WebSocket connection. */
114
+ get url(): string;
115
+ /** The number of bytes of data queued for sending. */
116
+ get bufferedAmount(): number;
117
+ /** The extensions negotiated by the server. */
118
+ get extensions(): string;
59
119
  /**
60
120
  * Connect to a WebSocket server.
61
121
  * Resolves when the connection is open. Rejects on error.
@@ -89,9 +149,11 @@ declare class WebSocketClient {
89
149
  [Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage>;
90
150
  private enqueueMessage;
91
151
  private rejectAllWaiters;
152
+ private startKeepAlive;
153
+ private stopKeepAlive;
92
154
  private cleanup;
93
155
  private reset;
94
156
  }
95
157
 
96
158
  export { WebSocketClient };
97
- export type { ClientOptions, ConnectOptions, WebSocketCloseInfo, WebSocketMessage, WebSocketState };
159
+ export type { ClientOptions, ConnectOptions, KeepAliveOptions, WebSocketCloseInfo, WebSocketMessage, WebSocketState };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@culpeo/async-ws",
3
- "version": "0.2.0",
3
+ "version": "1.1.0",
4
4
  "description": "Promise-first WebSocket client for Node.js and browsers",
5
5
  "author": "Gerardo Lecaros",
6
6
  "license": "MIT",
@@ -58,6 +58,8 @@
58
58
  "build": "rollup -c",
59
59
  "clean": "rimraf dist",
60
60
  "typecheck": "tsc --noEmit",
61
+ "format": "prettier --write .",
62
+ "format:check": "prettier --check .",
61
63
  "changeset": "changeset",
62
64
  "version": "changeset version",
63
65
  "release": "changeset publish",
@@ -68,17 +70,18 @@
68
70
  },
69
71
  "devDependencies": {
70
72
  "@changesets/cli": "^2.31.0",
71
- "@rollup/plugin-alias": "^5.1.1",
72
- "@rollup/plugin-commonjs": "^28.0.0",
73
- "@rollup/plugin-node-resolve": "^15.3.0",
73
+ "@rollup/plugin-alias": "^6.0.0",
74
+ "@rollup/plugin-commonjs": "^29.0.3",
75
+ "@rollup/plugin-node-resolve": "^16.0.3",
74
76
  "@rollup/plugin-typescript": "^12.1.0",
75
77
  "@types/ws": "^8.5.12",
76
78
  "@vitest/browser-playwright": "^4.1.8",
79
+ "prettier": "^3.8.3",
77
80
  "rimraf": "^6.0.1",
78
81
  "rollup": "^4.24.0",
79
82
  "rollup-plugin-dts": "^6.1.1",
80
83
  "tslib": "^2.8.0",
81
- "typescript": "^5.6.3",
84
+ "typescript": "^6.0.3",
82
85
  "vitest": "^4.1.8"
83
86
  }
84
87
  }