@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 +13 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/browser/index.js +270 -0
- package/dist/cjs/index.cjs +293 -0
- package/dist/esm/index.js +291 -0
- package/dist/iife/index.js +277 -0
- package/dist/index.d.ts +97 -0
- package/package.json +84 -0
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
|
+
> [](https://www.npmjs.com/package/@culpeo/async-ws)
|
|
6
|
+
> [](./LICENSE)
|
|
7
|
+
> [](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 };
|