@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 +28 -0
- package/README.md +100 -2
- package/dist/browser/index.js +161 -15
- package/dist/cjs/index.cjs +161 -14
- package/dist/esm/index.js +161 -14
- package/dist/iife/index.js +161 -15
- package/dist/index.d.ts +43 -1
- package/package.json +8 -5
package/dist/iife/index.js
CHANGED
|
@@ -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
|
-
|
|
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" &&
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
|
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(), {
|
|
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": "
|
|
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": "^
|
|
72
|
-
"@rollup/plugin-commonjs": "^
|
|
73
|
-
"@rollup/plugin-node-resolve": "^
|
|
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": "^
|
|
84
|
+
"typescript": "^6.0.3",
|
|
82
85
|
"vitest": "^4.1.8"
|
|
83
86
|
}
|
|
84
87
|
}
|