@culpeo/async-ws 0.2.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.
@@ -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,15 @@ 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
+ }
48
67
 
49
68
  /**
50
69
  * Imperative WebSocket client that works in both browser and Node.js.
@@ -65,7 +84,17 @@ var AsyncWS = (function (exports) {
65
84
  this.terminalError = null;
66
85
  this.closeInfo = null;
67
86
  this.removeListeners = null;
87
+ this.keepAliveTimer = null;
88
+ this.pongTimer = null;
89
+ this.removePongListener = null;
90
+ this.connectionId = 0;
68
91
  this.maxBufferSize = options?.maxBufferSize ?? 0;
92
+ if (options?.keepAlive) {
93
+ {
94
+ throw new Error("keepAlive is not supported in browsers. " +
95
+ "The browser handles WebSocket ping/pong at the protocol level automatically.");
96
+ }
97
+ }
69
98
  }
70
99
  /** Current connection state. */
71
100
  get readyState() {
@@ -75,16 +104,41 @@ var AsyncWS = (function (exports) {
75
104
  get lastCloseInfo() {
76
105
  return this.closeInfo;
77
106
  }
107
+ /** The negotiated subprotocol, or empty string if none. */
108
+ get protocol() {
109
+ return this.socket?.protocol ?? "";
110
+ }
111
+ /** The URL of the WebSocket connection. */
112
+ get url() {
113
+ return this.socket?.url ?? "";
114
+ }
115
+ /** The number of bytes of data queued for sending. */
116
+ get bufferedAmount() {
117
+ return this.socket?.bufferedAmount ?? 0;
118
+ }
119
+ /** The extensions negotiated by the server. */
120
+ get extensions() {
121
+ return this.socket?.extensions ?? "";
122
+ }
78
123
  /**
79
124
  * Connect to a WebSocket server.
80
125
  * Resolves when the connection is open. Rejects on error.
81
126
  */
82
127
  connect(url, options) {
83
- if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
128
+ if (this.state !== "idle" &&
129
+ this.state !== "closed" &&
130
+ this.state !== "errored") {
84
131
  return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
85
132
  }
133
+ if (options?.timeout !== undefined && options.timeout <= 0) {
134
+ return Promise.reject(new Error("timeout must be greater than 0."));
135
+ }
136
+ if (options?.signal?.aborted) {
137
+ return Promise.reject(new Error("Connection aborted."));
138
+ }
86
139
  this.reset();
87
140
  this.state = "connecting";
141
+ const currentConnectionId = ++this.connectionId;
88
142
  return new Promise((resolve, reject) => {
89
143
  try {
90
144
  this.socket = createWebSocket(url, options);
@@ -92,19 +146,63 @@ var AsyncWS = (function (exports) {
92
146
  }
93
147
  catch (err) {
94
148
  this.state = "errored";
95
- this.terminalError = err instanceof Error ? err : new Error(String(err));
149
+ this.terminalError =
150
+ err instanceof Error ? err : new Error(String(err));
96
151
  reject(this.terminalError);
97
152
  return;
98
153
  }
99
154
  let settled = false;
155
+ let timeoutId = null;
156
+ const settle = (fn) => {
157
+ if (settled)
158
+ return;
159
+ settled = true;
160
+ if (timeoutId !== null) {
161
+ clearTimeout(timeoutId);
162
+ timeoutId = null;
163
+ }
164
+ if (options?.signal) {
165
+ options.signal.removeEventListener("abort", onAbort);
166
+ }
167
+ fn();
168
+ };
169
+ const onAbort = () => {
170
+ settle(() => {
171
+ this.state = "closed";
172
+ this.terminalError = new Error("Connection aborted.");
173
+ if (this.socket) {
174
+ socketTerminate(this.socket);
175
+ }
176
+ // Don't call cleanup() here — socketTerminate() emits
177
+ // error/close asynchronously; let onClose handle cleanup.
178
+ reject(this.terminalError);
179
+ });
180
+ };
181
+ if (options?.timeout !== undefined) {
182
+ timeoutId = setTimeout(() => {
183
+ settle(() => {
184
+ this.state = "closed";
185
+ this.terminalError = new Error(`Connection timed out after ${options.timeout}ms.`);
186
+ if (this.socket) {
187
+ socketTerminate(this.socket);
188
+ }
189
+ // Don't call cleanup() here — socketTerminate() emits
190
+ // error/close asynchronously; let onClose handle cleanup.
191
+ reject(this.terminalError);
192
+ });
193
+ }, options.timeout);
194
+ }
195
+ if (options?.signal) {
196
+ options.signal.addEventListener("abort", onAbort, { once: true });
197
+ }
100
198
  this.removeListeners = attachListeners(this.socket,
101
199
  // onOpen
102
200
  () => {
103
- if (!settled) {
104
- settled = true;
201
+ settle(() => {
105
202
  this.state = "open";
203
+ this.startKeepAlive();
106
204
  resolve();
107
- }
205
+ });
108
206
  },
109
207
  // onMessage
110
208
  (data, binary) => {
@@ -112,14 +210,16 @@ var AsyncWS = (function (exports) {
112
210
  },
113
211
  // onClose
114
212
  (code, reason, wasClean) => {
213
+ // Ignore close events from a stale connection (e.g., after
214
+ // timeout/abort triggered a reconnect on the same client).
215
+ if (currentConnectionId !== this.connectionId)
216
+ return;
115
217
  this.closeInfo = { code, reason, wasClean };
116
- this.state;
117
218
  this.state = "closed";
118
219
  this.cleanup();
119
- if (!settled) {
120
- settled = true;
220
+ settle(() => {
121
221
  reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
122
- }
222
+ });
123
223
  // Only reject pending waiters once buffer is drained
124
224
  if (this.buffer.length === 0) {
125
225
  this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
@@ -127,11 +227,13 @@ var AsyncWS = (function (exports) {
127
227
  },
128
228
  // onError
129
229
  (error) => {
230
+ // Ignore error events from a stale connection.
231
+ if (currentConnectionId !== this.connectionId)
232
+ return;
130
233
  this.terminalError = error;
131
- if (!settled) {
132
- settled = true;
234
+ settle(() => {
133
235
  reject(error);
134
- }
236
+ });
135
237
  // Reject any pending receive() waiters immediately
136
238
  this.rejectAllWaiters(error);
137
239
  // Don't call cleanup() here — per spec, a close event always
@@ -179,7 +281,9 @@ var AsyncWS = (function (exports) {
179
281
  * Resolves when the close handshake completes.
180
282
  */
181
283
  close(code, reason) {
182
- if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
284
+ if (this.state === "closed" ||
285
+ this.state === "idle" ||
286
+ this.state === "errored") {
183
287
  return Promise.resolve();
184
288
  }
185
289
  if (!this.socket) {
@@ -189,7 +293,9 @@ var AsyncWS = (function (exports) {
189
293
  // Already closing — wait for the close event via a one-shot listener
190
294
  return new Promise((resolve) => {
191
295
  if (this.socket) {
192
- this.socket.addEventListener("close", () => resolve(), { once: true });
296
+ this.socket.addEventListener("close", () => resolve(), {
297
+ once: true,
298
+ });
193
299
  }
194
300
  else {
195
301
  resolve();
@@ -254,11 +360,50 @@ var AsyncWS = (function (exports) {
254
360
  waiter.reject(error);
255
361
  }
256
362
  }
363
+ startKeepAlive() {
364
+ if (!this.keepAliveConfig || !this.socket)
365
+ return;
366
+ const { interval, timeout } = this.keepAliveConfig;
367
+ const pongTimeout = timeout ?? interval;
368
+ this.removePongListener = attachPongListener(this.socket);
369
+ this.keepAliveTimer = setInterval(() => {
370
+ if (this.state !== "open" || !this.socket)
371
+ return;
372
+ socketPing(this.socket);
373
+ // Clear any existing pong watchdog before starting a new one
374
+ // to prevent multiple timers when timeout > interval.
375
+ if (this.pongTimer !== null) {
376
+ clearTimeout(this.pongTimer);
377
+ }
378
+ this.pongTimer = setTimeout(() => {
379
+ if (this.state === "open" && this.socket) {
380
+ this.terminalError = new Error(`Keep-alive timeout: no pong received within ${pongTimeout}ms.`);
381
+ socketTerminate(this.socket);
382
+ }
383
+ }, pongTimeout);
384
+ }, interval);
385
+ }
386
+ stopKeepAlive() {
387
+ if (this.keepAliveTimer !== null) {
388
+ clearInterval(this.keepAliveTimer);
389
+ this.keepAliveTimer = null;
390
+ }
391
+ if (this.pongTimer !== null) {
392
+ clearTimeout(this.pongTimer);
393
+ this.pongTimer = null;
394
+ }
395
+ if (this.removePongListener) {
396
+ this.removePongListener();
397
+ this.removePongListener = null;
398
+ }
399
+ }
257
400
  cleanup() {
401
+ this.stopKeepAlive();
258
402
  if (this.removeListeners) {
259
403
  this.removeListeners();
260
404
  this.removeListeners = null;
261
405
  }
406
+ this.socket = null;
262
407
  }
263
408
  reset() {
264
409
  this.socket = null;
@@ -267,6 +412,7 @@ var AsyncWS = (function (exports) {
267
412
  this.terminalError = null;
268
413
  this.closeInfo = null;
269
414
  this.removeListeners = null;
415
+ this.stopKeepAlive();
270
416
  }
271
417
  }
272
418
 
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,25 @@ 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);
55
87
  /** Current connection state. */
56
88
  get readyState(): WebSocketState;
57
89
  /** Close info from the last close event, if any. */
58
90
  get lastCloseInfo(): WebSocketCloseInfo | null;
91
+ /** The negotiated subprotocol, or empty string if none. */
92
+ get protocol(): string;
93
+ /** The URL of the WebSocket connection. */
94
+ get url(): string;
95
+ /** The number of bytes of data queued for sending. */
96
+ get bufferedAmount(): number;
97
+ /** The extensions negotiated by the server. */
98
+ get extensions(): string;
59
99
  /**
60
100
  * Connect to a WebSocket server.
61
101
  * Resolves when the connection is open. Rejects on error.
@@ -89,9 +129,11 @@ declare class WebSocketClient {
89
129
  [Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage>;
90
130
  private enqueueMessage;
91
131
  private rejectAllWaiters;
132
+ private startKeepAlive;
133
+ private stopKeepAlive;
92
134
  private cleanup;
93
135
  private reset;
94
136
  }
95
137
 
96
138
  export { WebSocketClient };
97
- export type { ClientOptions, ConnectOptions, WebSocketCloseInfo, WebSocketMessage, WebSocketState };
139
+ 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.0.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
  }