@hedystia/ws 2.3.2 → 2.3.4
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/dist/client.cjs +39 -29
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +36 -23
- package/dist/client.d.mts +36 -23
- package/dist/client.mjs +39 -30
- package/dist/client.mjs.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/server.cjs +571 -66
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +104 -24
- package/dist/server.d.mts +104 -24
- package/dist/server.mjs +571 -67
- package/dist/server.mjs.map +1 -1
- package/dist/types.d.cts +41 -26
- package/dist/types.d.mts +41 -26
- package/package.json +1 -5
- package/readme.md +42 -33
- package/dist/_virtual/_rolldown/runtime.mjs +0 -5
package/dist/server.mjs
CHANGED
|
@@ -1,17 +1,244 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
2
|
//#region src/server.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* WebSocket magic GUID defined in RFC 6455 section 4.2.2.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
9
|
+
const OP_CONTINUATION = 0;
|
|
10
|
+
const OP_TEXT = 1;
|
|
11
|
+
const OP_BINARY = 2;
|
|
12
|
+
const OP_CLOSE = 8;
|
|
13
|
+
const OP_PING = 9;
|
|
14
|
+
const OP_PONG = 10;
|
|
15
|
+
/**
|
|
16
|
+
* Compute the `Sec-WebSocket-Accept` response header per RFC 6455 §4.2.2.
|
|
17
|
+
*
|
|
18
|
+
* @param clientKey - Value of the `Sec-WebSocket-Key` request header
|
|
19
|
+
* @returns Base-64 encoded SHA-1 digest
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
function computeAccept(clientKey) {
|
|
24
|
+
return createHash("sha1").update(clientKey + WS_GUID).digest("base64");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Write a single unmasked (server-to-client) WebSocket data frame to a raw
|
|
28
|
+
* duplex socket per RFC 6455 §5.2.
|
|
29
|
+
*
|
|
30
|
+
* @param socket - Underlying duplex transport
|
|
31
|
+
* @param opcode - WebSocket opcode (text, binary, close, ping, pong …)
|
|
32
|
+
* @param payload - Payload bytes to encapsulate
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
function writeFrame(socket, opcode, payload) {
|
|
37
|
+
const len = payload.length;
|
|
38
|
+
let header;
|
|
39
|
+
if (len < 126) {
|
|
40
|
+
header = Buffer.allocUnsafe(2);
|
|
41
|
+
header[0] = 128 | opcode;
|
|
42
|
+
header[1] = len;
|
|
43
|
+
} else if (len < 65536) {
|
|
44
|
+
header = Buffer.allocUnsafe(4);
|
|
45
|
+
header[0] = 128 | opcode;
|
|
46
|
+
header[1] = 126;
|
|
47
|
+
header.writeUInt16BE(len, 2);
|
|
48
|
+
} else {
|
|
49
|
+
header = Buffer.allocUnsafe(10);
|
|
50
|
+
header[0] = 128 | opcode;
|
|
51
|
+
header[1] = 127;
|
|
52
|
+
header.writeUInt32BE(0, 2);
|
|
53
|
+
header.writeUInt32BE(len >>> 0, 6);
|
|
54
|
+
}
|
|
55
|
+
if (payload.length === 0) socket.write(header);
|
|
56
|
+
else socket.write(Buffer.concat([header, payload]));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Incremental streaming RFC 6455 frame parser for masked client→server frames.
|
|
60
|
+
* Handles fragmentation, ping/pong, and close frames internally.
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
var FrameParser = class {
|
|
65
|
+
buf = Buffer.alloc(0);
|
|
66
|
+
fragments = [];
|
|
67
|
+
fragmentOpcode = 0;
|
|
68
|
+
maxPayload;
|
|
69
|
+
/** Callback fired when a complete data frame is assembled. */
|
|
70
|
+
onMessage;
|
|
71
|
+
/** Callback fired when a close frame is received. */
|
|
72
|
+
onClose;
|
|
73
|
+
/** Callback fired when a ping frame is received. */
|
|
74
|
+
onPing;
|
|
75
|
+
/** Callback fired on parse errors or protocol violations. */
|
|
76
|
+
onError;
|
|
77
|
+
/**
|
|
78
|
+
* @param maxPayload - Maximum allowed frame payload in bytes (default 100 MiB)
|
|
79
|
+
*/
|
|
80
|
+
constructor(maxPayload = 100 * 1024 * 1024) {
|
|
81
|
+
this.maxPayload = maxPayload;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Feed a new chunk of raw socket data into the parser.
|
|
85
|
+
*
|
|
86
|
+
* @param chunk - Incoming bytes from the transport
|
|
87
|
+
*/
|
|
88
|
+
push(chunk) {
|
|
89
|
+
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
|
|
90
|
+
this.drain();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Consume as many complete frames as possible from the internal buffer.
|
|
94
|
+
*
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
drain() {
|
|
98
|
+
for (;;) {
|
|
99
|
+
if (this.buf.length < 2) return;
|
|
100
|
+
const b0 = this.buf[0];
|
|
101
|
+
const b1 = this.buf[1];
|
|
102
|
+
const fin = (b0 & 128) !== 0;
|
|
103
|
+
const rsv = b0 & 112;
|
|
104
|
+
const opcode = b0 & 15;
|
|
105
|
+
const masked = (b1 & 128) !== 0;
|
|
106
|
+
let payloadLen = b1 & 127;
|
|
107
|
+
let offset = 2;
|
|
108
|
+
if (rsv !== 0) {
|
|
109
|
+
this.onError?.(/* @__PURE__ */ new Error("WebSocket: RSV bits must be 0 (no extensions negotiated)"));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (payloadLen === 126) {
|
|
113
|
+
if (this.buf.length < 4) return;
|
|
114
|
+
payloadLen = this.buf.readUInt16BE(2);
|
|
115
|
+
offset = 4;
|
|
116
|
+
} else if (payloadLen === 127) {
|
|
117
|
+
if (this.buf.length < 10) return;
|
|
118
|
+
const hi = this.buf.readUInt32BE(2);
|
|
119
|
+
const lo = this.buf.readUInt32BE(6);
|
|
120
|
+
payloadLen = hi * 4294967296 + lo;
|
|
121
|
+
offset = 10;
|
|
122
|
+
}
|
|
123
|
+
if (payloadLen > this.maxPayload) {
|
|
124
|
+
this.onError?.(/* @__PURE__ */ new Error(`WebSocket: payload length ${payloadLen} exceeds maxPayload ${this.maxPayload}`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const frameEnd = offset + (masked ? 4 : 0) + payloadLen;
|
|
128
|
+
if (this.buf.length < frameEnd) return;
|
|
129
|
+
let payload;
|
|
130
|
+
if (masked) {
|
|
131
|
+
const mask = this.buf.subarray(offset, offset + 4);
|
|
132
|
+
payload = Buffer.allocUnsafe(payloadLen);
|
|
133
|
+
for (let i = 0; i < payloadLen; i++) payload[i] = this.buf[offset + 4 + i] ^ mask[i & 3];
|
|
134
|
+
} else payload = Buffer.from(this.buf.subarray(offset, frameEnd));
|
|
135
|
+
this.buf = this.buf.subarray(frameEnd);
|
|
136
|
+
this.handleFrame(fin, opcode, payload);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Route a parsed frame to the appropriate callback based on opcode.
|
|
141
|
+
*
|
|
142
|
+
* @param fin - Whether this is the final fragment
|
|
143
|
+
* @param opcode - WebSocket frame opcode
|
|
144
|
+
* @param payload - Unmasked payload bytes
|
|
145
|
+
*
|
|
146
|
+
* @internal
|
|
147
|
+
*/
|
|
148
|
+
handleFrame(fin, opcode, payload) {
|
|
149
|
+
switch (opcode) {
|
|
150
|
+
case OP_PING:
|
|
151
|
+
this.onPing?.(payload);
|
|
152
|
+
return;
|
|
153
|
+
case OP_PONG: return;
|
|
154
|
+
case OP_CLOSE: {
|
|
155
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1e3;
|
|
156
|
+
const reason = payload.length > 2 ? payload.subarray(2).toString("utf8") : "";
|
|
157
|
+
this.onClose?.(code, reason);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case OP_CONTINUATION:
|
|
161
|
+
case OP_TEXT:
|
|
162
|
+
case OP_BINARY:
|
|
163
|
+
if (opcode !== OP_CONTINUATION) this.fragmentOpcode = opcode;
|
|
164
|
+
this.fragments.push(payload);
|
|
165
|
+
if (fin) {
|
|
166
|
+
const full = Buffer.concat(this.fragments);
|
|
167
|
+
this.fragments = [];
|
|
168
|
+
const isBinary = this.fragmentOpcode === OP_BINARY;
|
|
169
|
+
this.onMessage?.(isBinary ? full : full.toString("utf8"), isBinary);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
default: this.onError?.(/* @__PURE__ */ new Error(`WebSocket: unknown opcode 0x${opcode.toString(16)}`));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Thin wrapper around a raw `Duplex` that exposes a minimal frame-aware
|
|
178
|
+
* WebSocket interface used internally by {@link WebSocketServer}.
|
|
179
|
+
*
|
|
180
|
+
* @internal
|
|
181
|
+
*/
|
|
182
|
+
var NativeSocket = class {
|
|
183
|
+
/** Underlying duplex transport stream. */
|
|
184
|
+
duplex;
|
|
185
|
+
/** Incremental frame parser attached to this transport. */
|
|
186
|
+
parser;
|
|
187
|
+
/** WHATWG-compatible ready-state: `1` open · `2` closing · `3` closed. */
|
|
188
|
+
readyState = 1;
|
|
189
|
+
/**
|
|
190
|
+
* @param duplex - Raw duplex transport
|
|
191
|
+
* @param maxPayload - Maximum allowed payload in bytes
|
|
192
|
+
*/
|
|
193
|
+
constructor(duplex, maxPayload) {
|
|
194
|
+
this.duplex = duplex;
|
|
195
|
+
this.parser = new FrameParser(maxPayload);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Send a data frame to the peer.
|
|
199
|
+
*
|
|
200
|
+
* @param data - Payload to transmit
|
|
201
|
+
*/
|
|
202
|
+
send(data) {
|
|
203
|
+
if (this.readyState !== 1) return;
|
|
204
|
+
const isBuf = Buffer.isBuffer(data);
|
|
205
|
+
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : isBuf ? data : Buffer.from(data);
|
|
206
|
+
writeFrame(this.duplex, typeof data === "string" ? OP_TEXT : OP_BINARY, buf);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Initiate the close handshake.
|
|
210
|
+
*
|
|
211
|
+
* @param code - Close status code (default `1000`)
|
|
212
|
+
* @param reason - Optional UTF-8 reason phrase
|
|
213
|
+
*/
|
|
214
|
+
close(code = 1e3, reason = "") {
|
|
215
|
+
if (this.readyState !== 1) return;
|
|
216
|
+
this.readyState = 2;
|
|
217
|
+
const reasonBuf = Buffer.from(reason, "utf8");
|
|
218
|
+
const payload = Buffer.allocUnsafe(2 + reasonBuf.length);
|
|
219
|
+
payload.writeUInt16BE(code, 0);
|
|
220
|
+
reasonBuf.copy(payload, 2);
|
|
221
|
+
writeFrame(this.duplex, OP_CLOSE, payload);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Hard-terminate the underlying transport without a close handshake.
|
|
225
|
+
*/
|
|
226
|
+
terminate() {
|
|
227
|
+
this.readyState = 3;
|
|
228
|
+
this.duplex.destroy();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Runtime-agnostic WebSocket server built entirely on Node.js built-ins
|
|
233
|
+
* (`node:crypto`, `node:stream`) — no third-party dependencies.
|
|
5
234
|
*
|
|
6
235
|
* @remarks
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* tuples coming from any HTTP runtime they prefer.
|
|
236
|
+
* The class does **not** create or own an HTTP server. Callers feed it raw
|
|
237
|
+
* upgrade tuples coming from any HTTP runtime (Node.js `http`, Bun, Deno,
|
|
238
|
+
* Hono, Fastify's upgrade hook, etc.).
|
|
11
239
|
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
* matching the shape of `Bun.ServerWebSocket`.
|
|
240
|
+
* Topic-based pub/sub is implemented in user-space, matching the shape of
|
|
241
|
+
* `Bun.ServerWebSocket` so the same handler code runs on every runtime.
|
|
15
242
|
*
|
|
16
243
|
* @typeParam Data - Shape of the user-attached `data` field
|
|
17
244
|
*
|
|
@@ -21,8 +248,9 @@ import { WebSocketServer as WebSocketServer$1 } from "ws";
|
|
|
21
248
|
* import { WebSocketServer } from "@hedystia/ws/server";
|
|
22
249
|
*
|
|
23
250
|
* const wss = new WebSocketServer({
|
|
24
|
-
* open:
|
|
251
|
+
* open: (ws) => ws.send("welcome"),
|
|
25
252
|
* message: (ws, msg) => ws.publish("room", msg),
|
|
253
|
+
* close: (ws, code) => console.log("closed", code),
|
|
26
254
|
* });
|
|
27
255
|
*
|
|
28
256
|
* const http = createServer((_req, res) => res.end("ok"));
|
|
@@ -34,7 +262,8 @@ import { WebSocketServer as WebSocketServer$1 } from "ws";
|
|
|
34
262
|
*/
|
|
35
263
|
var WebSocketServer = class {
|
|
36
264
|
handlers;
|
|
37
|
-
|
|
265
|
+
maxPayload;
|
|
266
|
+
resolveData;
|
|
38
267
|
topics = /* @__PURE__ */ new Map();
|
|
39
268
|
socketTopics = /* @__PURE__ */ new WeakMap();
|
|
40
269
|
allSockets = /* @__PURE__ */ new Set();
|
|
@@ -42,7 +271,7 @@ var WebSocketServer = class {
|
|
|
42
271
|
* Build a new WebSocket server.
|
|
43
272
|
*
|
|
44
273
|
* @param handlers - Lifecycle handlers ({@link WebSocketHandlers})
|
|
45
|
-
* @param options
|
|
274
|
+
* @param options - Optional behavioural overrides ({@link WebSocketServerOptions})
|
|
46
275
|
*
|
|
47
276
|
* @example
|
|
48
277
|
* ```ts
|
|
@@ -54,24 +283,23 @@ var WebSocketServer = class {
|
|
|
54
283
|
*/
|
|
55
284
|
constructor(handlers, options = {}) {
|
|
56
285
|
this.handlers = handlers;
|
|
57
|
-
this.
|
|
58
|
-
|
|
59
|
-
maxPayload: options.maxPayload,
|
|
60
|
-
perMessageDeflate: options.perMessageDeflate ?? false
|
|
61
|
-
});
|
|
286
|
+
this.maxPayload = options.maxPayload;
|
|
287
|
+
this.resolveData = options.resolveData;
|
|
62
288
|
}
|
|
63
289
|
/**
|
|
64
290
|
* Upgrade a raw HTTP upgrade tuple to a WebSocket connection.
|
|
65
291
|
*
|
|
66
292
|
* @remarks
|
|
67
|
-
*
|
|
68
|
-
*
|
|
293
|
+
* Performs the RFC 6455 handshake synchronously on the duplex socket,
|
|
294
|
+
* then wires up frame parsing and lifecycle handlers. The returned
|
|
295
|
+
* promise resolves to the {@link ServerWebSocket} wrapper immediately
|
|
296
|
+
* after the handshake bytes are written.
|
|
69
297
|
*
|
|
70
|
-
* @param req
|
|
298
|
+
* @param req - Upgrade tuple emitted by `node:http`'s `'upgrade'` event
|
|
71
299
|
* @param options - Optional initial `data` for the new connection
|
|
72
300
|
* @returns Promise that resolves with the established socket wrapper
|
|
73
301
|
*
|
|
74
|
-
* @throws {Error} When
|
|
302
|
+
* @throws {Error} When `Sec-WebSocket-Key` is absent from the request headers
|
|
75
303
|
*
|
|
76
304
|
* @example
|
|
77
305
|
* ```ts
|
|
@@ -88,12 +316,27 @@ var WebSocketServer = class {
|
|
|
88
316
|
upgrade(req, options) {
|
|
89
317
|
return new Promise((resolve, reject) => {
|
|
90
318
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
319
|
+
const rawReq = req.rawRequest;
|
|
320
|
+
const duplex = req.socket;
|
|
321
|
+
const clientKey = rawReq.headers?.["sec-websocket-key"];
|
|
322
|
+
if (!clientKey) {
|
|
323
|
+
duplex.destroy();
|
|
324
|
+
return reject(/* @__PURE__ */ new Error("WebSocket upgrade: missing Sec-WebSocket-Key header"));
|
|
325
|
+
}
|
|
326
|
+
const accept = computeAccept(clientKey);
|
|
327
|
+
const protocol = (rawReq.headers?.["sec-websocket-protocol"])?.split(",")[0]?.trim();
|
|
328
|
+
let response = `HTTP/1.1 101 Switching Protocols\r
|
|
329
|
+
Upgrade: websocket\r
|
|
330
|
+
Connection: Upgrade\r
|
|
331
|
+
Sec-WebSocket-Accept: ${accept}\r\n`;
|
|
332
|
+
if (protocol) response += `Sec-WebSocket-Protocol: ${protocol}\r\n`;
|
|
333
|
+
response += "\r\n";
|
|
334
|
+
duplex.write(response);
|
|
335
|
+
const native = new NativeSocket(duplex, this.maxPayload);
|
|
336
|
+
const data = options?.data ?? {};
|
|
337
|
+
const wrapped = this.wrap(native, data, rawReq);
|
|
338
|
+
this.bind(native, wrapped, req.head);
|
|
339
|
+
resolve(wrapped);
|
|
97
340
|
} catch (err) {
|
|
98
341
|
reject(err);
|
|
99
342
|
}
|
|
@@ -102,10 +345,10 @@ var WebSocketServer = class {
|
|
|
102
345
|
/**
|
|
103
346
|
* Publish a message to all sockets currently subscribed to `topic`.
|
|
104
347
|
*
|
|
105
|
-
* @param topic
|
|
106
|
-
* @param message
|
|
107
|
-
* @param _compress
|
|
108
|
-
* @returns Number of sockets that received the message
|
|
348
|
+
* @param topic - Topic name
|
|
349
|
+
* @param message - Payload to broadcast
|
|
350
|
+
* @param _compress - Reserved for future use
|
|
351
|
+
* @returns Number of sockets that received the message
|
|
109
352
|
*
|
|
110
353
|
* @example
|
|
111
354
|
* ```ts
|
|
@@ -117,8 +360,8 @@ var WebSocketServer = class {
|
|
|
117
360
|
if (!set || set.size === 0) return 0;
|
|
118
361
|
const payload = toSendable(message);
|
|
119
362
|
let count = 0;
|
|
120
|
-
for (const
|
|
121
|
-
|
|
363
|
+
for (const native of set) if (native.readyState === 1) {
|
|
364
|
+
native.send(payload);
|
|
122
365
|
count++;
|
|
123
366
|
}
|
|
124
367
|
return count;
|
|
@@ -126,49 +369,100 @@ var WebSocketServer = class {
|
|
|
126
369
|
/**
|
|
127
370
|
* Close the server and optionally terminate all live sockets.
|
|
128
371
|
*
|
|
129
|
-
* @param closeActiveConnections - When `true`,
|
|
130
|
-
*
|
|
372
|
+
* @param closeActiveConnections - When `true`, immediately terminates
|
|
373
|
+
* every live connection before clearing internal state
|
|
131
374
|
*/
|
|
132
375
|
close(closeActiveConnections = false) {
|
|
133
376
|
if (closeActiveConnections) {
|
|
134
|
-
for (const
|
|
135
|
-
|
|
377
|
+
for (const native of this.allSockets) try {
|
|
378
|
+
if (typeof native.terminate === "function") native.terminate();
|
|
379
|
+
else if (typeof native.close === "function") native.close(1001, "Server shutdown");
|
|
136
380
|
} catch {}
|
|
137
381
|
this.allSockets.clear();
|
|
138
382
|
}
|
|
139
|
-
this.
|
|
383
|
+
this.topics.clear();
|
|
140
384
|
}
|
|
141
385
|
/**
|
|
142
|
-
*
|
|
386
|
+
* Wire up the duplex transport listeners (`data`, `close`, `error`) and
|
|
387
|
+
* invoke the user's `open` handler.
|
|
388
|
+
*
|
|
389
|
+
* @param native - Wrapped transport socket
|
|
390
|
+
* @param wrapped - Public {@link ServerWebSocket} wrapper
|
|
391
|
+
* @param head - Buffered bytes captured by the HTTP parser (re-fed into the parser)
|
|
143
392
|
*
|
|
144
393
|
* @internal
|
|
145
394
|
*/
|
|
146
|
-
bind(
|
|
147
|
-
this.allSockets.add(
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
const message = isBinary ? raw instanceof
|
|
395
|
+
bind(native, wrapped, head) {
|
|
396
|
+
this.allSockets.add(native);
|
|
397
|
+
if (head && head.length > 0) native.parser.push(Buffer.isBuffer(head) ? head : Buffer.from(head));
|
|
398
|
+
native.parser.onMessage = (raw, isBinary) => {
|
|
399
|
+
const message = isBinary ? raw instanceof Buffer ? raw : Buffer.from(raw) : raw;
|
|
151
400
|
Promise.resolve(this.handlers.message(wrapped, message)).catch((err) => console.error("[ws] message handler error:", err));
|
|
401
|
+
};
|
|
402
|
+
native.parser.onClose = (code, reason) => {
|
|
403
|
+
if (native.readyState === 1) native.close(code, reason);
|
|
404
|
+
this.cleanup(native);
|
|
405
|
+
if (this.handlers.close) Promise.resolve(this.handlers.close(wrapped, code, reason)).catch((err) => console.error("[ws] close handler error:", err));
|
|
406
|
+
};
|
|
407
|
+
native.parser.onPing = (data) => {
|
|
408
|
+
if (native.readyState === 1) writeFrame(native.duplex, OP_PONG, data);
|
|
409
|
+
};
|
|
410
|
+
native.parser.onError = (err) => {
|
|
411
|
+
native.terminate();
|
|
412
|
+
this.cleanup(native);
|
|
413
|
+
if (this.handlers.error) Promise.resolve(this.handlers.error(wrapped, err)).catch((e) => console.error("[ws] error handler error:", e));
|
|
414
|
+
};
|
|
415
|
+
native.duplex.on("data", (chunk) => {
|
|
416
|
+
try {
|
|
417
|
+
native.parser.push(chunk);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.error("[ws] frame parsing error:", err);
|
|
420
|
+
native.terminate();
|
|
421
|
+
this.cleanup(native);
|
|
422
|
+
}
|
|
152
423
|
});
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.
|
|
424
|
+
native.duplex.on("close", () => {
|
|
425
|
+
if (native.readyState !== 3) {
|
|
426
|
+
native.readyState = 3;
|
|
427
|
+
this.cleanup(native);
|
|
428
|
+
if (this.handlers.close) Promise.resolve(this.handlers.close(wrapped, 1006, "")).catch((err) => console.error("[ws] close handler error:", err));
|
|
158
429
|
}
|
|
159
|
-
this.allSockets.delete(socket);
|
|
160
|
-
if (this.handlers.close) Promise.resolve(this.handlers.close(wrapped, code, reason?.toString() ?? "")).catch((err) => console.error("[ws] close handler error:", err));
|
|
161
430
|
});
|
|
162
|
-
|
|
431
|
+
native.duplex.on("error", (err) => {
|
|
432
|
+
native.readyState = 3;
|
|
433
|
+
this.cleanup(native);
|
|
163
434
|
if (this.handlers.error) Promise.resolve(this.handlers.error(wrapped, err)).catch((e) => console.error("[ws] error handler error:", e));
|
|
164
435
|
});
|
|
436
|
+
if (this.handlers.open) Promise.resolve(this.handlers.open(wrapped)).catch((err) => console.error("[ws] open handler error:", err));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Remove a socket from every topic it had joined and from the global
|
|
440
|
+
* tracking set.
|
|
441
|
+
*
|
|
442
|
+
* @param native - Socket being cleaned up
|
|
443
|
+
*
|
|
444
|
+
* @internal
|
|
445
|
+
*/
|
|
446
|
+
cleanup(native) {
|
|
447
|
+
const owned = this.socketTopics.get(native);
|
|
448
|
+
if (owned) {
|
|
449
|
+
for (const topic of owned) this.topics.get(topic)?.delete(native);
|
|
450
|
+
this.socketTopics.delete(native);
|
|
451
|
+
}
|
|
452
|
+
this.allSockets.delete(native);
|
|
165
453
|
}
|
|
166
454
|
/**
|
|
167
|
-
* Build the {@link ServerWebSocket} wrapper exposed to user handlers
|
|
455
|
+
* Build the {@link ServerWebSocket} wrapper exposed to user handlers,
|
|
456
|
+
* embedding topic-based pub/sub backed by the server's internal maps.
|
|
457
|
+
*
|
|
458
|
+
* @param native - Wrapped transport socket
|
|
459
|
+
* @param data - User-supplied `data` payload attached on upgrade
|
|
460
|
+
* @param rawReq - Raw incoming message used to extract the remote address
|
|
461
|
+
* @returns The fully-featured public wrapper
|
|
168
462
|
*
|
|
169
463
|
* @internal
|
|
170
464
|
*/
|
|
171
|
-
wrap(
|
|
465
|
+
wrap(native, data, rawReq) {
|
|
172
466
|
const remoteAddress = rawReq?.socket?.remoteAddress || rawReq?.headers?.["x-forwarded-for"] || "";
|
|
173
467
|
const subscribe = (topic) => {
|
|
174
468
|
let set = this.topics.get(topic);
|
|
@@ -176,52 +470,262 @@ var WebSocketServer = class {
|
|
|
176
470
|
set = /* @__PURE__ */ new Set();
|
|
177
471
|
this.topics.set(topic, set);
|
|
178
472
|
}
|
|
179
|
-
set.add(
|
|
180
|
-
let owned = this.socketTopics.get(
|
|
473
|
+
set.add(native);
|
|
474
|
+
let owned = this.socketTopics.get(native);
|
|
181
475
|
if (!owned) {
|
|
182
476
|
owned = /* @__PURE__ */ new Set();
|
|
183
|
-
this.socketTopics.set(
|
|
477
|
+
this.socketTopics.set(native, owned);
|
|
184
478
|
}
|
|
185
479
|
owned.add(topic);
|
|
186
480
|
};
|
|
187
481
|
const unsubscribe = (topic) => {
|
|
188
|
-
this.topics.get(topic)?.delete(
|
|
189
|
-
this.socketTopics.get(
|
|
482
|
+
this.topics.get(topic)?.delete(native);
|
|
483
|
+
this.socketTopics.get(native)?.delete(topic);
|
|
190
484
|
};
|
|
191
485
|
const publishToPeers = (topic, message) => {
|
|
192
486
|
const set = this.topics.get(topic);
|
|
193
487
|
if (!set) return;
|
|
194
488
|
const payload = toSendable(message);
|
|
195
|
-
for (const peer of set) if (peer !==
|
|
489
|
+
for (const peer of set) if (peer !== native && peer.readyState === 1) peer.send(payload);
|
|
196
490
|
};
|
|
197
491
|
const wrapper = {
|
|
198
492
|
data,
|
|
199
493
|
get readyState() {
|
|
200
|
-
return
|
|
494
|
+
return native.readyState;
|
|
201
495
|
},
|
|
202
496
|
remoteAddress,
|
|
203
497
|
send: (message, _compress) => {
|
|
204
498
|
const payload = toSendable(message);
|
|
205
|
-
|
|
499
|
+
native.send(payload);
|
|
206
500
|
return typeof payload === "string" ? Buffer.byteLength(payload) : payload.byteLength;
|
|
207
501
|
},
|
|
208
502
|
close: (code, reason) => {
|
|
209
|
-
|
|
503
|
+
native.close(code, reason);
|
|
210
504
|
},
|
|
211
505
|
subscribe,
|
|
212
506
|
unsubscribe,
|
|
213
507
|
publish: publishToPeers,
|
|
214
|
-
isSubscribed: (topic) => !!this.socketTopics.get(
|
|
508
|
+
isSubscribed: (topic) => !!this.socketTopics.get(native)?.has(topic),
|
|
215
509
|
cork: (cb) => cb(wrapper)
|
|
216
510
|
};
|
|
217
511
|
return wrapper;
|
|
218
512
|
}
|
|
219
513
|
};
|
|
220
514
|
/**
|
|
221
|
-
*
|
|
515
|
+
* Start a standalone WebSocket server on the given port.
|
|
516
|
+
*
|
|
517
|
+
* @remarks
|
|
518
|
+
* Auto-detects the runtime and uses the native implementation:
|
|
519
|
+
* - **Bun:** delegates to `Bun.serve()` with native WebSocket support
|
|
520
|
+
* - **Node/Deno:** creates a `node:http` server with the built-in
|
|
521
|
+
* {@link WebSocketServer} upgrade handler
|
|
522
|
+
*
|
|
523
|
+
* @typeParam Data - Shape of the user-attached `data` field
|
|
524
|
+
*
|
|
525
|
+
* @param handlers - Lifecycle handlers ({@link WebSocketHandlers})
|
|
526
|
+
* @param options - Server options including `port` and `hostname`
|
|
527
|
+
* @returns A promise resolving to {@link ServeInfo}
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* ```ts
|
|
531
|
+
* import { serve } from "@hedystia/ws";
|
|
532
|
+
*
|
|
533
|
+
* const server = await serve({
|
|
534
|
+
* open: (ws) => ws.subscribe("global"),
|
|
535
|
+
* message: (ws, msg) => ws.publish("global", msg),
|
|
536
|
+
* });
|
|
537
|
+
*
|
|
538
|
+
* console.log(`Listening on ${server.url}`);
|
|
539
|
+
* ```
|
|
540
|
+
*/
|
|
541
|
+
async function serve(handlers, options) {
|
|
542
|
+
const { detectRuntime } = await import("./runtime.mjs");
|
|
543
|
+
if (detectRuntime() === "bun") return serveBun(handlers, options);
|
|
544
|
+
return serveNode(handlers, options);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Start a WebSocket server using Bun's native `Bun.serve()`.
|
|
548
|
+
*
|
|
549
|
+
* @typeParam Data - Shape of the user-attached `data` field
|
|
550
|
+
* @param handlers - Lifecycle handlers
|
|
551
|
+
* @param options - Server options including `port` and `hostname`
|
|
552
|
+
* @returns A promise resolving to {@link ServeInfo}
|
|
553
|
+
*
|
|
554
|
+
* @internal
|
|
555
|
+
*/
|
|
556
|
+
async function serveBun(handlers, options) {
|
|
557
|
+
const topics = /* @__PURE__ */ new Map();
|
|
558
|
+
const socketTopics = /* @__PURE__ */ new WeakMap();
|
|
559
|
+
const allSockets = /* @__PURE__ */ new Set();
|
|
560
|
+
const nativeToWrapped = /* @__PURE__ */ new WeakMap();
|
|
561
|
+
function cleanup(ws) {
|
|
562
|
+
const owned = socketTopics.get(ws);
|
|
563
|
+
if (owned) {
|
|
564
|
+
for (const topic of owned) topics.get(topic)?.delete(ws);
|
|
565
|
+
socketTopics.delete(ws);
|
|
566
|
+
}
|
|
567
|
+
allSockets.delete(ws);
|
|
568
|
+
}
|
|
569
|
+
const server = globalThis.Bun.serve({
|
|
570
|
+
port: options?.port ?? 0,
|
|
571
|
+
hostname: options?.hostname ?? "0.0.0.0",
|
|
572
|
+
fetch: (req) => {
|
|
573
|
+
if (req.headers.get("upgrade") === "websocket") {
|
|
574
|
+
const data = options?.resolveData ? options.resolveData(req) : {};
|
|
575
|
+
return server.upgrade(req, { data }) ? void 0 : new Response("upgrade failed", { status: 400 });
|
|
576
|
+
}
|
|
577
|
+
return new Response("Not found", { status: 404 });
|
|
578
|
+
},
|
|
579
|
+
websocket: {
|
|
580
|
+
open: (ws) => {
|
|
581
|
+
const wrapped = createBunWrapper(ws, ws.data ?? {}, topics, socketTopics);
|
|
582
|
+
nativeToWrapped.set(ws, wrapped);
|
|
583
|
+
allSockets.add(wrapped);
|
|
584
|
+
if (handlers.open) Promise.resolve(handlers.open(wrapped)).catch((err) => console.error("[ws] open handler error:", err));
|
|
585
|
+
},
|
|
586
|
+
message: (ws, msg) => {
|
|
587
|
+
const wrapped = nativeToWrapped.get(ws);
|
|
588
|
+
if (wrapped) Promise.resolve(handlers.message(wrapped, msg)).catch((err) => console.error("[ws] message handler error:", err));
|
|
589
|
+
},
|
|
590
|
+
close: (ws, code, reason) => {
|
|
591
|
+
const wrapped = nativeToWrapped.get(ws);
|
|
592
|
+
if (wrapped) {
|
|
593
|
+
cleanup(wrapped);
|
|
594
|
+
if (handlers.close) Promise.resolve(handlers.close(wrapped, code, reason)).catch((err) => console.error("[ws] close handler error:", err));
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
drain: handlers.drain ? (ws) => {
|
|
598
|
+
const wrapped = nativeToWrapped.get(ws);
|
|
599
|
+
if (wrapped && handlers.drain) Promise.resolve(handlers.drain(wrapped)).catch((err) => console.error("[ws] drain handler error:", err));
|
|
600
|
+
} : void 0
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
return {
|
|
604
|
+
port: server.port,
|
|
605
|
+
hostname: server.hostname,
|
|
606
|
+
url: new URL(`http://${server.hostname}:${server.port}/`),
|
|
607
|
+
publish: (topic, message, _compress) => {
|
|
608
|
+
const set = topics.get(topic);
|
|
609
|
+
if (!set || set.size === 0) return 0;
|
|
610
|
+
const payload = toSendable(message);
|
|
611
|
+
let count = 0;
|
|
612
|
+
for (const ws of set) if (ws.readyState === 1) {
|
|
613
|
+
ws.send(payload);
|
|
614
|
+
count++;
|
|
615
|
+
}
|
|
616
|
+
return count;
|
|
617
|
+
},
|
|
618
|
+
stop: (closeActiveConnections) => {
|
|
619
|
+
if (closeActiveConnections) {
|
|
620
|
+
for (const ws of allSockets) try {
|
|
621
|
+
ws.close(1001, "Server shutdown");
|
|
622
|
+
} catch {}
|
|
623
|
+
allSockets.clear();
|
|
624
|
+
}
|
|
625
|
+
server.stop(closeActiveConnections);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Wrap a native Bun WebSocket into a {@link ServerWebSocket} compatible with
|
|
631
|
+
* the public handler interface.
|
|
632
|
+
*
|
|
633
|
+
* @typeParam Data - Shape of the user-attached `data` field
|
|
634
|
+
* @param ws - Raw Bun WebSocket
|
|
635
|
+
* @param data - User-supplied data payload
|
|
636
|
+
* @param topics - Global topic → socket set map
|
|
637
|
+
* @param socketTopics - Reverse lookup for per-socket topic membership
|
|
638
|
+
* @returns A {@link ServerWebSocket} wrapper
|
|
639
|
+
*
|
|
640
|
+
* @internal
|
|
641
|
+
*/
|
|
642
|
+
function createBunWrapper(ws, data, topics, socketTopics) {
|
|
643
|
+
const wrapper = {
|
|
644
|
+
data,
|
|
645
|
+
get readyState() {
|
|
646
|
+
return ws.readyState;
|
|
647
|
+
},
|
|
648
|
+
get remoteAddress() {
|
|
649
|
+
return ws.remoteAddress || "";
|
|
650
|
+
},
|
|
651
|
+
send: (message, _compress) => {
|
|
652
|
+
ws.send(message);
|
|
653
|
+
return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
654
|
+
},
|
|
655
|
+
close: (code, reason) => ws.close(code, reason),
|
|
656
|
+
subscribe: (topic) => {
|
|
657
|
+
let set = topics.get(topic);
|
|
658
|
+
if (!set) {
|
|
659
|
+
set = /* @__PURE__ */ new Set();
|
|
660
|
+
topics.set(topic, set);
|
|
661
|
+
}
|
|
662
|
+
set.add(wrapper);
|
|
663
|
+
let owned = socketTopics.get(wrapper);
|
|
664
|
+
if (!owned) {
|
|
665
|
+
owned = /* @__PURE__ */ new Set();
|
|
666
|
+
socketTopics.set(wrapper, owned);
|
|
667
|
+
}
|
|
668
|
+
owned.add(topic);
|
|
669
|
+
},
|
|
670
|
+
unsubscribe: (topic) => {
|
|
671
|
+
topics.get(topic)?.delete(wrapper);
|
|
672
|
+
socketTopics.get(wrapper)?.delete(topic);
|
|
673
|
+
},
|
|
674
|
+
publish: (topic, message, _compress) => {
|
|
675
|
+
const set = topics.get(topic);
|
|
676
|
+
if (!set) return;
|
|
677
|
+
const payload = toSendable(message);
|
|
678
|
+
for (const peer of set) if (peer !== wrapper && peer.readyState === 1) peer.send(payload);
|
|
679
|
+
},
|
|
680
|
+
isSubscribed: (topic) => !!socketTopics.get(wrapper)?.has(topic),
|
|
681
|
+
cork: (cb) => cb(wrapper)
|
|
682
|
+
};
|
|
683
|
+
return wrapper;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Start a WebSocket server using Node.js `node:http` + the built-in
|
|
687
|
+
* {@link WebSocketServer} upgrade handler.
|
|
688
|
+
*
|
|
689
|
+
* @typeParam Data - Shape of the user-attached `data` field
|
|
690
|
+
* @param handlers - Lifecycle handlers
|
|
691
|
+
* @param options - Server options including `port` and `hostname`
|
|
692
|
+
* @returns A promise resolving to {@link ServeInfo}
|
|
693
|
+
*
|
|
694
|
+
* @internal
|
|
695
|
+
*/
|
|
696
|
+
async function serveNode(handlers, options) {
|
|
697
|
+
const { createServer: createHttpServer } = await import("node:http");
|
|
698
|
+
const wss = new WebSocketServer(handlers, options);
|
|
699
|
+
const httpServer = createHttpServer((_req, res) => {
|
|
700
|
+
res.writeHead(404);
|
|
701
|
+
res.end("Not found");
|
|
702
|
+
});
|
|
703
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
704
|
+
const data = options?.resolveData ? options.resolveData(req) : void 0;
|
|
705
|
+
wss.upgrade({
|
|
706
|
+
rawRequest: req,
|
|
707
|
+
socket,
|
|
708
|
+
head
|
|
709
|
+
}, data ? { data } : void 0).catch(() => socket.destroy());
|
|
710
|
+
});
|
|
711
|
+
await new Promise((resolve) => httpServer.listen(options?.port ?? 0, options?.hostname ?? "0.0.0.0", resolve));
|
|
712
|
+
const port = httpServer.address()?.port ?? 0;
|
|
713
|
+
return {
|
|
714
|
+
port,
|
|
715
|
+
hostname: options?.hostname ?? "0.0.0.0",
|
|
716
|
+
url: new URL(`http://${options?.hostname ?? "0.0.0.0"}:${port}/`),
|
|
717
|
+
publish: (topic, message, _compress) => wss.publish(topic, message, _compress),
|
|
718
|
+
stop: (closeActiveConnections) => {
|
|
719
|
+
wss.close(closeActiveConnections);
|
|
720
|
+
httpServer.close();
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Coerce a {@link WSMessage} into a form that {@link NativeSocket.send} accepts.
|
|
222
726
|
*
|
|
223
727
|
* @param message - User-supplied payload
|
|
224
|
-
* @returns A `string`, `Buffer` or `Uint8Array` ready to be
|
|
728
|
+
* @returns A `string`, `Buffer` or `Uint8Array` ready to be framed
|
|
225
729
|
*
|
|
226
730
|
* @internal
|
|
227
731
|
*/
|
|
@@ -231,6 +735,6 @@ function toSendable(message) {
|
|
|
231
735
|
return message;
|
|
232
736
|
}
|
|
233
737
|
//#endregion
|
|
234
|
-
export { WebSocketServer };
|
|
738
|
+
export { WebSocketServer, serve };
|
|
235
739
|
|
|
236
740
|
//# sourceMappingURL=server.mjs.map
|