@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,293 @@
1
+ 'use strict';
2
+
3
+ var ws = require('ws');
4
+
5
+ function createWebSocket(url, options) {
6
+ return new ws.WebSocket(url, options?.protocols, {
7
+ headers: options?.headers,
8
+ });
9
+ }
10
+ function socketSend(socket, data) {
11
+ return new Promise((resolve, reject) => {
12
+ socket.send(data, (error) => {
13
+ if (error) {
14
+ reject(error);
15
+ }
16
+ else {
17
+ resolve();
18
+ }
19
+ });
20
+ });
21
+ }
22
+ function setBinaryType(socket) {
23
+ socket.binaryType = "arraybuffer";
24
+ }
25
+ function attachListeners(socket, onOpen, onMessage, onClose, onError) {
26
+ const handleOpen = () => onOpen();
27
+ const handleMessage = (event) => {
28
+ const isBinary = !(typeof event.data === "string");
29
+ let data;
30
+ if (isBinary) {
31
+ if (event.data instanceof ArrayBuffer) {
32
+ data = event.data;
33
+ }
34
+ else if (Buffer.isBuffer(event.data)) {
35
+ const buf = event.data;
36
+ data = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
37
+ }
38
+ else if (Array.isArray(event.data)) {
39
+ const buf = Buffer.concat(event.data);
40
+ data = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
41
+ }
42
+ else {
43
+ data = event.data;
44
+ }
45
+ }
46
+ else {
47
+ data = event.data;
48
+ }
49
+ onMessage(data, isBinary);
50
+ };
51
+ const handleClose = (event) => onClose(event.code, event.reason, event.wasClean);
52
+ const handleError = (event) => onError(new Error(event.message));
53
+ socket.addEventListener("open", handleOpen);
54
+ socket.addEventListener("message", handleMessage);
55
+ socket.addEventListener("close", handleClose);
56
+ socket.addEventListener("error", handleError);
57
+ return () => {
58
+ socket.removeEventListener("open", handleOpen);
59
+ socket.removeEventListener("message", handleMessage);
60
+ socket.removeEventListener("close", handleClose);
61
+ socket.removeEventListener("error", handleError);
62
+ };
63
+ }
64
+ function socketClose(socket, code, reason) {
65
+ socket.close(code, reason);
66
+ }
67
+ ws.WebSocket.OPEN;
68
+
69
+ /**
70
+ * Imperative WebSocket client that works in both browser and Node.js.
71
+ *
72
+ * Turns the event-driven WebSocket API into a promise-based one:
73
+ * - `connect(url)` returns a promise that resolves when the connection opens.
74
+ * - `send(data)` returns a promise that resolves when the data is accepted.
75
+ * - `receive()` returns a promise that resolves with the next message.
76
+ * - `close()` returns a promise that resolves when the connection closes.
77
+ * - Supports `for await...of` iteration over incoming messages.
78
+ */
79
+ class WebSocketClient {
80
+ constructor(options) {
81
+ this.socket = null;
82
+ this.state = "idle";
83
+ this.buffer = [];
84
+ this.waiters = [];
85
+ this.terminalError = null;
86
+ this.closeInfo = null;
87
+ this.removeListeners = null;
88
+ this.maxBufferSize = options?.maxBufferSize ?? 0;
89
+ }
90
+ /** Current connection state. */
91
+ get readyState() {
92
+ return this.state;
93
+ }
94
+ /** Close info from the last close event, if any. */
95
+ get lastCloseInfo() {
96
+ return this.closeInfo;
97
+ }
98
+ /**
99
+ * Connect to a WebSocket server.
100
+ * Resolves when the connection is open. Rejects on error.
101
+ */
102
+ connect(url, options) {
103
+ if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
104
+ return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
105
+ }
106
+ this.reset();
107
+ this.state = "connecting";
108
+ return new Promise((resolve, reject) => {
109
+ try {
110
+ this.socket = createWebSocket(url, options);
111
+ setBinaryType(this.socket);
112
+ }
113
+ catch (err) {
114
+ this.state = "errored";
115
+ this.terminalError = err instanceof Error ? err : new Error(String(err));
116
+ reject(this.terminalError);
117
+ return;
118
+ }
119
+ let settled = false;
120
+ this.removeListeners = attachListeners(this.socket,
121
+ // onOpen
122
+ () => {
123
+ if (!settled) {
124
+ settled = true;
125
+ this.state = "open";
126
+ resolve();
127
+ }
128
+ },
129
+ // onMessage
130
+ (data, binary) => {
131
+ this.enqueueMessage({ data, binary });
132
+ },
133
+ // onClose
134
+ (code, reason, wasClean) => {
135
+ this.closeInfo = { code, reason, wasClean };
136
+ this.state;
137
+ this.state = "closed";
138
+ this.cleanup();
139
+ if (!settled) {
140
+ settled = true;
141
+ reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
142
+ }
143
+ // Only reject pending waiters once buffer is drained
144
+ if (this.buffer.length === 0) {
145
+ this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
146
+ }
147
+ },
148
+ // onError
149
+ (error) => {
150
+ this.terminalError = error;
151
+ if (!settled) {
152
+ settled = true;
153
+ reject(error);
154
+ }
155
+ // Reject any pending receive() waiters immediately
156
+ this.rejectAllWaiters(error);
157
+ // Don't call cleanup() here — per spec, a close event always
158
+ // follows an error event. Let onClose handle state transition
159
+ // and listener removal.
160
+ });
161
+ });
162
+ }
163
+ /**
164
+ * Send data over the WebSocket.
165
+ * Resolves when the data has been accepted by the socket.
166
+ */
167
+ send(data) {
168
+ if (this.state !== "open" || !this.socket) {
169
+ return Promise.reject(new Error(`Cannot send: client is in "${this.state}" state`));
170
+ }
171
+ return socketSend(this.socket, data);
172
+ }
173
+ /**
174
+ * Wait for and return the next incoming message.
175
+ *
176
+ * If messages have been buffered, returns the oldest one immediately.
177
+ * If the socket has closed cleanly and the buffer is empty, rejects.
178
+ */
179
+ receive() {
180
+ // Drain buffer first, even if the socket is closed
181
+ if (this.buffer.length > 0) {
182
+ return Promise.resolve(this.buffer.shift());
183
+ }
184
+ if (this.state === "errored" && this.terminalError) {
185
+ return Promise.reject(this.terminalError);
186
+ }
187
+ if (this.state === "closed") {
188
+ return Promise.reject(new Error("WebSocket is closed"));
189
+ }
190
+ if (this.state !== "open") {
191
+ return Promise.reject(new Error(`Cannot receive: client is in "${this.state}" state`));
192
+ }
193
+ return new Promise((resolve, reject) => {
194
+ this.waiters.push({ resolve, reject });
195
+ });
196
+ }
197
+ /**
198
+ * Close the WebSocket connection.
199
+ * Resolves when the close handshake completes.
200
+ */
201
+ close(code, reason) {
202
+ if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
203
+ return Promise.resolve();
204
+ }
205
+ if (!this.socket) {
206
+ return Promise.resolve();
207
+ }
208
+ if (this.state === "closing") {
209
+ // Already closing — wait for the close event via a one-shot listener
210
+ return new Promise((resolve) => {
211
+ if (this.socket) {
212
+ this.socket.addEventListener("close", () => resolve(), { once: true });
213
+ }
214
+ else {
215
+ resolve();
216
+ }
217
+ });
218
+ }
219
+ this.state = "closing";
220
+ // Validate close code before calling socketClose to avoid leaving the
221
+ // socket in a corrupt state (ws library sets readyState to CLOSING
222
+ // before throwing on invalid codes, making the socket unrecoverable).
223
+ if (code !== undefined) {
224
+ if (code !== 1000 && (code < 3000 || code > 4999)) {
225
+ this.state = "open";
226
+ return Promise.reject(new Error(`Invalid close code: ${code}. Must be 1000 or in range 3000-4999.`));
227
+ }
228
+ }
229
+ return new Promise((resolve) => {
230
+ if (this.socket) {
231
+ this.socket.addEventListener("close", () => resolve(), { once: true });
232
+ }
233
+ socketClose(this.socket, code, reason);
234
+ });
235
+ }
236
+ /**
237
+ * Async iterator over incoming messages.
238
+ *
239
+ * - Yields messages as they arrive.
240
+ * - Returns (ends iteration) on normal close.
241
+ * - Throws on error/abnormal close.
242
+ * - If the consumer `break`s, the socket is NOT closed automatically.
243
+ */
244
+ async *[Symbol.asyncIterator]() {
245
+ while (true) {
246
+ try {
247
+ yield await this.receive();
248
+ }
249
+ catch {
250
+ // If closed cleanly, end iteration
251
+ if (this.state === "closed" && this.closeInfo?.wasClean) {
252
+ return;
253
+ }
254
+ // Otherwise, propagate the error
255
+ throw this.terminalError ?? new Error("WebSocket closed unexpectedly");
256
+ }
257
+ }
258
+ }
259
+ enqueueMessage(msg) {
260
+ if (this.waiters.length > 0) {
261
+ const waiter = this.waiters.shift();
262
+ waiter.resolve(msg);
263
+ }
264
+ else {
265
+ if (this.maxBufferSize > 0 && this.buffer.length >= this.maxBufferSize) {
266
+ this.buffer.shift(); // drop oldest
267
+ }
268
+ this.buffer.push(msg);
269
+ }
270
+ }
271
+ rejectAllWaiters(error) {
272
+ const pending = this.waiters.splice(0);
273
+ for (const waiter of pending) {
274
+ waiter.reject(error);
275
+ }
276
+ }
277
+ cleanup() {
278
+ if (this.removeListeners) {
279
+ this.removeListeners();
280
+ this.removeListeners = null;
281
+ }
282
+ }
283
+ reset() {
284
+ this.socket = null;
285
+ this.buffer = [];
286
+ this.waiters = [];
287
+ this.terminalError = null;
288
+ this.closeInfo = null;
289
+ this.removeListeners = null;
290
+ }
291
+ }
292
+
293
+ exports.WebSocketClient = WebSocketClient;
@@ -0,0 +1,291 @@
1
+ import { WebSocket } from 'ws';
2
+
3
+ function createWebSocket(url, options) {
4
+ return new WebSocket(url, options?.protocols, {
5
+ headers: options?.headers,
6
+ });
7
+ }
8
+ function socketSend(socket, data) {
9
+ return new Promise((resolve, reject) => {
10
+ socket.send(data, (error) => {
11
+ if (error) {
12
+ reject(error);
13
+ }
14
+ else {
15
+ resolve();
16
+ }
17
+ });
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 = !(typeof event.data === "string");
27
+ let data;
28
+ if (isBinary) {
29
+ if (event.data instanceof ArrayBuffer) {
30
+ data = event.data;
31
+ }
32
+ else if (Buffer.isBuffer(event.data)) {
33
+ const buf = event.data;
34
+ data = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
35
+ }
36
+ else if (Array.isArray(event.data)) {
37
+ const buf = Buffer.concat(event.data);
38
+ data = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
39
+ }
40
+ else {
41
+ data = event.data;
42
+ }
43
+ }
44
+ else {
45
+ data = event.data;
46
+ }
47
+ onMessage(data, isBinary);
48
+ };
49
+ const handleClose = (event) => onClose(event.code, event.reason, event.wasClean);
50
+ const handleError = (event) => onError(new Error(event.message));
51
+ socket.addEventListener("open", handleOpen);
52
+ socket.addEventListener("message", handleMessage);
53
+ socket.addEventListener("close", handleClose);
54
+ socket.addEventListener("error", handleError);
55
+ return () => {
56
+ socket.removeEventListener("open", handleOpen);
57
+ socket.removeEventListener("message", handleMessage);
58
+ socket.removeEventListener("close", handleClose);
59
+ socket.removeEventListener("error", handleError);
60
+ };
61
+ }
62
+ function socketClose(socket, code, reason) {
63
+ socket.close(code, reason);
64
+ }
65
+ WebSocket.OPEN;
66
+
67
+ /**
68
+ * Imperative WebSocket client that works in both browser and Node.js.
69
+ *
70
+ * Turns the event-driven WebSocket API into a promise-based one:
71
+ * - `connect(url)` returns a promise that resolves when the connection opens.
72
+ * - `send(data)` returns a promise that resolves when the data is accepted.
73
+ * - `receive()` returns a promise that resolves with the next message.
74
+ * - `close()` returns a promise that resolves when the connection closes.
75
+ * - Supports `for await...of` iteration over incoming messages.
76
+ */
77
+ class WebSocketClient {
78
+ constructor(options) {
79
+ this.socket = null;
80
+ this.state = "idle";
81
+ this.buffer = [];
82
+ this.waiters = [];
83
+ this.terminalError = null;
84
+ this.closeInfo = null;
85
+ this.removeListeners = null;
86
+ this.maxBufferSize = options?.maxBufferSize ?? 0;
87
+ }
88
+ /** Current connection state. */
89
+ get readyState() {
90
+ return this.state;
91
+ }
92
+ /** Close info from the last close event, if any. */
93
+ get lastCloseInfo() {
94
+ return this.closeInfo;
95
+ }
96
+ /**
97
+ * Connect to a WebSocket server.
98
+ * Resolves when the connection is open. Rejects on error.
99
+ */
100
+ connect(url, options) {
101
+ if (this.state !== "idle" && this.state !== "closed" && this.state !== "errored") {
102
+ return Promise.reject(new Error(`Cannot connect: client is in "${this.state}" state`));
103
+ }
104
+ this.reset();
105
+ this.state = "connecting";
106
+ return new Promise((resolve, reject) => {
107
+ try {
108
+ this.socket = createWebSocket(url, options);
109
+ setBinaryType(this.socket);
110
+ }
111
+ catch (err) {
112
+ this.state = "errored";
113
+ this.terminalError = err instanceof Error ? err : new Error(String(err));
114
+ reject(this.terminalError);
115
+ return;
116
+ }
117
+ let settled = false;
118
+ this.removeListeners = attachListeners(this.socket,
119
+ // onOpen
120
+ () => {
121
+ if (!settled) {
122
+ settled = true;
123
+ this.state = "open";
124
+ resolve();
125
+ }
126
+ },
127
+ // onMessage
128
+ (data, binary) => {
129
+ this.enqueueMessage({ data, binary });
130
+ },
131
+ // onClose
132
+ (code, reason, wasClean) => {
133
+ this.closeInfo = { code, reason, wasClean };
134
+ this.state;
135
+ this.state = "closed";
136
+ this.cleanup();
137
+ if (!settled) {
138
+ settled = true;
139
+ reject(new Error(`WebSocket closed before opening (code: ${code}, reason: ${reason})`));
140
+ }
141
+ // Only reject pending waiters once buffer is drained
142
+ if (this.buffer.length === 0) {
143
+ this.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
144
+ }
145
+ },
146
+ // onError
147
+ (error) => {
148
+ this.terminalError = error;
149
+ if (!settled) {
150
+ settled = true;
151
+ reject(error);
152
+ }
153
+ // Reject any pending receive() waiters immediately
154
+ this.rejectAllWaiters(error);
155
+ // Don't call cleanup() here — per spec, a close event always
156
+ // follows an error event. Let onClose handle state transition
157
+ // and listener removal.
158
+ });
159
+ });
160
+ }
161
+ /**
162
+ * Send data over the WebSocket.
163
+ * Resolves when the data has been accepted by the socket.
164
+ */
165
+ send(data) {
166
+ if (this.state !== "open" || !this.socket) {
167
+ return Promise.reject(new Error(`Cannot send: client is in "${this.state}" state`));
168
+ }
169
+ return socketSend(this.socket, data);
170
+ }
171
+ /**
172
+ * Wait for and return the next incoming message.
173
+ *
174
+ * If messages have been buffered, returns the oldest one immediately.
175
+ * If the socket has closed cleanly and the buffer is empty, rejects.
176
+ */
177
+ receive() {
178
+ // Drain buffer first, even if the socket is closed
179
+ if (this.buffer.length > 0) {
180
+ return Promise.resolve(this.buffer.shift());
181
+ }
182
+ if (this.state === "errored" && this.terminalError) {
183
+ return Promise.reject(this.terminalError);
184
+ }
185
+ if (this.state === "closed") {
186
+ return Promise.reject(new Error("WebSocket is closed"));
187
+ }
188
+ if (this.state !== "open") {
189
+ return Promise.reject(new Error(`Cannot receive: client is in "${this.state}" state`));
190
+ }
191
+ return new Promise((resolve, reject) => {
192
+ this.waiters.push({ resolve, reject });
193
+ });
194
+ }
195
+ /**
196
+ * Close the WebSocket connection.
197
+ * Resolves when the close handshake completes.
198
+ */
199
+ close(code, reason) {
200
+ if (this.state === "closed" || this.state === "idle" || this.state === "errored") {
201
+ return Promise.resolve();
202
+ }
203
+ if (!this.socket) {
204
+ return Promise.resolve();
205
+ }
206
+ if (this.state === "closing") {
207
+ // Already closing — wait for the close event via a one-shot listener
208
+ return new Promise((resolve) => {
209
+ if (this.socket) {
210
+ this.socket.addEventListener("close", () => resolve(), { once: true });
211
+ }
212
+ else {
213
+ resolve();
214
+ }
215
+ });
216
+ }
217
+ this.state = "closing";
218
+ // Validate close code before calling socketClose to avoid leaving the
219
+ // socket in a corrupt state (ws library sets readyState to CLOSING
220
+ // before throwing on invalid codes, making the socket unrecoverable).
221
+ if (code !== undefined) {
222
+ if (code !== 1000 && (code < 3000 || code > 4999)) {
223
+ this.state = "open";
224
+ return Promise.reject(new Error(`Invalid close code: ${code}. Must be 1000 or in range 3000-4999.`));
225
+ }
226
+ }
227
+ return new Promise((resolve) => {
228
+ if (this.socket) {
229
+ this.socket.addEventListener("close", () => resolve(), { once: true });
230
+ }
231
+ socketClose(this.socket, code, reason);
232
+ });
233
+ }
234
+ /**
235
+ * Async iterator over incoming messages.
236
+ *
237
+ * - Yields messages as they arrive.
238
+ * - Returns (ends iteration) on normal close.
239
+ * - Throws on error/abnormal close.
240
+ * - If the consumer `break`s, the socket is NOT closed automatically.
241
+ */
242
+ async *[Symbol.asyncIterator]() {
243
+ while (true) {
244
+ try {
245
+ yield await this.receive();
246
+ }
247
+ catch {
248
+ // If closed cleanly, end iteration
249
+ if (this.state === "closed" && this.closeInfo?.wasClean) {
250
+ return;
251
+ }
252
+ // Otherwise, propagate the error
253
+ throw this.terminalError ?? new Error("WebSocket closed unexpectedly");
254
+ }
255
+ }
256
+ }
257
+ enqueueMessage(msg) {
258
+ if (this.waiters.length > 0) {
259
+ const waiter = this.waiters.shift();
260
+ waiter.resolve(msg);
261
+ }
262
+ else {
263
+ if (this.maxBufferSize > 0 && this.buffer.length >= this.maxBufferSize) {
264
+ this.buffer.shift(); // drop oldest
265
+ }
266
+ this.buffer.push(msg);
267
+ }
268
+ }
269
+ rejectAllWaiters(error) {
270
+ const pending = this.waiters.splice(0);
271
+ for (const waiter of pending) {
272
+ waiter.reject(error);
273
+ }
274
+ }
275
+ cleanup() {
276
+ if (this.removeListeners) {
277
+ this.removeListeners();
278
+ this.removeListeners = null;
279
+ }
280
+ }
281
+ reset() {
282
+ this.socket = null;
283
+ this.buffer = [];
284
+ this.waiters = [];
285
+ this.terminalError = null;
286
+ this.closeInfo = null;
287
+ this.removeListeners = null;
288
+ }
289
+ }
290
+
291
+ export { WebSocketClient };