@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - Cross-platform WebSocket client for Node.js and browsers
8
+ - Promise-based `connect()`, `send()`, `receive()`, `close()` API
9
+ - Async iteration with `for await...of`
10
+ - Message buffering with configurable `maxBufferSize`
11
+ - Close info tracking via `lastCloseInfo`
12
+ - TypeScript-first with bundled type definitions
13
+ - Node.js build uses `ws`; browser build uses native WebSocket
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gerardo Lecaros
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,358 @@
1
+ # @culpeo/async-ws
2
+
3
+ > Promise-first WebSocket client for Node.js and browsers.
4
+ >
5
+ > [![npm version](https://img.shields.io/npm/v/%40culpeo/async-ws)](https://www.npmjs.com/package/@culpeo/async-ws)
6
+ > [![license](https://img.shields.io/npm/l/%40culpeo/async-ws)](./LICENSE)
7
+ > [![bundle size](https://img.shields.io/bundlephobia/minzip/%40culpeo/async-ws)](https://bundlephobia.com/package/@culpeo/async-ws)
8
+
9
+ `@culpeo/async-ws` is a cross-platform WebSocket client that turns the event-driven WebSocket API into a small, imperative, promise-based interface.
10
+
11
+ ## Features
12
+
13
+ - Works in both Node.js and browsers from one package
14
+ - Promise-based `connect()`, `send()`, `receive()`, and `close()` APIs
15
+ - Async iteration support with `for await...of`
16
+ - Message buffering for messages that arrive before `receive()` is called
17
+ - Configurable `maxBufferSize` with oldest-message eviction when full
18
+ - Clean close information via `lastCloseInfo`
19
+ - TypeScript-first with bundled type definitions
20
+ - Binary and text message support
21
+ - Browser build uses the native `WebSocket`; Node build uses `ws`
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install @culpeo/async-ws
27
+ ```
28
+
29
+ ```bash
30
+ yarn add @culpeo/async-ws
31
+ ```
32
+
33
+ ```bash
34
+ pnpm add @culpeo/async-ws
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```ts
40
+ import { WebSocketClient } from "@culpeo/async-ws";
41
+
42
+ const client = new WebSocketClient();
43
+
44
+ await client.connect("wss://echo.websocket.events");
45
+ await client.send("hello");
46
+
47
+ const message = await client.receive();
48
+ console.log(message.data); // string | ArrayBuffer
49
+ console.log(message.binary); // boolean
50
+
51
+ await client.close();
52
+ ```
53
+
54
+ ## API Reference
55
+
56
+ ### `WebSocketClient`
57
+
58
+ #### Constructor
59
+
60
+ ```ts
61
+ new WebSocketClient(options?: ClientOptions)
62
+ ```
63
+
64
+ Creates a new client instance.
65
+
66
+ #### Constructor options
67
+
68
+ - `maxBufferSize?: number`
69
+ - Maximum number of incoming messages to keep buffered before they are consumed
70
+ - Default: `0` (unlimited)
71
+ - When the limit is reached, the oldest buffered message is dropped
72
+
73
+ #### Properties
74
+
75
+ #### `client.readyState`
76
+
77
+ ```ts
78
+ readonly readyState: WebSocketState
79
+ ```
80
+
81
+ Returns the current client state:
82
+
83
+ - `"idle"`
84
+ - `"connecting"`
85
+ - `"open"`
86
+ - `"closing"`
87
+ - `"closed"`
88
+ - `"errored"`
89
+
90
+ #### `client.lastCloseInfo`
91
+
92
+ ```ts
93
+ readonly lastCloseInfo: WebSocketCloseInfo | null
94
+ ```
95
+
96
+ Returns close metadata from the most recent close event, or `null` if the socket has not closed yet.
97
+
98
+ #### Methods
99
+
100
+ #### `connect()`
101
+
102
+ ```ts
103
+ connect(url: string | URL, options?: ConnectOptions): Promise<void>
104
+ ```
105
+
106
+ Opens a WebSocket connection and resolves when the connection is established.
107
+
108
+ Rejects when:
109
+
110
+ - the client is already connecting, open, or closing
111
+ - the socket constructor throws
112
+ - the connection errors before opening
113
+ - the socket closes before opening
114
+
115
+ ##### `ConnectOptions`
116
+
117
+ - `protocols?: string | string[]` — WebSocket subprotocols to request
118
+ - `headers?: Record<string, string>` — custom handshake headers in Node.js
119
+
120
+ > In browsers, passing `headers` throws because the native WebSocket API does not support custom headers.
121
+
122
+ #### `send()`
123
+
124
+ ```ts
125
+ send(data: string | ArrayBuffer | ArrayBufferView): Promise<void>
126
+ ```
127
+
128
+ Sends text or binary data.
129
+
130
+ Resolves when the underlying socket accepts the payload. Rejects if the client is not open or if the underlying adapter reports an error.
131
+
132
+ #### `receive()`
133
+
134
+ ```ts
135
+ receive(): Promise<WebSocketMessage>
136
+ ```
137
+
138
+ Resolves with the next incoming message.
139
+
140
+ Behavior:
141
+
142
+ - If buffered messages exist, returns the oldest buffered message immediately
143
+ - If no buffered message exists, waits for the next incoming message
144
+ - If the socket closes after buffering messages, buffered messages are still drained first
145
+ - Rejects when the client is not open and no buffered messages remain
146
+
147
+ #### `close()`
148
+
149
+ ```ts
150
+ close(code?: number, reason?: string): Promise<void>
151
+ ```
152
+
153
+ Starts the close handshake and resolves when the socket closes.
154
+
155
+ Behavior:
156
+
157
+ - Resolves immediately if the client is idle, already closed, or errored
158
+ - If a close is already in progress, waits for the close event
159
+ - Validates custom close codes before calling the underlying socket
160
+ - Accepts `1000` or values in the range `3000-4999`
161
+
162
+ #### Async iterator
163
+
164
+ ```ts
165
+ client[Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage>
166
+ ```
167
+
168
+ Allows consumption with `for await...of`.
169
+
170
+ Behavior:
171
+
172
+ - Yields incoming messages as they arrive
173
+ - Ends iteration on a clean close
174
+ - Throws on unexpected or error-driven termination
175
+ - Does not automatically close the socket if you `break` out of the loop
176
+
177
+ ## Types
178
+
179
+ ### `ConnectOptions`
180
+
181
+ ```ts
182
+ interface ConnectOptions {
183
+ protocols?: string | string[];
184
+ headers?: Record<string, string>;
185
+ }
186
+ ```
187
+
188
+ Connection-time options.
189
+
190
+ ### `ClientOptions`
191
+
192
+ ```ts
193
+ interface ClientOptions {
194
+ maxBufferSize?: number;
195
+ }
196
+ ```
197
+
198
+ Client-level configuration.
199
+
200
+ ### `WebSocketMessage`
201
+
202
+ ```ts
203
+ interface WebSocketMessage {
204
+ data: string | ArrayBuffer;
205
+ binary: boolean;
206
+ }
207
+ ```
208
+
209
+ Represents a received message payload.
210
+
211
+ ### `WebSocketCloseInfo`
212
+
213
+ ```ts
214
+ interface WebSocketCloseInfo {
215
+ code: number;
216
+ reason: string;
217
+ wasClean: boolean;
218
+ }
219
+ ```
220
+
221
+ Represents close metadata captured from the underlying socket.
222
+
223
+ ### `WebSocketState`
224
+
225
+ ```ts
226
+ type WebSocketState =
227
+ | "idle"
228
+ | "connecting"
229
+ | "open"
230
+ | "closing"
231
+ | "closed"
232
+ | "errored";
233
+ ```
234
+
235
+ Represents the client lifecycle state.
236
+
237
+ ## Browser vs Node
238
+
239
+ `@culpeo/async-ws` ships one API for both environments:
240
+
241
+ - **Node.js build** uses the `ws` package internally
242
+ - **Browser build** uses the native `WebSocket` implementation
243
+
244
+ This is handled at build time with Rollup. The browser bundle aliases the Node adapter module to a browser-specific adapter, so application code does not need environment checks or separate imports.
245
+
246
+ In practice, that means you write this once:
247
+
248
+ ```ts
249
+ import { WebSocketClient } from "@culpeo/async-ws";
250
+ ```
251
+
252
+ …and the appropriate adapter is selected by the published package exports and browser build.
253
+
254
+ ## Async Iterator
255
+
256
+ ```ts
257
+ import { WebSocketClient } from "@culpeo/async-ws";
258
+
259
+ const client = new WebSocketClient();
260
+ await client.connect("wss://example.com/ws");
261
+
262
+ try {
263
+ for await (const message of client) {
264
+ if (!message.binary) {
265
+ console.log("text:", message.data);
266
+ }
267
+ }
268
+ } finally {
269
+ await client.close();
270
+ }
271
+ ```
272
+
273
+ This is useful when you want a stream-like consumer loop without manually calling `receive()` each time.
274
+
275
+ ## Error Handling
276
+
277
+ All core operations are async and communicate failure by rejecting:
278
+
279
+ - `connect()` rejects on invalid state, connection failure, or early close
280
+ - `send()` rejects when called before the socket is open or when the adapter fails to send
281
+ - `receive()` rejects when the client is not in a receivable state and no buffered messages remain
282
+ - `close()` rejects for invalid close codes
283
+
284
+ Additional notes:
285
+
286
+ - Connection errors are treated as terminal for pending receivers
287
+ - A socket error is typically followed by a close event; close metadata is exposed through `lastCloseInfo`
288
+ - If buffered messages exist when a close happens, those messages are still delivered before `receive()` starts rejecting
289
+
290
+ A simple pattern:
291
+
292
+ ```ts
293
+ try {
294
+ await client.connect("wss://example.com/ws");
295
+ await client.send("ping");
296
+ const reply = await client.receive();
297
+ console.log(reply);
298
+ } catch (error) {
299
+ console.error("WebSocket operation failed", error);
300
+ console.error("Last close info:", client.lastCloseInfo);
301
+ }
302
+ ```
303
+
304
+ ## Message Buffering
305
+
306
+ Incoming messages are buffered when they arrive before a consumer calls `receive()`.
307
+
308
+ By default, buffering is unlimited:
309
+
310
+ ```ts
311
+ const client = new WebSocketClient();
312
+ ```
313
+
314
+ To cap memory usage, set `maxBufferSize`:
315
+
316
+ ```ts
317
+ const client = new WebSocketClient({ maxBufferSize: 100 });
318
+ ```
319
+
320
+ When the buffer is full:
321
+
322
+ - the oldest message is removed
323
+ - the newest message is stored
324
+
325
+ This makes buffering predictable for bursty message streams while keeping the public API simple.
326
+
327
+ ## Building from Source
328
+
329
+ ```bash
330
+ git clone <your-fork-or-repo-url>
331
+ cd <repo-directory>
332
+ npm install
333
+ ```
334
+
335
+ Run tests:
336
+
337
+ ```bash
338
+ npm test
339
+ npm run test:browser
340
+ ```
341
+
342
+ Build the package:
343
+
344
+ ```bash
345
+ npm run build
346
+ ```
347
+
348
+ Current build outputs include:
349
+
350
+ - CommonJS for Node.js
351
+ - ESM for Node.js
352
+ - Browser ESM
353
+ - Browser IIFE bundle
354
+ - Bundled TypeScript declarations
355
+
356
+ ## License
357
+
358
+ MIT
@@ -0,0 +1,270 @@
1
+ const _WS = WebSocket;
2
+ function createWebSocket(url, options) {
3
+ if (options?.headers != null) {
4
+ throw new Error("Custom headers are not supported in the browser. Use subprotocols or query parameters instead.");
5
+ }
6
+ return new _WS(url, options?.protocols);
7
+ }
8
+ function socketSend(socket, data) {
9
+ if (socket.readyState !== _WS.OPEN) {
10
+ return Promise.reject(new Error("WebSocket is not open"));
11
+ }
12
+ try {
13
+ socket.send(data);
14
+ return Promise.resolve();
15
+ }
16
+ catch (err) {
17
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
18
+ }
19
+ }
20
+ function setBinaryType(socket) {
21
+ socket.binaryType = "arraybuffer";
22
+ }
23
+ function attachListeners(socket, onOpen, onMessage, onClose, onError) {
24
+ const handleOpen = () => onOpen();
25
+ const handleMessage = (event) => {
26
+ const isBinary = event.data instanceof ArrayBuffer;
27
+ onMessage(event.data, isBinary);
28
+ };
29
+ const handleClose = (event) => onClose(event.code, event.reason, event.wasClean);
30
+ const handleError = () => onError(new Error("WebSocket error"));
31
+ socket.addEventListener("open", handleOpen);
32
+ socket.addEventListener("message", handleMessage);
33
+ socket.addEventListener("close", handleClose);
34
+ socket.addEventListener("error", handleError);
35
+ return () => {
36
+ socket.removeEventListener("open", handleOpen);
37
+ socket.removeEventListener("message", handleMessage);
38
+ socket.removeEventListener("close", handleClose);
39
+ socket.removeEventListener("error", handleError);
40
+ };
41
+ }
42
+ function socketClose(socket, code, reason) {
43
+ socket.close(code, reason);
44
+ }
45
+
46
+ /**
47
+ * Imperative WebSocket client that works in both browser and Node.js.
48
+ *
49
+ * Turns the event-driven WebSocket API into a promise-based one:
50
+ * - `connect(url)` returns a promise that resolves when the connection opens.
51
+ * - `send(data)` returns a promise that resolves when the data is accepted.
52
+ * - `receive()` returns a promise that resolves with the next message.
53
+ * - `close()` returns a promise that resolves when the connection closes.
54
+ * - Supports `for await...of` iteration over incoming messages.
55
+ */
56
+ class WebSocketClient {
57
+ constructor(options) {
58
+ this.socket = null;
59
+ this.state = "idle";
60
+ this.buffer = [];
61
+ this.waiters = [];
62
+ this.terminalError = null;
63
+ this.closeInfo = null;
64
+ this.removeListeners = null;
65
+ this.maxBufferSize = options?.maxBufferSize ?? 0;
66
+ }
67
+ /** Current connection state. */
68
+ get readyState() {
69
+ return this.state;
70
+ }
71
+ /** Close info from the last close event, if any. */
72
+ get lastCloseInfo() {
73
+ return this.closeInfo;
74
+ }
75
+ /**
76
+ * Connect to a WebSocket server.
77
+ * Resolves when the connection is open. Rejects on error.
78
+ */
79
+ connect(url, options) {
80
+ if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
81
+ return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
82
+ }
83
+ this.reset();
84
+ this.state = "connecting";
85
+ return new Promise((resolve, reject) => {
86
+ try {
87
+ this.socket = createWebSocket(url, options);
88
+ setBinaryType(this.socket);
89
+ }
90
+ catch (err) {
91
+ this.state = "errored";
92
+ this.terminalError = err instanceof Error ? err : new Error(String(err));
93
+ reject(this.terminalError);
94
+ return;
95
+ }
96
+ let settled = false;
97
+ this.removeListeners = attachListeners(this.socket,
98
+ // onOpen
99
+ () => {
100
+ if (!settled) {
101
+ settled = true;
102
+ this.state = "open";
103
+ resolve();
104
+ }
105
+ },
106
+ // onMessage
107
+ (data, binary) => {
108
+ this.enqueueMessage({ data, binary });
109
+ },
110
+ // onClose
111
+ (code, reason, wasClean) => {
112
+ this.closeInfo = { code, reason, wasClean };
113
+ this.state;
114
+ this.state = "closed";
115
+ this.cleanup();
116
+ if (!settled) {
117
+ settled = true;
118
+ reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
119
+ }
120
+ // Only reject pending waiters once buffer is drained
121
+ if (this.buffer.length === 0) {
122
+ this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
123
+ }
124
+ },
125
+ // onError
126
+ (error) => {
127
+ this.terminalError = error;
128
+ if (!settled) {
129
+ settled = true;
130
+ reject(error);
131
+ }
132
+ // Reject any pending receive() waiters immediately
133
+ this.rejectAllWaiters(error);
134
+ // Don't call cleanup() here — per spec, a close event always
135
+ // follows an error event. Let onClose handle state transition
136
+ // and listener removal.
137
+ });
138
+ });
139
+ }
140
+ /**
141
+ * Send data over the WebSocket.
142
+ * Resolves when the data has been accepted by the socket.
143
+ */
144
+ send(data) {
145
+ if (this.state !== "open" || !this.socket) {
146
+ return Promise.reject(new Error(`Cannot send: client is in "${this.state}" state`));
147
+ }
148
+ return socketSend(this.socket, data);
149
+ }
150
+ /**
151
+ * Wait for and return the next incoming message.
152
+ *
153
+ * If messages have been buffered, returns the oldest one immediately.
154
+ * If the socket has closed cleanly and the buffer is empty, rejects.
155
+ */
156
+ receive() {
157
+ // Drain buffer first, even if the socket is closed
158
+ if (this.buffer.length > 0) {
159
+ return Promise.resolve(this.buffer.shift());
160
+ }
161
+ if (this.state === "errored" && this.terminalError) {
162
+ return Promise.reject(this.terminalError);
163
+ }
164
+ if (this.state === "closed") {
165
+ return Promise.reject(new Error("WebSocket is closed"));
166
+ }
167
+ if (this.state !== "open") {
168
+ return Promise.reject(new Error(`Cannot receive: client is in "${this.state}" state`));
169
+ }
170
+ return new Promise((resolve, reject) => {
171
+ this.waiters.push({ resolve, reject });
172
+ });
173
+ }
174
+ /**
175
+ * Close the WebSocket connection.
176
+ * Resolves when the close handshake completes.
177
+ */
178
+ close(code, reason) {
179
+ if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
180
+ return Promise.resolve();
181
+ }
182
+ if (!this.socket) {
183
+ return Promise.resolve();
184
+ }
185
+ if (this.state === "closing") {
186
+ // Already closing — wait for the close event via a one-shot listener
187
+ return new Promise((resolve) => {
188
+ if (this.socket) {
189
+ this.socket.addEventListener("close", () => resolve(), { once: true });
190
+ }
191
+ else {
192
+ resolve();
193
+ }
194
+ });
195
+ }
196
+ this.state = "closing";
197
+ // Validate close code before calling socketClose to avoid leaving the
198
+ // socket in a corrupt state (ws library sets readyState to CLOSING
199
+ // before throwing on invalid codes, making the socket unrecoverable).
200
+ if (code !== undefined) {
201
+ if (code !== 1000 && (code < 3000 || code > 4999)) {
202
+ this.state = "open";
203
+ return Promise.reject(new Error(`Invalid close code: ${code}. Must be 1000 or in range 3000-4999.`));
204
+ }
205
+ }
206
+ return new Promise((resolve) => {
207
+ if (this.socket) {
208
+ this.socket.addEventListener("close", () => resolve(), { once: true });
209
+ }
210
+ socketClose(this.socket, code, reason);
211
+ });
212
+ }
213
+ /**
214
+ * Async iterator over incoming messages.
215
+ *
216
+ * - Yields messages as they arrive.
217
+ * - Returns (ends iteration) on normal close.
218
+ * - Throws on error/abnormal close.
219
+ * - If the consumer `break`s, the socket is NOT closed automatically.
220
+ */
221
+ async *[Symbol.asyncIterator]() {
222
+ while (true) {
223
+ try {
224
+ yield await this.receive();
225
+ }
226
+ catch {
227
+ // If closed cleanly, end iteration
228
+ if (this.state === "closed" && this.closeInfo?.wasClean) {
229
+ return;
230
+ }
231
+ // Otherwise, propagate the error
232
+ throw this.terminalError ?? new Error("WebSocket closed unexpectedly");
233
+ }
234
+ }
235
+ }
236
+ enqueueMessage(msg) {
237
+ if (this.waiters.length > 0) {
238
+ const waiter = this.waiters.shift();
239
+ waiter.resolve(msg);
240
+ }
241
+ else {
242
+ if (this.maxBufferSize > 0 && this.buffer.length >= this.maxBufferSize) {
243
+ this.buffer.shift(); // drop oldest
244
+ }
245
+ this.buffer.push(msg);
246
+ }
247
+ }
248
+ rejectAllWaiters(error) {
249
+ const pending = this.waiters.splice(0);
250
+ for (const waiter of pending) {
251
+ waiter.reject(error);
252
+ }
253
+ }
254
+ cleanup() {
255
+ if (this.removeListeners) {
256
+ this.removeListeners();
257
+ this.removeListeners = null;
258
+ }
259
+ }
260
+ reset() {
261
+ this.socket = null;
262
+ this.buffer = [];
263
+ this.waiters = [];
264
+ this.terminalError = null;
265
+ this.closeInfo = null;
266
+ this.removeListeners = null;
267
+ }
268
+ }
269
+
270
+ export { WebSocketClient };