@hedystia/ws 2.3.3 → 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/server.cjs CHANGED
@@ -1,18 +1,245 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let ws = require("ws");
2
+ let node_crypto = require("node:crypto");
3
3
  //#region src/server.ts
4
4
  /**
5
- * Runtime-agnostic WebSocket server.
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
- * Internally backed by the [`ws`](https://github.com/websockets/ws) package
9
- * which runs on Bun, Node.js and Deno (via `npm:` specifiers). The class
10
- * does **not** create or own an HTTP server — callers feed it raw upgrade
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
- * It implements topic-based pub/sub on top of the per-connection
14
- * `subscribe` / `unsubscribe` / `publish` API expected by Hedystia,
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: (ws) => ws.send("welcome"),
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
- wss;
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 - Optional behavioural overrides ({@link WebSocketServerOptions})
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.wss = new ws.WebSocketServer({
59
- noServer: true,
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
- * The returned promise resolves to the connected {@link ServerWebSocket}
69
- * once the handshake completes; rejection means the handshake failed.
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 - Upgrade tuple emitted by `node:http`'s `'upgrade'` event
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 the underlying handshake throws synchronously
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
- this.wss.handleUpgrade(req.rawRequest, req.socket, req.head, (socket) => {
93
- const data = options?.data ?? {};
94
- const wrapped = this.wrap(socket, data, req.rawRequest);
95
- this.bind(socket, wrapped);
96
- resolve(wrapped);
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 - Topic name
107
- * @param message - Payload to broadcast
108
- * @param _compress - Reserved for future use; ignored under the `ws` adapter
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 socket of set) if (socket.readyState === 1) {
122
- socket.send(payload);
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`, calls `socket.terminate()`
131
- * on every live connection before shutting down.
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 socket of this.allSockets) try {
136
- socket.terminate();
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.wss.close();
384
+ this.topics.clear();
141
385
  }
142
386
  /**
143
- * Attach the per-socket lifecycle listeners (`message`, `close`, `error`).
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(socket, wrapped) {
148
- this.allSockets.add(socket);
149
- if (this.handlers.open) Promise.resolve(this.handlers.open(wrapped)).catch((err) => console.error("[ws] open handler error:", err));
150
- socket.on("message", (raw, isBinary) => {
151
- const message = isBinary ? raw instanceof ArrayBuffer ? new Uint8Array(raw) : Array.isArray(raw) ? Buffer.concat(raw) : raw : raw.toString();
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
- socket.on("close", (code, reason) => {
155
- const owned = this.socketTopics.get(socket);
156
- if (owned) {
157
- for (const topic of owned) this.topics.get(topic)?.delete(socket);
158
- this.socketTopics.delete(socket);
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
- socket.on("error", (err) => {
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(socket, data, rawReq) {
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(socket);
181
- let owned = this.socketTopics.get(socket);
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(socket, owned);
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(socket);
190
- this.socketTopics.get(socket)?.delete(topic);
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 !== socket && peer.readyState === 1) peer.send(payload);
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 socket.readyState;
495
+ return native.readyState;
202
496
  },
203
497
  remoteAddress,
204
498
  send: (message, _compress) => {
205
499
  const payload = toSendable(message);
206
- socket.send(payload);
500
+ native.send(payload);
207
501
  return typeof payload === "string" ? Buffer.byteLength(payload) : payload.byteLength;
208
502
  },
209
503
  close: (code, reason) => {
210
- socket.close(code, reason);
504
+ native.close(code, reason);
211
505
  },
212
506
  subscribe,
213
507
  unsubscribe,
214
508
  publish: publishToPeers,
215
- isSubscribed: (topic) => !!this.socketTopics.get(socket)?.has(topic),
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
- * Coerce a {@link WSMessage} into something the `ws` package can transmit.
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 sent
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