@culpeo/async-ws 1.0.0 → 1.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 89a662e: ### Adopt existing WebSocket connections
8
+
9
+ New static method `WebSocketClient.fromSocket(socket, options?)` wraps an already-open WebSocket (e.g. from a `WebSocketServer` connection event) into a ready-to-use `WebSocketClient`. Node.js only.
10
+
3
11
  ## 1.0.0
4
12
 
5
13
  ### Major Changes
package/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - Configurable `maxBufferSize` with oldest-message eviction when full
18
18
  - Connect timeout and `AbortSignal` support
19
19
  - Keep-alive with automatic ping/pong (Node.js)
20
+ - Server-side socket adoption with `fromSocket()` (Node.js)
20
21
  - Exposed WebSocket properties (`protocol`, `url`, `bufferedAmount`, `extensions`)
21
22
  - Clean close information via `lastCloseInfo`
22
23
  - TypeScript-first with bundled type definitions
@@ -133,7 +134,39 @@ readonly lastCloseInfo: WebSocketCloseInfo | null
133
134
 
134
135
  Returns close metadata from the most recent close event, or `null` if the socket has not closed yet.
135
136
 
136
- #### Methods
137
+ #### Static Methods
138
+
139
+ #### `fromSocket()`
140
+
141
+ ```ts
142
+ static fromSocket(rawSocket: unknown, options?: ClientOptions): WebSocketClient
143
+ ```
144
+
145
+ Wraps an already-open WebSocket into a `WebSocketClient` in the `"open"` state, ready to send and receive. Intended for server scenarios where a `WebSocketServer` hands you an established connection.
146
+
147
+ **Node.js only.** Throws in browser builds.
148
+
149
+ ```ts
150
+ import { WebSocketServer } from "ws";
151
+ import { WebSocketClient } from "@culpeo/async-ws";
152
+
153
+ const wss = new WebSocketServer({ port: 8080 });
154
+
155
+ wss.on("connection", async (socket) => {
156
+ const client = WebSocketClient.fromSocket(socket);
157
+
158
+ for await (const msg of client) {
159
+ console.log("received:", msg.data);
160
+ await client.send("echo: " + msg.data);
161
+ }
162
+ });
163
+ ```
164
+
165
+ The client takes ownership of the socket lifecycle — calling `close()` will close the underlying socket. Call `fromSocket()` immediately in the `connection` handler to avoid missing messages.
166
+
167
+ Accepts any WebSocket-compatible object (validated structurally, not via `instanceof`), so it works even when multiple copies of the `ws` package are installed.
168
+
169
+ #### Instance Methods
137
170
 
138
171
  #### `connect()`
139
172
 
@@ -405,6 +438,34 @@ await client.connect("wss://example.com/ws", {
405
438
  });
406
439
  ```
407
440
 
441
+ ## Server-Side Socket Adoption (Node.js)
442
+
443
+ Use `fromSocket()` to wrap connections from a `WebSocketServer`:
444
+
445
+ ```ts
446
+ import { WebSocketServer } from "ws";
447
+ import { WebSocketClient } from "@culpeo/async-ws";
448
+
449
+ const wss = new WebSocketServer({ port: 8080 });
450
+
451
+ wss.on("connection", async (socket) => {
452
+ const client = WebSocketClient.fromSocket(socket);
453
+
454
+ const msg = await client.receive();
455
+ await client.send("got: " + msg.data);
456
+ await client.close();
457
+ });
458
+ ```
459
+
460
+ The same `ClientOptions` are supported:
461
+
462
+ ```ts
463
+ const client = WebSocketClient.fromSocket(socket, {
464
+ maxBufferSize: 100,
465
+ keepAlive: { interval: 30000 },
466
+ });
467
+ ```
468
+
408
469
  ## Keep-Alive (Node.js)
409
470
 
410
471
  Enable automatic ping/pong to detect dead connections:
@@ -61,6 +61,10 @@ function socketPing(_socket) {
61
61
  function attachPongListener(_socket, _onPong) {
62
62
  return () => { };
63
63
  }
64
+ function adoptSocket(_rawSocket) {
65
+ throw new Error("fromSocket() is not supported in browsers. " +
66
+ "Browsers cannot accept server-side WebSocket connections.");
67
+ }
64
68
 
65
69
  /**
66
70
  * Imperative WebSocket client that works in both browser and Node.js.
@@ -93,6 +97,60 @@ class WebSocketClient {
93
97
  }
94
98
  }
95
99
  }
100
+ /**
101
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
102
+ *
103
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
104
+ * The client takes ownership of the socket lifecycle: calling `close()`
105
+ * will close the underlying socket.
106
+ *
107
+ * **Node.js only.** Throws in browser builds.
108
+ *
109
+ * Call this immediately in the server's `connection` handler to avoid
110
+ * missing messages:
111
+ *
112
+ * ```ts
113
+ * wss.on("connection", (socket) => {
114
+ * const client = WebSocketClient.fromSocket(socket);
115
+ * const msg = await client.receive();
116
+ * });
117
+ * ```
118
+ */
119
+ static fromSocket(rawSocket, options) {
120
+ const client = new WebSocketClient(options);
121
+ const socket = adoptSocket();
122
+ client.socket = socket;
123
+ setBinaryType(socket);
124
+ client.state = "open";
125
+ const currentConnectionId = ++client.connectionId;
126
+ client.removeListeners = attachListeners(socket,
127
+ // onOpen — already open, won't fire
128
+ () => { },
129
+ // onMessage
130
+ (data, binary) => {
131
+ client.enqueueMessage({ data, binary });
132
+ },
133
+ // onClose
134
+ (code, reason, wasClean) => {
135
+ if (currentConnectionId !== client.connectionId)
136
+ return;
137
+ client.closeInfo = { code, reason, wasClean };
138
+ client.state = "closed";
139
+ client.cleanup();
140
+ if (client.buffer.length === 0) {
141
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
142
+ }
143
+ },
144
+ // onError
145
+ (error) => {
146
+ if (currentConnectionId !== client.connectionId)
147
+ return;
148
+ client.terminalError = error;
149
+ client.rejectAllWaiters(error);
150
+ });
151
+ client.startKeepAlive();
152
+ return client;
153
+ }
96
154
  /** Current connection state. */
97
155
  get readyState() {
98
156
  return this.state;
@@ -75,6 +75,22 @@ function attachPongListener(socket, onPong) {
75
75
  socket.on("pong", handler);
76
76
  return () => socket.off("pong", handler);
77
77
  }
78
+ function adoptSocket(rawSocket) {
79
+ const s = rawSocket;
80
+ if (!s ||
81
+ typeof s !== "object" ||
82
+ typeof s.send !== "function" ||
83
+ typeof s.close !== "function" ||
84
+ typeof s.addEventListener !== "function" ||
85
+ typeof s.removeEventListener !== "function") {
86
+ throw new Error("Expected a WebSocket-compatible object (must have send, close, addEventListener, removeEventListener).");
87
+ }
88
+ if (s.readyState !== ws.WebSocket.OPEN) {
89
+ throw new Error("Socket must be in the OPEN state to be adopted. " +
90
+ "Call fromSocket() immediately in the server's connection handler.");
91
+ }
92
+ return rawSocket;
93
+ }
78
94
  ws.WebSocket.OPEN;
79
95
 
80
96
  /**
@@ -112,6 +128,60 @@ class WebSocketClient {
112
128
  this.keepAliveConfig = options.keepAlive;
113
129
  }
114
130
  }
131
+ /**
132
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
133
+ *
134
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
135
+ * The client takes ownership of the socket lifecycle: calling `close()`
136
+ * will close the underlying socket.
137
+ *
138
+ * **Node.js only.** Throws in browser builds.
139
+ *
140
+ * Call this immediately in the server's `connection` handler to avoid
141
+ * missing messages:
142
+ *
143
+ * ```ts
144
+ * wss.on("connection", (socket) => {
145
+ * const client = WebSocketClient.fromSocket(socket);
146
+ * const msg = await client.receive();
147
+ * });
148
+ * ```
149
+ */
150
+ static fromSocket(rawSocket, options) {
151
+ const client = new WebSocketClient(options);
152
+ const socket = adoptSocket(rawSocket);
153
+ client.socket = socket;
154
+ setBinaryType(socket);
155
+ client.state = "open";
156
+ const currentConnectionId = ++client.connectionId;
157
+ client.removeListeners = attachListeners(socket,
158
+ // onOpen — already open, won't fire
159
+ () => { },
160
+ // onMessage
161
+ (data, binary) => {
162
+ client.enqueueMessage({ data, binary });
163
+ },
164
+ // onClose
165
+ (code, reason, wasClean) => {
166
+ if (currentConnectionId !== client.connectionId)
167
+ return;
168
+ client.closeInfo = { code, reason, wasClean };
169
+ client.state = "closed";
170
+ client.cleanup();
171
+ if (client.buffer.length === 0) {
172
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
173
+ }
174
+ },
175
+ // onError
176
+ (error) => {
177
+ if (currentConnectionId !== client.connectionId)
178
+ return;
179
+ client.terminalError = error;
180
+ client.rejectAllWaiters(error);
181
+ });
182
+ client.startKeepAlive();
183
+ return client;
184
+ }
115
185
  /** Current connection state. */
116
186
  get readyState() {
117
187
  return this.state;
package/dist/esm/index.js CHANGED
@@ -73,6 +73,22 @@ function attachPongListener(socket, onPong) {
73
73
  socket.on("pong", handler);
74
74
  return () => socket.off("pong", handler);
75
75
  }
76
+ function adoptSocket(rawSocket) {
77
+ const s = rawSocket;
78
+ if (!s ||
79
+ typeof s !== "object" ||
80
+ typeof s.send !== "function" ||
81
+ typeof s.close !== "function" ||
82
+ typeof s.addEventListener !== "function" ||
83
+ typeof s.removeEventListener !== "function") {
84
+ throw new Error("Expected a WebSocket-compatible object (must have send, close, addEventListener, removeEventListener).");
85
+ }
86
+ if (s.readyState !== WebSocket.OPEN) {
87
+ throw new Error("Socket must be in the OPEN state to be adopted. " +
88
+ "Call fromSocket() immediately in the server's connection handler.");
89
+ }
90
+ return rawSocket;
91
+ }
76
92
  WebSocket.OPEN;
77
93
 
78
94
  /**
@@ -110,6 +126,60 @@ class WebSocketClient {
110
126
  this.keepAliveConfig = options.keepAlive;
111
127
  }
112
128
  }
129
+ /**
130
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
131
+ *
132
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
133
+ * The client takes ownership of the socket lifecycle: calling `close()`
134
+ * will close the underlying socket.
135
+ *
136
+ * **Node.js only.** Throws in browser builds.
137
+ *
138
+ * Call this immediately in the server's `connection` handler to avoid
139
+ * missing messages:
140
+ *
141
+ * ```ts
142
+ * wss.on("connection", (socket) => {
143
+ * const client = WebSocketClient.fromSocket(socket);
144
+ * const msg = await client.receive();
145
+ * });
146
+ * ```
147
+ */
148
+ static fromSocket(rawSocket, options) {
149
+ const client = new WebSocketClient(options);
150
+ const socket = adoptSocket(rawSocket);
151
+ client.socket = socket;
152
+ setBinaryType(socket);
153
+ client.state = "open";
154
+ const currentConnectionId = ++client.connectionId;
155
+ client.removeListeners = attachListeners(socket,
156
+ // onOpen — already open, won't fire
157
+ () => { },
158
+ // onMessage
159
+ (data, binary) => {
160
+ client.enqueueMessage({ data, binary });
161
+ },
162
+ // onClose
163
+ (code, reason, wasClean) => {
164
+ if (currentConnectionId !== client.connectionId)
165
+ return;
166
+ client.closeInfo = { code, reason, wasClean };
167
+ client.state = "closed";
168
+ client.cleanup();
169
+ if (client.buffer.length === 0) {
170
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
171
+ }
172
+ },
173
+ // onError
174
+ (error) => {
175
+ if (currentConnectionId !== client.connectionId)
176
+ return;
177
+ client.terminalError = error;
178
+ client.rejectAllWaiters(error);
179
+ });
180
+ client.startKeepAlive();
181
+ return client;
182
+ }
113
183
  /** Current connection state. */
114
184
  get readyState() {
115
185
  return this.state;
@@ -64,6 +64,10 @@ var AsyncWS = (function (exports) {
64
64
  function attachPongListener(_socket, _onPong) {
65
65
  return () => { };
66
66
  }
67
+ function adoptSocket(_rawSocket) {
68
+ throw new Error("fromSocket() is not supported in browsers. " +
69
+ "Browsers cannot accept server-side WebSocket connections.");
70
+ }
67
71
 
68
72
  /**
69
73
  * Imperative WebSocket client that works in both browser and Node.js.
@@ -96,6 +100,60 @@ var AsyncWS = (function (exports) {
96
100
  }
97
101
  }
98
102
  }
103
+ /**
104
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
105
+ *
106
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
107
+ * The client takes ownership of the socket lifecycle: calling `close()`
108
+ * will close the underlying socket.
109
+ *
110
+ * **Node.js only.** Throws in browser builds.
111
+ *
112
+ * Call this immediately in the server's `connection` handler to avoid
113
+ * missing messages:
114
+ *
115
+ * ```ts
116
+ * wss.on("connection", (socket) => {
117
+ * const client = WebSocketClient.fromSocket(socket);
118
+ * const msg = await client.receive();
119
+ * });
120
+ * ```
121
+ */
122
+ static fromSocket(rawSocket, options) {
123
+ const client = new WebSocketClient(options);
124
+ const socket = adoptSocket();
125
+ client.socket = socket;
126
+ setBinaryType(socket);
127
+ client.state = "open";
128
+ const currentConnectionId = ++client.connectionId;
129
+ client.removeListeners = attachListeners(socket,
130
+ // onOpen — already open, won't fire
131
+ () => { },
132
+ // onMessage
133
+ (data, binary) => {
134
+ client.enqueueMessage({ data, binary });
135
+ },
136
+ // onClose
137
+ (code, reason, wasClean) => {
138
+ if (currentConnectionId !== client.connectionId)
139
+ return;
140
+ client.closeInfo = { code, reason, wasClean };
141
+ client.state = "closed";
142
+ client.cleanup();
143
+ if (client.buffer.length === 0) {
144
+ client.rejectAllWaiters(new Error(`WebSocket closed (code: ${code}, reason: ${reason})`));
145
+ }
146
+ },
147
+ // onError
148
+ (error) => {
149
+ if (currentConnectionId !== client.connectionId)
150
+ return;
151
+ client.terminalError = error;
152
+ client.rejectAllWaiters(error);
153
+ });
154
+ client.startKeepAlive();
155
+ return client;
156
+ }
99
157
  /** Current connection state. */
100
158
  get readyState() {
101
159
  return this.state;
package/dist/index.d.ts CHANGED
@@ -84,6 +84,26 @@ declare class WebSocketClient {
84
84
  private readonly maxBufferSize;
85
85
  private readonly keepAliveConfig;
86
86
  constructor(options?: ClientOptions);
87
+ /**
88
+ * Adopt an already-open WebSocket (e.g. from a `WebSocketServer` connection event).
89
+ *
90
+ * Returns a `WebSocketClient` in the "open" state, ready to send/receive.
91
+ * The client takes ownership of the socket lifecycle: calling `close()`
92
+ * will close the underlying socket.
93
+ *
94
+ * **Node.js only.** Throws in browser builds.
95
+ *
96
+ * Call this immediately in the server's `connection` handler to avoid
97
+ * missing messages:
98
+ *
99
+ * ```ts
100
+ * wss.on("connection", (socket) => {
101
+ * const client = WebSocketClient.fromSocket(socket);
102
+ * const msg = await client.receive();
103
+ * });
104
+ * ```
105
+ */
106
+ static fromSocket(rawSocket: unknown, options?: ClientOptions): WebSocketClient;
87
107
  /** Current connection state. */
88
108
  get readyState(): WebSocketState;
89
109
  /** Close info from the last close event, if any. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@culpeo/async-ws",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Promise-first WebSocket client for Node.js and browsers",
5
5
  "author": "Gerardo Lecaros",
6
6
  "license": "MIT",