@orkestrel/websocket 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orkestrel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @orkestrel/websocket
2
+
3
+ A dependency-light RFC 6455 WebSocket for Node — native handshake and framing
4
+ over duplex streams, with a typed emitter surface. Part of the `@orkestrel`
5
+ line. Its sole runtime dependency is `@orkestrel/emitter`, used for the typed
6
+ `emitter` every connection exposes.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install @orkestrel/websocket
12
+ ```
13
+
14
+ ## Requirements
15
+
16
+ - Node.js >= 24
17
+ - Ships dual ESM + CommonJS builds (see `exports` in `package.json`)
18
+ - Server-only — a single Node-native surface
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { createServer } from 'node:http'
24
+ import { createNodeWebSocket } from '@orkestrel/websocket'
25
+
26
+ // A node:http server hands every upgrade request a raw socket; this wrapper takes it
27
+ // from there. Passing the client's `sec-websocket-key` selects SERVER mode — the
28
+ // wrapper writes the 101 handshake, marks the connection open, and decodes frames.
29
+ createServer().on('upgrade', (request, socket, head) => {
30
+ const ws = createNodeWebSocket({
31
+ socket,
32
+ key: request.headers['sec-websocket-key'], // present => server mode + 101 handshake
33
+ head, // any bytes that arrived bundled with the upgrade request
34
+ on: { message: (text) => ws.send(`echo: ${text}`) }, // wire listeners at construction
35
+ })
36
+
37
+ ws.emitter.on('close', (code, reason) => console.log('closed', code, reason))
38
+ })
39
+ ```
40
+
41
+ ## Guide
42
+
43
+ The full API — factories, the `NodeWebSocket` class, the pure codec helpers,
44
+ constants, and types — is documented in
45
+ [`guides/src/websocket.md`](https://github.com/orkestrel/websocket/blob/main/guides/src/websocket.md).
46
+
47
+ ## Package
48
+
49
+ Published as a single Node-only surface per the `exports` field in
50
+ `package.json` — one `.` entry backed by dual ESM (`.js`) and CommonJS
51
+ (`.cjs`) builds of `src/server`.
52
+
53
+ ## License
54
+
55
+ MIT © [Orkestrel](https://github.com/orkestrel) — see [LICENSE](./LICENSE).
@@ -0,0 +1,608 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_crypto = require("node:crypto");
3
+ let _orkestrel_emitter = require("@orkestrel/emitter");
4
+ //#region src/server/constants.ts
5
+ /**
6
+ * The RFC 6455 GUID concatenated to a client's `Sec-WebSocket-Key` before the SHA-1
7
+ * hash that yields the `Sec-WebSocket-Accept` response value.
8
+ *
9
+ * @remarks
10
+ * A fixed, spec-mandated constant (RFC 6455 §4.2.2) — read only by
11
+ * {@link computeWebSocketAccept}.
12
+ */
13
+ var WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
14
+ /** The WebSocket protocol version this wrapper speaks (`Sec-WebSocket-Version: 13`). */
15
+ var WEBSOCKET_VERSION = "13";
16
+ /** Text frame opcode — a UTF-8 payload (RFC 6455 §5.6). */
17
+ var WEBSOCKET_OPCODE_TEXT = 1;
18
+ /** Binary frame opcode — a raw byte payload (RFC 6455 §5.6). */
19
+ var WEBSOCKET_OPCODE_BINARY = 2;
20
+ /** Close frame opcode — a control frame ending the connection (RFC 6455 §5.5.1). */
21
+ var WEBSOCKET_OPCODE_CLOSE = 8;
22
+ /** Ping frame opcode — a control frame the peer must answer with a pong (RFC 6455 §5.5.2). */
23
+ var WEBSOCKET_OPCODE_PING = 9;
24
+ /** Pong frame opcode — a control frame answering a ping (RFC 6455 §5.5.3). */
25
+ var WEBSOCKET_OPCODE_PONG = 10;
26
+ /** Ready state for a connecting WebSocket (before the handshake completes). */
27
+ var WEBSOCKET_READY_CONNECTING = 0;
28
+ /** Ready state for an open WebSocket (the handshake completed; frames flow). */
29
+ var WEBSOCKET_READY_OPEN = 1;
30
+ /** Ready state for a closing WebSocket (a close frame was sent or received). */
31
+ var WEBSOCKET_READY_CLOSING = 2;
32
+ /** Ready state for a closed WebSocket (the socket ended). */
33
+ var WEBSOCKET_READY_CLOSED = 3;
34
+ /** Normal-closure status code (RFC 6455 §7.4.1) — the default `close` code. */
35
+ var WEBSOCKET_CLOSE_NORMAL = 1e3;
36
+ /** Protocol-error status code (RFC 6455 §7.4.1) — a framing/state rule was violated. */
37
+ var WEBSOCKET_CLOSE_PROTOCOL = 1002;
38
+ /** Unsupported-data status code (RFC 6455 §7.4.1) — the endpoint received a data type it cannot accept (e.g. binary on a text-only endpoint). */
39
+ var WEBSOCKET_CLOSE_UNSUPPORTED = 1003;
40
+ /** Invalid-frame-payload-data status code (RFC 6455 §7.4.1) — e.g. non-UTF-8 text or an unparseable close reason. */
41
+ var WEBSOCKET_CLOSE_INVALID = 1007;
42
+ /** Message-too-big status code (RFC 6455 §7.4.1) — a reassembled message exceeded the payload cap. */
43
+ var WEBSOCKET_CLOSE_TOOBIG = 1009;
44
+ /** The default maximum inbound single-frame length AND reassembled-message total byte count (100 MiB — the `ws` package default). */
45
+ var WEBSOCKET_MAX_PAYLOAD = 104857600;
46
+ /** The default close-handshake timeout in milliseconds — how long `close()` waits for the peer's echo before tearing the socket down. */
47
+ var WEBSOCKET_CLOSE_TIMEOUT_MS = 3e4;
48
+ /** The post-`#fail` flush grace in milliseconds — how long a validation-breach close frame is given to flush through the socket's write buffer before the hard `destroy()` fallback fires (the normal path destroys sooner, on the `end()` flush callback). */
49
+ var WEBSOCKET_FAIL_TIMEOUT_MS = 1e3;
50
+ /** The maximum control-frame payload length in bytes (RFC 6455 §5.5). */
51
+ var WEBSOCKET_CONTROL_MAXLEN = 125;
52
+ //#endregion
53
+ //#region src/server/helpers.ts
54
+ /**
55
+ * Compute the `Sec-WebSocket-Accept` response value for an RFC 6455 upgrade.
56
+ *
57
+ * @remarks
58
+ * The base64-encoded SHA-1 of the client's `Sec-WebSocket-Key` concatenated with the
59
+ * fixed {@link WEBSOCKET_GUID} (RFC 6455 §4.2.2) — the proof the server understood the
60
+ * handshake. Pure and deterministic.
61
+ *
62
+ * @param key - The client's `Sec-WebSocket-Key` header value
63
+ * @returns The base64 accept token to send back as `Sec-WebSocket-Accept`
64
+ */
65
+ function computeWebSocketAccept(key) {
66
+ return (0, node_crypto.createHash)("sha1").update(key + WEBSOCKET_GUID).digest("base64");
67
+ }
68
+ /**
69
+ * Decode a single RFC 6455 frame from the front of a buffer.
70
+ *
71
+ * @remarks
72
+ * Reads the FIN bit and opcode (byte 0), the mask bit and 7-bit payload length (byte
73
+ * 1) — extended to a 16-bit length when the 7-bit field is `126`, or a 64-bit length
74
+ * when it is `127` — the optional 4-byte mask key, then the payload, XOR-unmasking it
75
+ * against the key when the mask bit is set (client→server frames MUST be masked, RFC
76
+ * 6455 §5.3; an unmasked frame still decodes, leaving the payload as-is, so the caller
77
+ * can enforce policy). Returns `undefined` the moment the buffer is too short for the
78
+ * part it is up to (the length prefix, the mask, or the full payload) — the signal to
79
+ * the caller to read more bytes and retry, exactly like {@link SSEParser} on a partial
80
+ * line. `consumed` is the total bytes the frame occupied, so the caller slices the
81
+ * remainder. Pure; never throws on a short buffer.
82
+ *
83
+ * @param buffer - The accumulation buffer to decode the next frame from
84
+ * @returns The parsed {@link WebSocketFrame}, or `undefined` when the buffer is incomplete
85
+ */
86
+ function parseWebSocketFrame(buffer) {
87
+ if (buffer.length < 2) return void 0;
88
+ const firstByte = buffer[0] ?? 0;
89
+ const secondByte = buffer[1] ?? 0;
90
+ const fin = (firstByte & 128) !== 0;
91
+ const rsv = (firstByte & 112) >> 4;
92
+ const opcode = firstByte & 15;
93
+ const masked = (secondByte & 128) !== 0;
94
+ let length = secondByte & 127;
95
+ let offset = 2;
96
+ if (length === 126) {
97
+ if (buffer.length < offset + 2) return void 0;
98
+ length = buffer.readUInt16BE(offset);
99
+ offset += 2;
100
+ } else if (length === 127) {
101
+ if (buffer.length < offset + 8) return void 0;
102
+ const high = buffer.readUInt32BE(offset);
103
+ const low = buffer.readUInt32BE(offset + 4);
104
+ length = high * 4294967296 + low;
105
+ offset += 8;
106
+ }
107
+ let mask;
108
+ if (masked) {
109
+ if (buffer.length < offset + 4) return void 0;
110
+ mask = buffer.subarray(offset, offset + 4);
111
+ offset += 4;
112
+ }
113
+ if (buffer.length < offset + length) return void 0;
114
+ const payload = Buffer.alloc(length);
115
+ buffer.copy(payload, 0, offset, offset + length);
116
+ if (mask !== void 0) for (let index = 0; index < length; index += 1) payload[index] = (payload[index] ?? 0) ^ (mask[index % 4] ?? 0);
117
+ return {
118
+ fin,
119
+ opcode,
120
+ payload,
121
+ consumed: offset + length,
122
+ masked,
123
+ rsv
124
+ };
125
+ }
126
+ /**
127
+ * Read the declared payload length off the front of a buffer, without buffering or
128
+ * reading the payload itself.
129
+ *
130
+ * @remarks
131
+ * Decodes only byte 1's 7-bit length field, extended by the 16-bit (`126`) or 64-bit
132
+ * (`127`) form exactly like {@link parseWebSocketFrame} — but stops there, so a caller
133
+ * can reject an over-cap frame the moment its length is known, before the payload
134
+ * bytes have even arrived. Returns `undefined` until the length field itself is fully
135
+ * buffered (mirrors the parser's incomplete-buffer contract). Pure; never throws.
136
+ *
137
+ * @param buffer - The accumulation buffer to read the next frame's length from
138
+ * @returns The declared payload length, or `undefined` when the buffer is too short to know it yet
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * const declared = measureWebSocketFrame(buffer)
143
+ * if (declared !== undefined && declared > limit) fail(WEBSOCKET_CLOSE_TOOBIG)
144
+ * ```
145
+ */
146
+ function measureWebSocketFrame(buffer) {
147
+ if (buffer.length < 2) return void 0;
148
+ let length = (buffer[1] ?? 0) & 127;
149
+ const offset = 2;
150
+ if (length === 126) {
151
+ if (buffer.length < 4) return void 0;
152
+ length = buffer.readUInt16BE(offset);
153
+ } else if (length === 127) {
154
+ if (buffer.length < 10) return void 0;
155
+ const high = buffer.readUInt32BE(offset);
156
+ const low = buffer.readUInt32BE(6);
157
+ length = high * 4294967296 + low;
158
+ }
159
+ return length;
160
+ }
161
+ /**
162
+ * Decode a byte sequence as strict UTF-8, or signal it is malformed.
163
+ *
164
+ * @remarks
165
+ * Wraps `TextDecoder('utf-8', { fatal: true })` in a try/catch so a malformed sequence
166
+ * returns `undefined` instead of throwing (AGENTS §14 — a guard-adjacent coercer never
167
+ * throws on bad input). Pure.
168
+ *
169
+ * @param bytes - The raw bytes to decode
170
+ * @returns The decoded string, or `undefined` when `bytes` is not valid UTF-8
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * const text = parseUTF8(payload)
175
+ * if (text === undefined) fail(WEBSOCKET_CLOSE_INVALID)
176
+ * ```
177
+ */
178
+ function parseUTF8(bytes) {
179
+ try {
180
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
181
+ } catch {
182
+ return;
183
+ }
184
+ }
185
+ /**
186
+ * Whether a numeric value is a valid RFC 6455 close status code to RECEIVE (§7.4.1).
187
+ *
188
+ * @remarks
189
+ * True for `1000`–`1003`, `1007`–`1014`, and the application range `3000`–`4999`; false
190
+ * for anything below `1000`, the reserved-for-local-use-only codes `1004`–`1006` and
191
+ * `1015`, and the unassigned `1016`–`2999` range. The `1012`–`1014` extension of the
192
+ * strict RFC 6455 receivable set is a deliberate IANA-interop choice: those three codes
193
+ * (Service Restart, Try Again Later, Bad Gateway) are IANA-registered in the WebSocket
194
+ * Close Code Number Registry and accepted by the `ws` ecosystem and modern conformance
195
+ * suites, so a peer sending one is not treated as a protocol violation. Pure predicate,
196
+ * never throws.
197
+ *
198
+ * @param code - The close status code to validate
199
+ * @returns `true` when `code` is a valid RFC 6455 close code
200
+ *
201
+ * @example
202
+ * ```ts
203
+ * if (!isCloseCode(code)) fail(WEBSOCKET_CLOSE_PROTOCOL)
204
+ * ```
205
+ */
206
+ function isCloseCode(code) {
207
+ if (code >= 1e3 && code <= 1003) return true;
208
+ if (code >= 1007 && code <= 1014) return true;
209
+ if (code >= 3e3 && code <= 4999) return true;
210
+ return false;
211
+ }
212
+ /**
213
+ * Encode a single RFC 6455 frame to its wire bytes — the inverse of
214
+ * {@link parseWebSocketFrame}.
215
+ *
216
+ * @remarks
217
+ * Builds a final (FIN-set) frame: byte 0 is `0x80 | opcode`; the payload length uses
218
+ * the 7-bit form below 126, the `126` + 16-bit form below 65 536, or the `127` +
219
+ * 64-bit form beyond; when `masked` is set the mask bit is set, a 4-byte key (supplied
220
+ * via `options.mask`, else random) is written, and the payload is XOR-masked. Server→
221
+ * client frames are unmasked (the default); pass `masked: true` to encode a CLIENT
222
+ * frame (e.g. to feed the parser in a test). A `string` payload is encoded as UTF-8.
223
+ * Returns one contiguous `Buffer` (header + payload), so the wrapper writes it with a
224
+ * single `socket.write`. Pure.
225
+ *
226
+ * @param opcode - The frame opcode (a `WEBSOCKET_OPCODE_*` value)
227
+ * @param payload - The payload, a `Buffer` or a UTF-8 `string`
228
+ * @param options - Masking control ({@link WebSocketEncodeOptions}); defaults to unmasked
229
+ * @returns The complete frame as wire bytes
230
+ */
231
+ function encodeWebSocketFrame(opcode, payload, options) {
232
+ const body = typeof payload === "string" ? Buffer.from(payload, "utf-8") : payload;
233
+ const length = body.length;
234
+ const masked = options?.masked === true;
235
+ const mask = masked ? options?.mask ?? (0, node_crypto.randomBytes)(4) : void 0;
236
+ const maskBit = masked ? 128 : 0;
237
+ const extended = length < 126 ? 0 : length < 65536 ? 2 : 8;
238
+ const header = Buffer.alloc(2 + extended + (mask !== void 0 ? 4 : 0));
239
+ header[0] = 128 | opcode;
240
+ if (length < 126) header[1] = maskBit | length;
241
+ else if (length < 65536) {
242
+ header[1] = maskBit | 126;
243
+ header.writeUInt16BE(length, 2);
244
+ } else {
245
+ header[1] = maskBit | 127;
246
+ header.writeUInt32BE(Math.floor(length / 4294967296), 2);
247
+ header.writeUInt32BE(length % 4294967296, 6);
248
+ }
249
+ if (mask === void 0) return Buffer.concat([header, body]);
250
+ mask.copy(header, header.length - 4);
251
+ const maskedBody = Buffer.alloc(length);
252
+ for (let index = 0; index < length; index += 1) maskedBody[index] = (body[index] ?? 0) ^ (mask[index % 4] ?? 0);
253
+ return Buffer.concat([header, maskedBody]);
254
+ }
255
+ //#endregion
256
+ //#region src/server/NodeWebSocket.ts
257
+ /**
258
+ * A server-native WebSocket over a raw upgraded `node:stream` Duplex — the lean
259
+ * wrapper around the RFC 6455 wire protocol.
260
+ *
261
+ * @remarks
262
+ * Created by `createNodeWebSocket`. When given a client `key` it runs in SERVER mode —
263
+ * it writes the `101 Switching Protocols` handshake (`computeWebSocketAccept(key)`) and
264
+ * emits `open`; given no key it runs in CLIENT mode (no handshake, frames masked). It
265
+ * then listens on the socket's `data`, accumulating bytes in `#buffer` and decoding
266
+ * every complete frame with {@link parseWebSocketFrame} (slicing `consumed` and
267
+ * re-parsing the remainder): a TEXT frame — reassembling continuation fragments across
268
+ * `fin: false` frames — decodes to UTF-8 and emits `message`; a PING is auto-answered
269
+ * with a PONG and emits `ping`; a PONG emits `pong`; a CLOSE is echoed and ends the
270
+ * socket, emitting `close`. `send` writes a text frame, `ping` a ping, `close` a close
271
+ * frame; `destroy` tears down immediately. It owns a typed `#emitter` (AGENTS §13) that
272
+ * isolates a throwing listener and routes the error to its own `error` handler (the `error`
273
+ * option) — the socket never crashes. The untyped socket `data` is narrowed to a `Buffer`
274
+ * with a guard, never an assertion (AGENTS §14).
275
+ */
276
+ var NodeWebSocket = class {
277
+ #emitter;
278
+ #socket;
279
+ #protocol;
280
+ #masked;
281
+ #payload;
282
+ #timeout;
283
+ #requireMask;
284
+ #signal;
285
+ #buffer = Buffer.alloc(0);
286
+ #readyState = 0;
287
+ #code = void 0;
288
+ #reason = void 0;
289
+ #fragments = [];
290
+ #messageOpcode = void 0;
291
+ #fragmentBytes = 0;
292
+ #closeTimer = void 0;
293
+ #destroyed = false;
294
+ #onData = (chunk) => {
295
+ if (this.#readyState === 3) return;
296
+ const bytes = this.#bytes(chunk);
297
+ if (bytes === void 0) return;
298
+ this.#ingest(bytes);
299
+ };
300
+ #onClose = () => {
301
+ this.#finish();
302
+ };
303
+ #onError = (error) => {
304
+ this.#emitter.emit("error", error);
305
+ };
306
+ #onAbort = () => {
307
+ this.destroy();
308
+ };
309
+ constructor(options) {
310
+ this.#emitter = new _orkestrel_emitter.Emitter({
311
+ on: options.on,
312
+ error: options.error
313
+ });
314
+ this.#socket = options.socket;
315
+ this.#protocol = options.protocol;
316
+ this.#masked = options.key === void 0;
317
+ this.#payload = options.payload ?? 104857600;
318
+ this.#timeout = options.timeout ?? 3e4;
319
+ this.#requireMask = !this.#masked;
320
+ this.#signal = options.signal;
321
+ if (options.key !== void 0) {
322
+ const protocol = this.#protocol === void 0 ? "" : `Sec-WebSocket-Protocol: ${this.#protocol}\r\n`;
323
+ this.#socket.write(`HTTP/1.1 101 Switching Protocols\r
324
+ Upgrade: websocket\r
325
+ Connection: Upgrade\r
326
+ Sec-WebSocket-Accept: ${computeWebSocketAccept(options.key)}\r\n` + protocol + "\r\n");
327
+ }
328
+ this.#readyState = 1;
329
+ this.#socket.on("data", this.#onData);
330
+ this.#socket.on("close", this.#onClose);
331
+ this.#socket.on("error", this.#onError);
332
+ this.#emitter.emit("open");
333
+ const head = options.head;
334
+ if (head !== void 0 && head.length > 0) this.#ingest(head);
335
+ if (this.#readyState !== 3) if (this.#signal?.aborted === true) this.destroy();
336
+ else this.#signal?.addEventListener("abort", this.#onAbort, { once: true });
337
+ }
338
+ get emitter() {
339
+ return this.#emitter;
340
+ }
341
+ get readyState() {
342
+ return this.#readyState;
343
+ }
344
+ send(data) {
345
+ if (this.#readyState !== 1) return;
346
+ this.#write(1, Buffer.from(data, "utf-8"));
347
+ }
348
+ ping(data) {
349
+ if (this.#readyState !== 1) return;
350
+ if (data !== void 0 && Buffer.byteLength(data, "utf-8") > 125) throw new RangeError("ping payload exceeds 125 bytes");
351
+ this.#write(9, data === void 0 ? Buffer.alloc(0) : Buffer.from(data, "utf-8"));
352
+ }
353
+ close(code, reason) {
354
+ if (this.#readyState === 2 || this.#readyState === 3) return;
355
+ if (code !== void 0 && !isCloseCode(code)) throw new RangeError("invalid close code");
356
+ if (reason !== void 0 && Buffer.byteLength(reason, "utf-8") > 123) throw new RangeError("close reason exceeds 123 bytes");
357
+ this.#readyState = 2;
358
+ this.#code = code ?? 1e3;
359
+ this.#reason = reason === void 0 || reason.length === 0 ? void 0 : reason;
360
+ this.#write(8, this.#encodeClose(this.#code, this.#reason));
361
+ this.#socket.end();
362
+ this.#closeTimer = setTimeout(() => this.destroy(), this.#timeout);
363
+ this.#closeTimer.unref();
364
+ }
365
+ destroy() {
366
+ if (this.#destroyed) return;
367
+ this.#destroyed = true;
368
+ this.#socket.off("data", this.#onData);
369
+ this.#socket.off("close", this.#onClose);
370
+ this.#socket.off("error", this.#onError);
371
+ this.#signal?.removeEventListener("abort", this.#onAbort);
372
+ clearTimeout(this.#closeTimer);
373
+ this.#closeTimer = void 0;
374
+ if (!this.#socket.destroyed) this.#socket.destroy();
375
+ this.#finish();
376
+ this.#emitter.destroy();
377
+ }
378
+ #drain() {
379
+ for (;;) {
380
+ const frame = parseWebSocketFrame(this.#buffer);
381
+ if (frame === void 0) return;
382
+ this.#buffer = this.#buffer.subarray(frame.consumed);
383
+ this.#dispatch(frame.fin, frame.opcode, frame.payload, frame.masked, frame.rsv);
384
+ if (this.#readyState === 3) return;
385
+ }
386
+ }
387
+ #dispatch(fin, opcode, payload, masked, rsv) {
388
+ if (rsv !== 0) {
389
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
390
+ return;
391
+ }
392
+ if (masked !== this.#requireMask) {
393
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
394
+ return;
395
+ }
396
+ if (opcode === 8 || opcode === 9 || opcode === 10) {
397
+ if (!fin || payload.length > 125) {
398
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
399
+ return;
400
+ }
401
+ if (opcode === 9) {
402
+ this.#write(10, payload);
403
+ this.#emitter.emit("ping");
404
+ return;
405
+ }
406
+ if (opcode === 10) {
407
+ this.#emitter.emit("pong");
408
+ return;
409
+ }
410
+ this.#close(payload);
411
+ return;
412
+ }
413
+ if (opcode === 1 || opcode === 2) {
414
+ if (this.#messageOpcode !== void 0) {
415
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
416
+ return;
417
+ }
418
+ this.#messageOpcode = opcode;
419
+ } else if (opcode === 0) {
420
+ if (this.#messageOpcode === void 0) {
421
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
422
+ return;
423
+ }
424
+ } else {
425
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
426
+ return;
427
+ }
428
+ this.#fragments.push(payload);
429
+ this.#fragmentBytes += payload.length;
430
+ if (this.#fragmentBytes > this.#payload) {
431
+ this.#fail(WEBSOCKET_CLOSE_TOOBIG);
432
+ return;
433
+ }
434
+ if (!fin) return;
435
+ if (this.#messageOpcode === 2) {
436
+ this.#fail(WEBSOCKET_CLOSE_UNSUPPORTED);
437
+ return;
438
+ }
439
+ const text = parseUTF8(Buffer.concat(this.#fragments));
440
+ if (text === void 0) {
441
+ this.#fail(WEBSOCKET_CLOSE_INVALID);
442
+ return;
443
+ }
444
+ this.#emitter.emit("message", text);
445
+ this.#messageOpcode = void 0;
446
+ this.#fragments = [];
447
+ this.#fragmentBytes = 0;
448
+ }
449
+ #close(payload) {
450
+ if (!this.#decodeClose(payload)) return;
451
+ if (this.#readyState === 1) {
452
+ this.#readyState = 2;
453
+ this.#write(8, payload);
454
+ }
455
+ this.#socket.end();
456
+ this.#socket.off("data", this.#onData);
457
+ this.#socket.off("close", this.#onClose);
458
+ this.#socket.off("error", this.#onError);
459
+ this.#finish();
460
+ }
461
+ #fail(code, reason) {
462
+ if (this.#readyState === 2 || this.#readyState === 3) return;
463
+ this.#code = code;
464
+ this.#reason = reason;
465
+ this.#readyState = 2;
466
+ this.#socket.off("data", this.#onData);
467
+ this.#socket.off("close", this.#onClose);
468
+ this.#socket.off("error", this.#onError);
469
+ this.#write(8, this.#encodeClose(code, reason));
470
+ this.#socket.end(() => {
471
+ if (!this.#socket.destroyed) this.#socket.destroy();
472
+ clearTimeout(this.#closeTimer);
473
+ this.#closeTimer = void 0;
474
+ });
475
+ this.#messageOpcode = void 0;
476
+ this.#fragments = [];
477
+ this.#fragmentBytes = 0;
478
+ this.#finish();
479
+ this.#closeTimer = setTimeout(() => {
480
+ if (!this.#socket.destroyed) this.#socket.destroy();
481
+ }, WEBSOCKET_FAIL_TIMEOUT_MS);
482
+ this.#closeTimer.unref();
483
+ }
484
+ #write(opcode, payload) {
485
+ if (this.#socket.destroyed) return;
486
+ this.#socket.write(encodeWebSocketFrame(opcode, payload, { masked: this.#masked }));
487
+ }
488
+ #encodeClose(code, reason) {
489
+ if (code === void 0) return Buffer.alloc(0);
490
+ const text = reason === void 0 ? Buffer.alloc(0) : Buffer.from(reason, "utf-8");
491
+ const payload = Buffer.alloc(2 + text.length);
492
+ payload.writeUInt16BE(code, 0);
493
+ text.copy(payload, 2);
494
+ return payload;
495
+ }
496
+ #decodeClose(payload) {
497
+ if (payload.length === 0) {
498
+ this.#code = void 0;
499
+ this.#reason = void 0;
500
+ return true;
501
+ }
502
+ if (payload.length === 1) {
503
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
504
+ return false;
505
+ }
506
+ const code = payload.readUInt16BE(0);
507
+ if (!isCloseCode(code)) {
508
+ this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
509
+ return false;
510
+ }
511
+ const reason = payload.length > 2 ? parseUTF8(payload.subarray(2)) : "";
512
+ if (reason === void 0) {
513
+ this.#fail(WEBSOCKET_CLOSE_INVALID);
514
+ return false;
515
+ }
516
+ this.#code = code;
517
+ this.#reason = reason.length === 0 ? void 0 : reason;
518
+ return true;
519
+ }
520
+ #finish() {
521
+ if (this.#readyState === 3) return;
522
+ clearTimeout(this.#closeTimer);
523
+ this.#closeTimer = void 0;
524
+ this.#signal?.removeEventListener("abort", this.#onAbort);
525
+ this.#readyState = 3;
526
+ this.#emitter.emit("close", this.#code, this.#reason);
527
+ }
528
+ #ingest(bytes) {
529
+ this.#buffer = Buffer.concat([this.#buffer, bytes]);
530
+ const declared = measureWebSocketFrame(this.#buffer);
531
+ if (declared !== void 0 && declared > this.#payload) {
532
+ this.#fail(WEBSOCKET_CLOSE_TOOBIG);
533
+ return;
534
+ }
535
+ this.#drain();
536
+ }
537
+ #bytes(chunk) {
538
+ if (Buffer.isBuffer(chunk)) return chunk;
539
+ if (typeof chunk === "string") return Buffer.from(chunk, "utf-8");
540
+ }
541
+ };
542
+ //#endregion
543
+ //#region src/server/factories.ts
544
+ /**
545
+ * Create a server-native WebSocket over a raw upgraded `node:stream` Duplex socket.
546
+ *
547
+ * @remarks
548
+ * The construction entry point for the {@link NodeWebSocketInterface} (AGENTS §8). Pass
549
+ * the upgraded `socket` plus the client's `Sec-WebSocket-Key` as `key` to run in SERVER
550
+ * mode — the wrapper writes the `101 Switching Protocols` handshake and sends unmasked
551
+ * frames; omit `key` for CLIENT mode (no handshake, masked frames). This is the
552
+ * lean-native handle; it speaks only the WebSocket wire protocol — an MCP transport (the
553
+ * later chunk) is built ON it. It is the WebSocket counterpart to
554
+ * `createSQLiteDatabase` / `createIndexedDBDatabase`.
555
+ *
556
+ * @param options - The {@link NodeWebSocketOptions} (`socket`, optional `key` / `head` /
557
+ * `protocol` / `on`)
558
+ * @returns A typed {@link NodeWebSocketInterface}
559
+ *
560
+ * @example
561
+ * ```ts
562
+ * import { createNodeWebSocket } from '@src/server'
563
+ *
564
+ * // In a node:http 'upgrade' handler — server mode, identified by the client key:
565
+ * server.on('upgrade', (request, socket, head) => {
566
+ * const ws = createNodeWebSocket({
567
+ * socket,
568
+ * key: request.headers['sec-websocket-key'],
569
+ * head,
570
+ * on: { message: (text) => ws.send(`echo: ${text}`) },
571
+ * })
572
+ * })
573
+ * ```
574
+ */
575
+ function createNodeWebSocket(options) {
576
+ return new NodeWebSocket(options);
577
+ }
578
+ //#endregion
579
+ exports.NodeWebSocket = NodeWebSocket;
580
+ exports.WEBSOCKET_CLOSE_INVALID = WEBSOCKET_CLOSE_INVALID;
581
+ exports.WEBSOCKET_CLOSE_NORMAL = WEBSOCKET_CLOSE_NORMAL;
582
+ exports.WEBSOCKET_CLOSE_PROTOCOL = WEBSOCKET_CLOSE_PROTOCOL;
583
+ exports.WEBSOCKET_CLOSE_TIMEOUT_MS = WEBSOCKET_CLOSE_TIMEOUT_MS;
584
+ exports.WEBSOCKET_CLOSE_TOOBIG = WEBSOCKET_CLOSE_TOOBIG;
585
+ exports.WEBSOCKET_CLOSE_UNSUPPORTED = WEBSOCKET_CLOSE_UNSUPPORTED;
586
+ exports.WEBSOCKET_CONTROL_MAXLEN = WEBSOCKET_CONTROL_MAXLEN;
587
+ exports.WEBSOCKET_FAIL_TIMEOUT_MS = WEBSOCKET_FAIL_TIMEOUT_MS;
588
+ exports.WEBSOCKET_GUID = WEBSOCKET_GUID;
589
+ exports.WEBSOCKET_MAX_PAYLOAD = WEBSOCKET_MAX_PAYLOAD;
590
+ exports.WEBSOCKET_OPCODE_BINARY = WEBSOCKET_OPCODE_BINARY;
591
+ exports.WEBSOCKET_OPCODE_CLOSE = WEBSOCKET_OPCODE_CLOSE;
592
+ exports.WEBSOCKET_OPCODE_PING = WEBSOCKET_OPCODE_PING;
593
+ exports.WEBSOCKET_OPCODE_PONG = WEBSOCKET_OPCODE_PONG;
594
+ exports.WEBSOCKET_OPCODE_TEXT = WEBSOCKET_OPCODE_TEXT;
595
+ exports.WEBSOCKET_READY_CLOSED = WEBSOCKET_READY_CLOSED;
596
+ exports.WEBSOCKET_READY_CLOSING = WEBSOCKET_READY_CLOSING;
597
+ exports.WEBSOCKET_READY_CONNECTING = WEBSOCKET_READY_CONNECTING;
598
+ exports.WEBSOCKET_READY_OPEN = WEBSOCKET_READY_OPEN;
599
+ exports.WEBSOCKET_VERSION = WEBSOCKET_VERSION;
600
+ exports.computeWebSocketAccept = computeWebSocketAccept;
601
+ exports.createNodeWebSocket = createNodeWebSocket;
602
+ exports.encodeWebSocketFrame = encodeWebSocketFrame;
603
+ exports.isCloseCode = isCloseCode;
604
+ exports.measureWebSocketFrame = measureWebSocketFrame;
605
+ exports.parseUTF8 = parseUTF8;
606
+ exports.parseWebSocketFrame = parseWebSocketFrame;
607
+
608
+ //# sourceMappingURL=index.cjs.map