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