@culpeo/async-ws 0.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.
@@ -0,0 +1,277 @@
1
+ var AsyncWS = (function (exports) {
2
+ 'use strict';
3
+
4
+ const _WS = WebSocket;
5
+ function createWebSocket(url, options) {
6
+ if (options?.headers != null) {
7
+ throw new Error("Custom headers are not supported in the browser. Use subprotocols or query parameters instead.");
8
+ }
9
+ return new _WS(url, options?.protocols);
10
+ }
11
+ function socketSend(socket, data) {
12
+ if (socket.readyState !== _WS.OPEN) {
13
+ return Promise.reject(new Error("WebSocket is not open"));
14
+ }
15
+ try {
16
+ socket.send(data);
17
+ return Promise.resolve();
18
+ }
19
+ catch (err) {
20
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
21
+ }
22
+ }
23
+ function setBinaryType(socket) {
24
+ socket.binaryType = "arraybuffer";
25
+ }
26
+ function attachListeners(socket, onOpen, onMessage, onClose, onError) {
27
+ const handleOpen = () => onOpen();
28
+ const handleMessage = (event) => {
29
+ const isBinary = event.data instanceof ArrayBuffer;
30
+ onMessage(event.data, isBinary);
31
+ };
32
+ const handleClose = (event) => onClose(event.code, event.reason, event.wasClean);
33
+ const handleError = () => onError(new Error("WebSocket error"));
34
+ socket.addEventListener("open", handleOpen);
35
+ socket.addEventListener("message", handleMessage);
36
+ socket.addEventListener("close", handleClose);
37
+ socket.addEventListener("error", handleError);
38
+ return () => {
39
+ socket.removeEventListener("open", handleOpen);
40
+ socket.removeEventListener("message", handleMessage);
41
+ socket.removeEventListener("close", handleClose);
42
+ socket.removeEventListener("error", handleError);
43
+ };
44
+ }
45
+ function socketClose(socket, code, reason) {
46
+ socket.close(code, reason);
47
+ }
48
+
49
+ /**
50
+ * Imperative WebSocket client that works in both browser and Node.js.
51
+ *
52
+ * Turns the event-driven WebSocket API into a promise-based one:
53
+ * - `connect(url)` returns a promise that resolves when the connection opens.
54
+ * - `send(data)` returns a promise that resolves when the data is accepted.
55
+ * - `receive()` returns a promise that resolves with the next message.
56
+ * - `close()` returns a promise that resolves when the connection closes.
57
+ * - Supports `for await...of` iteration over incoming messages.
58
+ */
59
+ class WebSocketClient {
60
+ constructor(options) {
61
+ this.socket = null;
62
+ this.state = "idle";
63
+ this.buffer = [];
64
+ this.waiters = [];
65
+ this.terminalError = null;
66
+ this.closeInfo = null;
67
+ this.removeListeners = null;
68
+ this.maxBufferSize = options?.maxBufferSize ?? 0;
69
+ }
70
+ /** Current connection state. */
71
+ get readyState() {
72
+ return this.state;
73
+ }
74
+ /** Close info from the last close event, if any. */
75
+ get lastCloseInfo() {
76
+ return this.closeInfo;
77
+ }
78
+ /**
79
+ * Connect to a WebSocket server.
80
+ * Resolves when the connection is open. Rejects on error.
81
+ */
82
+ connect(url, options) {
83
+ if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
84
+ return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
85
+ }
86
+ this.reset();
87
+ this.state = "connecting";
88
+ return new Promise((resolve, reject) => {
89
+ try {
90
+ this.socket = createWebSocket(url, options);
91
+ setBinaryType(this.socket);
92
+ }
93
+ catch (err) {
94
+ this.state = "errored";
95
+ this.terminalError = err instanceof Error ? err : new Error(String(err));
96
+ reject(this.terminalError);
97
+ return;
98
+ }
99
+ let settled = false;
100
+ this.removeListeners = attachListeners(this.socket,
101
+ // onOpen
102
+ () => {
103
+ if (!settled) {
104
+ settled = true;
105
+ this.state = "open";
106
+ resolve();
107
+ }
108
+ },
109
+ // onMessage
110
+ (data, binary) => {
111
+ this.enqueueMessage({ data, binary });
112
+ },
113
+ // onClose
114
+ (code, reason, wasClean) => {
115
+ this.closeInfo = { code, reason, wasClean };
116
+ this.state;
117
+ this.state = "closed";
118
+ this.cleanup();
119
+ if (!settled) {
120
+ settled = true;
121
+ reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
122
+ }
123
+ // Only reject pending waiters once buffer is drained
124
+ if (this.buffer.length === 0) {
125
+ this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
126
+ }
127
+ },
128
+ // onError
129
+ (error) => {
130
+ this.terminalError = error;
131
+ if (!settled) {
132
+ settled = true;
133
+ reject(error);
134
+ }
135
+ // Reject any pending receive() waiters immediately
136
+ this.rejectAllWaiters(error);
137
+ // Don't call cleanup() here — per spec, a close event always
138
+ // follows an error event. Let onClose handle state transition
139
+ // and listener removal.
140
+ });
141
+ });
142
+ }
143
+ /**
144
+ * Send data over the WebSocket.
145
+ * Resolves when the data has been accepted by the socket.
146
+ */
147
+ send(data) {
148
+ if (this.state !== "open" || !this.socket) {
149
+ return Promise.reject(new Error(`Cannot send: client is in "${this.state}" state`));
150
+ }
151
+ return socketSend(this.socket, data);
152
+ }
153
+ /**
154
+ * Wait for and return the next incoming message.
155
+ *
156
+ * If messages have been buffered, returns the oldest one immediately.
157
+ * If the socket has closed cleanly and the buffer is empty, rejects.
158
+ */
159
+ receive() {
160
+ // Drain buffer first, even if the socket is closed
161
+ if (this.buffer.length > 0) {
162
+ return Promise.resolve(this.buffer.shift());
163
+ }
164
+ if (this.state === "errored" && this.terminalError) {
165
+ return Promise.reject(this.terminalError);
166
+ }
167
+ if (this.state === "closed") {
168
+ return Promise.reject(new Error("WebSocket is closed"));
169
+ }
170
+ if (this.state !== "open") {
171
+ return Promise.reject(new Error(`Cannot receive: client is in "${this.state}" state`));
172
+ }
173
+ return new Promise((resolve, reject) => {
174
+ this.waiters.push({ resolve, reject });
175
+ });
176
+ }
177
+ /**
178
+ * Close the WebSocket connection.
179
+ * Resolves when the close handshake completes.
180
+ */
181
+ close(code, reason) {
182
+ if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
183
+ return Promise.resolve();
184
+ }
185
+ if (!this.socket) {
186
+ return Promise.resolve();
187
+ }
188
+ if (this.state === "closing") {
189
+ // Already closing — wait for the close event via a one-shot listener
190
+ return new Promise((resolve) => {
191
+ if (this.socket) {
192
+ this.socket.addEventListener("close", () => resolve(), { once: true });
193
+ }
194
+ else {
195
+ resolve();
196
+ }
197
+ });
198
+ }
199
+ this.state = "closing";
200
+ // Validate close code before calling socketClose to avoid leaving the
201
+ // socket in a corrupt state (ws library sets readyState to CLOSING
202
+ // before throwing on invalid codes, making the socket unrecoverable).
203
+ if (code !== undefined) {
204
+ if (code !== 1000 && (code < 3000 || code > 4999)) {
205
+ this.state = "open";
206
+ return Promise.reject(new Error(`Invalid close code: ${code}. Must be 1000 or in range 3000-4999.`));
207
+ }
208
+ }
209
+ return new Promise((resolve) => {
210
+ if (this.socket) {
211
+ this.socket.addEventListener("close", () => resolve(), { once: true });
212
+ }
213
+ socketClose(this.socket, code, reason);
214
+ });
215
+ }
216
+ /**
217
+ * Async iterator over incoming messages.
218
+ *
219
+ * - Yields messages as they arrive.
220
+ * - Returns (ends iteration) on normal close.
221
+ * - Throws on error/abnormal close.
222
+ * - If the consumer `break`s, the socket is NOT closed automatically.
223
+ */
224
+ async *[Symbol.asyncIterator]() {
225
+ while (true) {
226
+ try {
227
+ yield await this.receive();
228
+ }
229
+ catch {
230
+ // If closed cleanly, end iteration
231
+ if (this.state === "closed" && this.closeInfo?.wasClean) {
232
+ return;
233
+ }
234
+ // Otherwise, propagate the error
235
+ throw this.terminalError ?? new Error("WebSocket closed unexpectedly");
236
+ }
237
+ }
238
+ }
239
+ enqueueMessage(msg) {
240
+ if (this.waiters.length > 0) {
241
+ const waiter = this.waiters.shift();
242
+ waiter.resolve(msg);
243
+ }
244
+ else {
245
+ if (this.maxBufferSize > 0 && this.buffer.length >= this.maxBufferSize) {
246
+ this.buffer.shift(); // drop oldest
247
+ }
248
+ this.buffer.push(msg);
249
+ }
250
+ }
251
+ rejectAllWaiters(error) {
252
+ const pending = this.waiters.splice(0);
253
+ for (const waiter of pending) {
254
+ waiter.reject(error);
255
+ }
256
+ }
257
+ cleanup() {
258
+ if (this.removeListeners) {
259
+ this.removeListeners();
260
+ this.removeListeners = null;
261
+ }
262
+ }
263
+ reset() {
264
+ this.socket = null;
265
+ this.buffer = [];
266
+ this.waiters = [];
267
+ this.terminalError = null;
268
+ this.closeInfo = null;
269
+ this.removeListeners = null;
270
+ }
271
+ }
272
+
273
+ exports.WebSocketClient = WebSocketClient;
274
+
275
+ return exports;
276
+
277
+ })({});
@@ -0,0 +1,97 @@
1
+ /** Options for connecting to a WebSocket server. */
2
+ interface ConnectOptions {
3
+ /** Subprotocols to request. */
4
+ protocols?: string | string[];
5
+ /** HTTP headers to send during the handshake (Node.js only). */
6
+ headers?: Record<string, string>;
7
+ }
8
+ /** Represents a received WebSocket message. */
9
+ interface WebSocketMessage {
10
+ /** The message payload. */
11
+ data: string | ArrayBuffer;
12
+ /** Whether the payload is binary data. */
13
+ binary: boolean;
14
+ }
15
+ /** Information about a WebSocket close event. */
16
+ interface WebSocketCloseInfo {
17
+ /** The close code. */
18
+ code: number;
19
+ /** The close reason string. */
20
+ reason: string;
21
+ /** Whether the close handshake completed cleanly. */
22
+ wasClean: boolean;
23
+ }
24
+ /** Options for the WebSocketClient constructor. */
25
+ interface ClientOptions {
26
+ /**
27
+ * Maximum number of messages to buffer before a consumer calls `receive()`.
28
+ * When the buffer is full, the oldest message is dropped.
29
+ * Set to 0 for unlimited. Defaults to 0 (unlimited).
30
+ */
31
+ maxBufferSize?: number;
32
+ }
33
+ type WebSocketState = "idle" | "connecting" | "open" | "closing" | "closed" | "errored";
34
+
35
+ /**
36
+ * Imperative WebSocket client that works in both browser and Node.js.
37
+ *
38
+ * Turns the event-driven WebSocket API into a promise-based one:
39
+ * - `connect(url)` returns a promise that resolves when the connection opens.
40
+ * - `send(data)` returns a promise that resolves when the data is accepted.
41
+ * - `receive()` returns a promise that resolves with the next message.
42
+ * - `close()` returns a promise that resolves when the connection closes.
43
+ * - Supports `for await...of` iteration over incoming messages.
44
+ */
45
+ declare class WebSocketClient {
46
+ private socket;
47
+ private state;
48
+ private buffer;
49
+ private waiters;
50
+ private terminalError;
51
+ private closeInfo;
52
+ private removeListeners;
53
+ private readonly maxBufferSize;
54
+ constructor(options?: ClientOptions);
55
+ /** Current connection state. */
56
+ get readyState(): WebSocketState;
57
+ /** Close info from the last close event, if any. */
58
+ get lastCloseInfo(): WebSocketCloseInfo | null;
59
+ /**
60
+ * Connect to a WebSocket server.
61
+ * Resolves when the connection is open. Rejects on error.
62
+ */
63
+ connect(url: string | URL, options?: ConnectOptions): Promise<void>;
64
+ /**
65
+ * Send data over the WebSocket.
66
+ * Resolves when the data has been accepted by the socket.
67
+ */
68
+ send(data: string | ArrayBuffer | ArrayBufferView): Promise<void>;
69
+ /**
70
+ * Wait for and return the next incoming message.
71
+ *
72
+ * If messages have been buffered, returns the oldest one immediately.
73
+ * If the socket has closed cleanly and the buffer is empty, rejects.
74
+ */
75
+ receive(): Promise<WebSocketMessage>;
76
+ /**
77
+ * Close the WebSocket connection.
78
+ * Resolves when the close handshake completes.
79
+ */
80
+ close(code?: number, reason?: string): Promise<void>;
81
+ /**
82
+ * Async iterator over incoming messages.
83
+ *
84
+ * - Yields messages as they arrive.
85
+ * - Returns (ends iteration) on normal close.
86
+ * - Throws on error/abnormal close.
87
+ * - If the consumer `break`s, the socket is NOT closed automatically.
88
+ */
89
+ [Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage>;
90
+ private enqueueMessage;
91
+ private rejectAllWaiters;
92
+ private cleanup;
93
+ private reset;
94
+ }
95
+
96
+ export { WebSocketClient };
97
+ export type { ClientOptions, ConnectOptions, WebSocketCloseInfo, WebSocketMessage, WebSocketState };
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@culpeo/async-ws",
3
+ "version": "0.1.0",
4
+ "description": "Promise-first WebSocket client for Node.js and browsers",
5
+ "author": "Gerardo Lecaros",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "type": "module",
11
+ "keywords": [
12
+ "websocket",
13
+ "ws",
14
+ "async",
15
+ "promise",
16
+ "browser",
17
+ "node",
18
+ "client",
19
+ "async-iterator",
20
+ "cross-platform"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/culpeo-labs/async-ws.git"
25
+ },
26
+ "homepage": "https://github.com/culpeo-labs/async-ws#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/culpeo-labs/async-ws/issues"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "sideEffects": false,
34
+ "main": "./dist/cjs/index.cjs",
35
+ "module": "./dist/esm/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "files": [
38
+ "dist",
39
+ "LICENSE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "exports": {
43
+ "./package.json": "./package.json",
44
+ ".": {
45
+ "types": "./dist/index.d.ts",
46
+ "browser": "./dist/browser/index.js",
47
+ "import": "./dist/esm/index.js",
48
+ "require": "./dist/cjs/index.cjs",
49
+ "default": "./dist/esm/index.js"
50
+ },
51
+ "./iife": {
52
+ "default": "./dist/iife/index.js"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "test": "vitest run",
57
+ "test:browser": "xvfb-run vitest run --config vitest.config.browser.mts",
58
+ "build": "rollup -c",
59
+ "clean": "rimraf dist",
60
+ "typecheck": "tsc --noEmit",
61
+ "changeset": "changeset",
62
+ "version": "changeset version",
63
+ "release": "changeset publish",
64
+ "prepublishOnly": "npm run clean && npm run typecheck && npm run build && npm test"
65
+ },
66
+ "dependencies": {
67
+ "ws": "^8.18.0"
68
+ },
69
+ "devDependencies": {
70
+ "@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",
74
+ "@rollup/plugin-typescript": "^12.1.0",
75
+ "@types/ws": "^8.5.12",
76
+ "@vitest/browser-playwright": "^4.1.8",
77
+ "rimraf": "^6.0.1",
78
+ "rollup": "^4.24.0",
79
+ "rollup-plugin-dts": "^6.1.1",
80
+ "tslib": "^2.8.0",
81
+ "typescript": "^5.6.3",
82
+ "vitest": "^4.1.8"
83
+ }
84
+ }