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