@orkestrel/websocket 0.0.10 → 0.0.11

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.
@@ -3,7 +3,7 @@ let node_crypto = require("node:crypto");
3
3
  let _orkestrel_emitter = require("@orkestrel/emitter");
4
4
  //#region src/server/constants.ts
5
5
  /**
6
- * The RFC 6455 GUID concatenated to a client's `Sec-WebSocket-Key` before the SHA-1
6
+ * Names the RFC 6455 GUID concatenated to a client's `Sec-WebSocket-Key` before the SHA-1
7
7
  * hash that yields the `Sec-WebSocket-Accept` response value.
8
8
  *
9
9
  * @remarks
@@ -11,52 +11,113 @@ let _orkestrel_emitter = require("@orkestrel/emitter");
11
11
  * {@link computeWebSocketAccept}.
12
12
  */
13
13
  var WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
14
- /** The WebSocket protocol version this wrapper speaks (`Sec-WebSocket-Version: 13`). */
14
+ /** Names the WebSocket protocol version this wrapper speaks (`Sec-WebSocket-Version: 13`). */
15
15
  var WEBSOCKET_VERSION = "13";
16
- /** Text frame opcode — a UTF-8 payload (RFC 6455 §5.6). */
16
+ /** Names the text frame opcode — a UTF-8 payload (RFC 6455 §5.6). */
17
17
  var WEBSOCKET_OPCODE_TEXT = 1;
18
- /** Binary frame opcode — a raw byte payload (RFC 6455 §5.6). */
18
+ /** Names the binary frame opcode — a raw byte payload (RFC 6455 §5.6). */
19
19
  var WEBSOCKET_OPCODE_BINARY = 2;
20
- /** Continuation frame opcode — the next fragment of an open data message (RFC 6455 §5.4). */
20
+ /** Names the continuation frame opcode — the next fragment of an open data message (RFC 6455 §5.4). */
21
21
  var WEBSOCKET_OPCODE_CONTINUATION = 0;
22
- /** Close frame opcode — a control frame ending the connection (RFC 6455 §5.5.1). */
22
+ /** Names the close frame opcode — a control frame ending the connection (RFC 6455 §5.5.1). */
23
23
  var WEBSOCKET_OPCODE_CLOSE = 8;
24
- /** Ping frame opcode — a control frame the peer must answer with a pong (RFC 6455 §5.5.2). */
24
+ /** Names the ping frame opcode — a control frame the peer must answer with a pong (RFC 6455 §5.5.2). */
25
25
  var WEBSOCKET_OPCODE_PING = 9;
26
- /** Pong frame opcode — a control frame answering a ping (RFC 6455 §5.5.3). */
26
+ /** Names the pong frame opcode — a control frame answering a ping (RFC 6455 §5.5.3). */
27
27
  var WEBSOCKET_OPCODE_PONG = 10;
28
- /** Ready state for a connecting WebSocket (before the handshake completes). */
28
+ /** Names the ready state for a connecting WebSocket (before the handshake completes). */
29
29
  var WEBSOCKET_READY_CONNECTING = 0;
30
- /** Ready state for an open WebSocket (the handshake completed; frames flow). */
30
+ /** Names the ready state for an open WebSocket (the handshake completed; frames flow). */
31
31
  var WEBSOCKET_READY_OPEN = 1;
32
- /** Ready state for a closing WebSocket (a close frame was sent or received). */
32
+ /** Names the ready state for a closing WebSocket (a close frame was sent or received). */
33
33
  var WEBSOCKET_READY_CLOSING = 2;
34
- /** Ready state for a closed WebSocket (the socket ended). */
34
+ /** Names the ready state for a closed WebSocket (the socket ended). */
35
35
  var WEBSOCKET_READY_CLOSED = 3;
36
- /** Normal-closure status code (RFC 6455 §7.4.1) — the default `close` code. */
36
+ /** Names the normal-closure status code (RFC 6455 §7.4.1) — the default `close` code. */
37
37
  var WEBSOCKET_CLOSE_NORMAL = 1e3;
38
- /** Protocol-error status code (RFC 6455 §7.4.1) — a framing/state rule was violated. */
38
+ /** Names the protocol-error status code (RFC 6455 §7.4.1) — a framing/state rule was violated. */
39
39
  var WEBSOCKET_CLOSE_PROTOCOL = 1002;
40
- /** 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). */
40
+ /** Names the unsupported-data status code (RFC 6455 §7.4.1) — the endpoint received a data type it cannot accept, for example binary on a text-only endpoint. */
41
41
  var WEBSOCKET_CLOSE_UNSUPPORTED = 1003;
42
- /** Invalid-frame-payload-data status code (RFC 6455 §7.4.1) — e.g. non-UTF-8 text or an unparseable close reason. */
42
+ /** Names the invalid-frame-payload-data status code (RFC 6455 §7.4.1) — for example non-UTF-8 text or an unparseable close reason. */
43
43
  var WEBSOCKET_CLOSE_INVALID = 1007;
44
- /** Message-too-big status code (RFC 6455 §7.4.1) — a reassembled message exceeded the payload cap. */
45
- var WEBSOCKET_CLOSE_TOOBIG = 1009;
46
- /** The default maximum inbound single-frame length AND reassembled-message total byte count (100 MiB — the `ws` package default). */
44
+ /** Names the message-too-big status code (RFC 6455 §7.4.1) — a reassembled message exceeded the payload cap. */
45
+ var WEBSOCKET_CLOSE_TOO_BIG = 1009;
46
+ /** Names the default maximum inbound single-frame length AND reassembled-message total byte count (100 MiB — the `ws` package default). */
47
47
  var WEBSOCKET_MAX_PAYLOAD = 104857600;
48
- /** The default close-handshake timeout in milliseconds — how long `close()` waits for the peer's echo before tearing the socket down. */
48
+ /** Names the default close-handshake timeout in milliseconds — how long `close()` waits for the peer's echo before tearing the socket down. */
49
49
  var WEBSOCKET_CLOSE_TIMEOUT_MS = 3e4;
50
- /** 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). */
50
+ /** Names 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). */
51
51
  var WEBSOCKET_FAIL_TIMEOUT_MS = 1e3;
52
- /** The maximum control-frame payload length in bytes (RFC 6455 §5.5). */
53
- var WEBSOCKET_CONTROL_MAXLEN = 125;
54
- /** The maximum UTF-8 close-reason length after the two-byte status code. */
55
- var WEBSOCKET_CLOSE_REASON_MAXLEN = 123;
52
+ /** Names the maximum control-frame payload length in bytes (RFC 6455 §5.5). */
53
+ var WEBSOCKET_CONTROL_MAX_LENGTH = 125;
54
+ /** Names the maximum UTF-8 close-reason length after the two-byte status code. */
55
+ var WEBSOCKET_CLOSE_REASON_MAX_LENGTH = 123;
56
+ //#endregion
57
+ //#region src/server/errors.ts
58
+ /**
59
+ * Represents an error thrown by the WebSocket wrapper for a refused caller-supplied value.
60
+ *
61
+ * @remarks
62
+ * Carries a {@link WebSocketErrorCode} and an optional `context` record holding the
63
+ * refused value under a key naming it: an `'OPTION'` carries the offending option
64
+ * (`payload`, `timeout`, `key`, or `protocol`), a `'LIMIT'` carries `size` and the
65
+ * `limit` it exceeded, a `'CLOSE'` carries the refused close `code`, and a `'FRAME'`
66
+ * carries `opcode` or the mask's `size`. Narrow a caught value with
67
+ * {@link isWebSocketError}.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { createNodeWebSocket, isWebSocketError } from '@src/server'
72
+ *
73
+ * try {
74
+ * createNodeWebSocket({ socket, key: 'not-base64' })
75
+ * } catch (error) {
76
+ * if (isWebSocketError(error) && error.code === 'OPTION') socket.destroy()
77
+ * }
78
+ * ```
79
+ */
80
+ var WebSocketError = class extends Error {
81
+ code;
82
+ context;
83
+ /**
84
+ * Creates a WebSocket error carrying a machine-readable code.
85
+ *
86
+ * @param code - The machine-readable {@link WebSocketErrorCode} a `catch` branches on
87
+ * @param message - The human-readable description, carried as the `Error` message
88
+ * @param context - The refused value keyed by name; omitted leaves `context` `undefined`
89
+ */
90
+ constructor(code, message, context) {
91
+ super(message);
92
+ this.name = "WebSocketError";
93
+ this.code = code;
94
+ if (context !== void 0) this.context = context;
95
+ }
96
+ };
97
+ /**
98
+ * Checks whether a value is a {@link WebSocketError}.
99
+ *
100
+ * @param value - The value to test (typically a `catch` binding)
101
+ * @returns True if `value` is a `WebSocketError`; false otherwise
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * import { isWebSocketError } from '@src/server'
106
+ *
107
+ * try {
108
+ * ws.close(1000.5)
109
+ * } catch (error) {
110
+ * if (isWebSocketError(error) && error.code === 'CLOSE') ws.close()
111
+ * }
112
+ * ```
113
+ */
114
+ function isWebSocketError(value) {
115
+ return value instanceof WebSocketError;
116
+ }
56
117
  //#endregion
57
118
  //#region src/server/helpers.ts
58
119
  /**
59
- * Compute the `Sec-WebSocket-Accept` response value for an RFC 6455 upgrade.
120
+ * Computes the `Sec-WebSocket-Accept` response value for an RFC 6455 upgrade.
60
121
  *
61
122
  * @remarks
62
123
  * The base64-encoded SHA-1 of the client's `Sec-WebSocket-Key` concatenated with the
@@ -70,7 +131,121 @@ function computeWebSocketAccept(key) {
70
131
  return (0, node_crypto.createHash)("sha1").update(key + WEBSOCKET_GUID).digest("base64");
71
132
  }
72
133
  /**
73
- * Whether a value is a canonical RFC 6455 `Sec-WebSocket-Key`.
134
+ * Reads the declared payload length off the front of a buffer, without buffering or
135
+ * reading the payload itself.
136
+ *
137
+ * @remarks
138
+ * Decodes only byte 1's 7-bit length field, extended by the 16-bit (`126`) or 64-bit
139
+ * (`127`) form exactly like `parseWebSocketFrame` — but stops there, so a caller
140
+ * can reject an over-cap frame the moment its length is known, before the payload
141
+ * bytes have even arrived. Returns `undefined` until the length field itself is fully
142
+ * buffered (mirrors the parser's incomplete-buffer contract). Pure; never throws.
143
+ *
144
+ * @param buffer - The accumulation buffer to read the next frame's length from
145
+ * @returns The declared payload length, or `undefined` when the buffer is too short to know it yet
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const declared = measureWebSocketFrame(buffer)
150
+ * if (declared !== undefined && declared > limit) fail(WEBSOCKET_CLOSE_TOO_BIG)
151
+ * ```
152
+ */
153
+ function measureWebSocketFrame(buffer) {
154
+ if (buffer.length < 2) return void 0;
155
+ let length = buffer.readUInt8(1) & 127;
156
+ const offset = 2;
157
+ if (length === 126) {
158
+ if (buffer.length < 4) return void 0;
159
+ length = buffer.readUInt16BE(offset);
160
+ } else if (length === 127) {
161
+ if (buffer.length < 10) return void 0;
162
+ const high = buffer.readUInt32BE(offset);
163
+ const low = buffer.readUInt32BE(6);
164
+ length = high * 4294967296 + low;
165
+ }
166
+ return length;
167
+ }
168
+ /**
169
+ * Checks whether the next frame uses the shortest valid RFC 6455 payload-length encoding.
170
+ *
171
+ * @remarks
172
+ * Returns `undefined` until the complete length prefix is buffered. The 16-bit form
173
+ * is canonical only for lengths at least 126; the 64-bit form only for lengths at
174
+ * least 65,536 and with its most-significant bit clear (RFC 6455 §5.2). Reads the same
175
+ * length prefix as {@link measureWebSocketFrame}, under the same incomplete-buffer
176
+ * contract. Pure; never throws.
177
+ *
178
+ * @param buffer - The accumulation buffer containing the next frame header
179
+ * @returns Its canonicality, or `undefined` while the length prefix is incomplete
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * if (matchesWebSocketCanonical(buffer) === false) fail(WEBSOCKET_CLOSE_PROTOCOL)
184
+ * ```
185
+ */
186
+ function matchesWebSocketCanonical(buffer) {
187
+ if (buffer.length < 2) return void 0;
188
+ const lengthCode = buffer.readUInt8(1) & 127;
189
+ if (lengthCode < 126) return true;
190
+ if (lengthCode === 126) {
191
+ if (buffer.length < 4) return void 0;
192
+ return buffer.readUInt16BE(2) >= 126;
193
+ }
194
+ if (buffer.length < 10) return void 0;
195
+ const high = buffer.readUInt32BE(2);
196
+ const low = buffer.readUInt32BE(6);
197
+ if ((high & 2147483648) !== 0) return false;
198
+ return high > 0 || low >= 65536;
199
+ }
200
+ /**
201
+ * Encodes a single RFC 6455 frame to its wire bytes — the inverse of
202
+ * `parseWebSocketFrame`.
203
+ *
204
+ * @remarks
205
+ * Builds a final (FIN-set) frame: byte 0 is `0x80 | opcode`; the payload length uses
206
+ * the 7-bit form below 126, the `126` + 16-bit form below 65 536, or the `127` +
207
+ * 64-bit form beyond; when `masked` is set the mask bit is set, a 4-byte key (supplied
208
+ * through `options.mask`, else random) is written, and the payload is XOR-masked. Server→
209
+ * client frames are unmasked (the default); pass `masked: true` to encode a CLIENT
210
+ * frame (for example to feed the parser in a test). A `string` payload is encoded as
211
+ * UTF-8. Returns one contiguous `Buffer` (header + payload), so the wrapper writes it
212
+ * with a single `socket.write`. Pure.
213
+ *
214
+ * @param opcode - The frame opcode (a `WEBSOCKET_OPCODE_*` value)
215
+ * @param payload - The payload, a `Buffer` or a UTF-8 `string`
216
+ * @param options - Masking control ({@link WebSocketEncodeOptions}); defaults to unmasked
217
+ * @returns The complete frame as wire bytes
218
+ * @throws A {@link WebSocketError} coded `FRAME` when `opcode` is outside the four-bit wire field, when `options.mask` is not 4 bytes, or when `options.mask` is supplied without `masked: true`
219
+ */
220
+ function encodeWebSocketFrame(opcode, payload, options) {
221
+ if (!Number.isInteger(opcode) || opcode < 0 || opcode > 15) throw new WebSocketError("FRAME", "opcode must be an integer between 0 and 15", { opcode });
222
+ if (options?.mask !== void 0 && options.mask.length !== 4) throw new WebSocketError("FRAME", "mask must contain exactly 4 bytes", { size: options.mask.length });
223
+ if (options?.mask !== void 0 && options.masked !== true) throw new WebSocketError("FRAME", "mask requires masked: true");
224
+ const body = typeof payload === "string" ? Buffer.from(payload, "utf-8") : payload;
225
+ const length = body.length;
226
+ const masked = options?.masked === true;
227
+ const mask = masked ? options?.mask ?? (0, node_crypto.randomBytes)(4) : void 0;
228
+ const maskBit = masked ? 128 : 0;
229
+ const extended = length < 126 ? 0 : length < 65536 ? 2 : 8;
230
+ const header = Buffer.alloc(2 + extended + (mask !== void 0 ? 4 : 0));
231
+ header[0] = 128 | opcode;
232
+ if (length < 126) header[1] = maskBit | length;
233
+ else if (length < 65536) {
234
+ header[1] = maskBit | 126;
235
+ header.writeUInt16BE(length, 2);
236
+ } else {
237
+ header[1] = maskBit | 127;
238
+ header.writeUInt32BE(Math.floor(length / 4294967296), 2);
239
+ header.writeUInt32BE(length % 4294967296, 6);
240
+ }
241
+ if (mask === void 0) return Buffer.concat([header, body]);
242
+ mask.copy(header, header.length - 4);
243
+ const maskedBody = Buffer.alloc(length);
244
+ for (let index = 0; index < length; index += 1) maskedBody[index] = body.readUInt8(index) ^ mask.readUInt8(index % 4);
245
+ return Buffer.concat([header, maskedBody]);
246
+ }
247
+ /**
248
+ * Checks whether a value is a canonical RFC 6455 `Sec-WebSocket-Key`.
74
249
  *
75
250
  * @remarks
76
251
  * A valid key is exactly 16 random bytes encoded as 24 characters of base64, ending
@@ -78,7 +253,7 @@ function computeWebSocketAccept(key) {
78
253
  * malformed or non-canonical encodings return `false`; nothing is thrown.
79
254
  *
80
255
  * @param key - The proposed `Sec-WebSocket-Key` header value
81
- * @returns `true` when `key` is the canonical base64 encoding of 16 bytes
256
+ * @returns True if `key` is the canonical base64 encoding of 16 bytes; false otherwise
82
257
  *
83
258
  * @example
84
259
  * ```ts
@@ -91,7 +266,7 @@ function isWebSocketKey(key) {
91
266
  return Buffer.from(key, "base64").length === 16;
92
267
  }
93
268
  /**
94
- * Whether a value is one valid WebSocket subprotocol token.
269
+ * Checks whether a value is one valid WebSocket subprotocol token.
95
270
  *
96
271
  * @remarks
97
272
  * Subprotocols use the HTTP `token` grammar. Whitespace, separators, commas, and
@@ -99,18 +274,48 @@ function isWebSocketKey(key) {
99
274
  * second handshake header.
100
275
  *
101
276
  * @param protocol - The negotiated subprotocol to validate
102
- * @returns `true` when `protocol` is one non-empty HTTP token
277
+ * @returns True if `protocol` is one non-empty HTTP token; false otherwise
103
278
  *
104
279
  * @example
105
280
  * ```ts
106
- * if (!isWebSocketProtocol(protocol)) throw new RangeError('invalid protocol')
281
+ * if (!isWebSocketProtocol(protocol)) socket.destroy()
107
282
  * ```
108
283
  */
109
284
  function isWebSocketProtocol(protocol) {
110
285
  return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(protocol);
111
286
  }
112
287
  /**
113
- * Decode a single RFC 6455 frame from the front of a buffer.
288
+ * Checks whether a numeric value is a valid RFC 6455 close status code to RECEIVE (§7.4.1).
289
+ *
290
+ * @remarks
291
+ * True for `1000`–`1003`, `1007`–`1014`, and the application range `3000`–`4999`; false
292
+ * for anything below `1000`, the reserved-for-local-use-only codes `1004`–`1006` and
293
+ * `1015`, and the unassigned `1016`–`2999` range. The `1012`–`1014` extension of the
294
+ * strict RFC 6455 receivable set is a deliberate IANA-interop choice: those three codes
295
+ * (Service Restart, Try Again Later, Bad Gateway) are IANA-registered in the WebSocket
296
+ * Close Code Number Registry and accepted by the `ws` ecosystem and modern conformance
297
+ * suites, so a peer sending one is not treated as a protocol violation. Pure predicate,
298
+ * never throws.
299
+ *
300
+ * @param code - The close status code to validate
301
+ * @returns True if `code` is a valid RFC 6455 close code; false otherwise
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * if (!isCloseCode(code)) fail(WEBSOCKET_CLOSE_PROTOCOL)
306
+ * ```
307
+ */
308
+ function isCloseCode(code) {
309
+ if (!Number.isInteger(code)) return false;
310
+ if (code >= 1e3 && code <= 1003) return true;
311
+ if (code >= 1007 && code <= 1014) return true;
312
+ if (code >= 3e3 && code <= 4999) return true;
313
+ return false;
314
+ }
315
+ //#endregion
316
+ //#region src/server/parsers.ts
317
+ /**
318
+ * Decodes a single RFC 6455 frame from the front of a buffer.
114
319
  *
115
320
  * @remarks
116
321
  * Reads the FIN bit and opcode (byte 0), the mask bit and 7-bit payload length (byte
@@ -120,12 +325,17 @@ function isWebSocketProtocol(protocol) {
120
325
  * 6455 §5.3; an unmasked frame still decodes, leaving the payload as-is, so the caller
121
326
  * can enforce policy). Returns `undefined` the moment the buffer is too short for the
122
327
  * part it is up to (the length prefix, the mask, or the full payload) — the signal to
123
- * the caller to read more bytes and retry, exactly like {@link SSEParser} on a partial
124
- * line. `consumed` is the total bytes the frame occupied, so the caller slices the
125
- * remainder. Pure; never throws on a short buffer.
328
+ * the caller to read more bytes and retry. `consumed` is the total bytes the frame
329
+ * occupied, so the caller slices the remainder. Pure; never throws on a short buffer.
126
330
  *
127
331
  * @param buffer - The accumulation buffer to decode the next frame from
128
332
  * @returns The parsed {@link WebSocketFrame}, or `undefined` when the buffer is incomplete
333
+ *
334
+ * @example
335
+ * ```ts
336
+ * const frame = parseWebSocketFrame(buffer)
337
+ * if (frame === undefined) return // incomplete — wait for more bytes
338
+ * ```
129
339
  */
130
340
  function parseWebSocketFrame(buffer) {
131
341
  if (buffer.length < 2) return void 0;
@@ -168,77 +378,12 @@ function parseWebSocketFrame(buffer) {
168
378
  };
169
379
  }
170
380
  /**
171
- * Read the declared payload length off the front of a buffer, without buffering or
172
- * reading the payload itself.
173
- *
174
- * @remarks
175
- * Decodes only byte 1's 7-bit length field, extended by the 16-bit (`126`) or 64-bit
176
- * (`127`) form exactly like {@link parseWebSocketFrame} — but stops there, so a caller
177
- * can reject an over-cap frame the moment its length is known, before the payload
178
- * bytes have even arrived. Returns `undefined` until the length field itself is fully
179
- * buffered (mirrors the parser's incomplete-buffer contract). Pure; never throws.
180
- *
181
- * @param buffer - The accumulation buffer to read the next frame's length from
182
- * @returns The declared payload length, or `undefined` when the buffer is too short to know it yet
183
- *
184
- * @example
185
- * ```ts
186
- * const declared = measureWebSocketFrame(buffer)
187
- * if (declared !== undefined && declared > limit) fail(WEBSOCKET_CLOSE_TOOBIG)
188
- * ```
189
- */
190
- function measureWebSocketFrame(buffer) {
191
- if (buffer.length < 2) return void 0;
192
- let length = buffer.readUInt8(1) & 127;
193
- const offset = 2;
194
- if (length === 126) {
195
- if (buffer.length < 4) return void 0;
196
- length = buffer.readUInt16BE(offset);
197
- } else if (length === 127) {
198
- if (buffer.length < 10) return void 0;
199
- const high = buffer.readUInt32BE(offset);
200
- const low = buffer.readUInt32BE(6);
201
- length = high * 4294967296 + low;
202
- }
203
- return length;
204
- }
205
- /**
206
- * Whether the next frame uses the shortest valid RFC 6455 payload-length encoding.
207
- *
208
- * @remarks
209
- * Returns `undefined` until the complete length prefix is buffered. The 16-bit form
210
- * is canonical only for lengths at least 126; the 64-bit form only for lengths at
211
- * least 65,536 and with its most-significant bit clear (RFC 6455 §5.2).
212
- *
213
- * @param buffer - The accumulation buffer containing the next frame header
214
- * @returns Its canonicality, or `undefined` while the length prefix is incomplete
215
- *
216
- * @example
217
- * ```ts
218
- * if (isWebSocketFrameCanonical(buffer) === false) fail(WEBSOCKET_CLOSE_PROTOCOL)
219
- * ```
220
- */
221
- function isWebSocketFrameCanonical(buffer) {
222
- if (buffer.length < 2) return void 0;
223
- const lengthCode = buffer.readUInt8(1) & 127;
224
- if (lengthCode < 126) return true;
225
- if (lengthCode === 126) {
226
- if (buffer.length < 4) return void 0;
227
- return buffer.readUInt16BE(2) >= 126;
228
- }
229
- if (buffer.length < 10) return void 0;
230
- const high = buffer.readUInt32BE(2);
231
- const low = buffer.readUInt32BE(6);
232
- if ((high & 2147483648) !== 0) return false;
233
- return high > 0 || low >= 65536;
234
- }
235
- /**
236
- * Decode a byte sequence as strict UTF-8, or signal it is malformed.
381
+ * Decodes a byte sequence as strict UTF-8, or signals it is malformed.
237
382
  *
238
383
  * @remarks
239
384
  * Wraps `TextDecoder('utf-8', { fatal: true })` in a try/catch so a malformed sequence
240
- * returns `undefined` instead of throwing (AGENTS §14 — a guard-adjacent coercer never
241
- * throws on bad input). Pure.
385
+ * returns `undefined` instead of throwing — a guard-adjacent coercer never throws on bad
386
+ * input. Pure.
242
387
  *
243
388
  * @param bytes - The raw bytes to decode
244
389
  * @returns The decoded string, or `undefined` when `bytes` is not valid UTF-8
@@ -256,84 +401,10 @@ function parseUTF8(bytes) {
256
401
  return;
257
402
  }
258
403
  }
259
- /**
260
- * Whether a numeric value is a valid RFC 6455 close status code to RECEIVE (§7.4.1).
261
- *
262
- * @remarks
263
- * True for `1000`–`1003`, `1007`–`1014`, and the application range `3000`–`4999`; false
264
- * for anything below `1000`, the reserved-for-local-use-only codes `1004`–`1006` and
265
- * `1015`, and the unassigned `1016`–`2999` range. The `1012`–`1014` extension of the
266
- * strict RFC 6455 receivable set is a deliberate IANA-interop choice: those three codes
267
- * (Service Restart, Try Again Later, Bad Gateway) are IANA-registered in the WebSocket
268
- * Close Code Number Registry and accepted by the `ws` ecosystem and modern conformance
269
- * suites, so a peer sending one is not treated as a protocol violation. Pure predicate,
270
- * never throws.
271
- *
272
- * @param code - The close status code to validate
273
- * @returns `true` when `code` is a valid RFC 6455 close code
274
- *
275
- * @example
276
- * ```ts
277
- * if (!isCloseCode(code)) fail(WEBSOCKET_CLOSE_PROTOCOL)
278
- * ```
279
- */
280
- function isCloseCode(code) {
281
- if (!Number.isInteger(code)) return false;
282
- if (code >= 1e3 && code <= 1003) return true;
283
- if (code >= 1007 && code <= 1014) return true;
284
- if (code >= 3e3 && code <= 4999) return true;
285
- return false;
286
- }
287
- /**
288
- * Encode a single RFC 6455 frame to its wire bytes — the inverse of
289
- * {@link parseWebSocketFrame}.
290
- *
291
- * @remarks
292
- * Builds a final (FIN-set) frame: byte 0 is `0x80 | opcode`; the payload length uses
293
- * the 7-bit form below 126, the `126` + 16-bit form below 65 536, or the `127` +
294
- * 64-bit form beyond; when `masked` is set the mask bit is set, a 4-byte key (supplied
295
- * via `options.mask`, else random) is written, and the payload is XOR-masked. Server→
296
- * client frames are unmasked (the default); pass `masked: true` to encode a CLIENT
297
- * frame (e.g. to feed the parser in a test). A `string` payload is encoded as UTF-8.
298
- * Returns one contiguous `Buffer` (header + payload), so the wrapper writes it with a
299
- * single `socket.write`. Pure.
300
- *
301
- * @param opcode - The frame opcode (a `WEBSOCKET_OPCODE_*` value)
302
- * @param payload - The payload, a `Buffer` or a UTF-8 `string`
303
- * @param options - Masking control ({@link WebSocketEncodeOptions}); defaults to unmasked
304
- * @returns The complete frame as wire bytes
305
- */
306
- function encodeWebSocketFrame(opcode, payload, options) {
307
- if (!Number.isInteger(opcode) || opcode < 0 || opcode > 15) throw new RangeError("opcode must be an integer between 0 and 15");
308
- if (options?.mask !== void 0 && options.mask.length !== 4) throw new RangeError("mask must contain exactly 4 bytes");
309
- if (options?.mask !== void 0 && options.masked !== true) throw new RangeError("mask requires masked: true");
310
- const body = typeof payload === "string" ? Buffer.from(payload, "utf-8") : payload;
311
- const length = body.length;
312
- const masked = options?.masked === true;
313
- const mask = masked ? options?.mask ?? (0, node_crypto.randomBytes)(4) : void 0;
314
- const maskBit = masked ? 128 : 0;
315
- const extended = length < 126 ? 0 : length < 65536 ? 2 : 8;
316
- const header = Buffer.alloc(2 + extended + (mask !== void 0 ? 4 : 0));
317
- header[0] = 128 | opcode;
318
- if (length < 126) header[1] = maskBit | length;
319
- else if (length < 65536) {
320
- header[1] = maskBit | 126;
321
- header.writeUInt16BE(length, 2);
322
- } else {
323
- header[1] = maskBit | 127;
324
- header.writeUInt32BE(Math.floor(length / 4294967296), 2);
325
- header.writeUInt32BE(length % 4294967296, 6);
326
- }
327
- if (mask === void 0) return Buffer.concat([header, body]);
328
- mask.copy(header, header.length - 4);
329
- const maskedBody = Buffer.alloc(length);
330
- for (let index = 0; index < length; index += 1) maskedBody[index] = body.readUInt8(index) ^ mask.readUInt8(index % 4);
331
- return Buffer.concat([header, maskedBody]);
332
- }
333
404
  //#endregion
334
405
  //#region src/server/NodeWebSocket.ts
335
406
  /**
336
- * A server-native WebSocket over a raw upgraded `node:stream` Duplex — the lean
407
+ * Represents a server-native WebSocket over a raw upgraded `node:stream` Duplex — the lean
337
408
  * wrapper around the RFC 6455 wire protocol.
338
409
  *
339
410
  * @remarks
@@ -346,11 +417,25 @@ function encodeWebSocketFrame(opcode, payload, options) {
346
417
  * `fin: false` frames — decodes to UTF-8 and emits `message`; a PING is auto-answered
347
418
  * with a PONG and emits `ping`; a PONG emits `pong`; a CLOSE is echoed and ends the
348
419
  * socket, emitting `close`. `send` writes a text frame, `ping` a ping, `close` a close
349
- * frame; `destroy` tears down immediately. It owns a typed `#emitter` (AGENTS §13) that
350
- * isolates a throwing listener and routes the error to its own `error` handler (the `error`
351
- * option) — the socket never crashes. An underlying socket error emits the domain
352
- * `error` event and terminates the wrapper. The untyped socket `data` is narrowed to a
353
- * `Buffer` with a guard, never an assertion (AGENTS §14).
420
+ * frame; `destroy` tears down immediately. It owns a typed `#emitter` by composition, and
421
+ * the emitter isolates a throwing listener and routes the error to its own `error` handler
422
+ * (the `error` option) — the socket never crashes. An underlying socket error emits the
423
+ * domain `error` event and terminates the wrapper. The untyped socket `data` is narrowed
424
+ * to a `Buffer` with a guard, never an assertion.
425
+ *
426
+ * @example
427
+ * ```ts
428
+ * import { NodeWebSocket } from '@src/server'
429
+ *
430
+ * // In a node:http 'upgrade' handler, over the socket the server already handed over:
431
+ * const key = request.headers['sec-websocket-key']
432
+ * if (typeof key !== 'string') {
433
+ * socket.destroy()
434
+ * return
435
+ * }
436
+ * const ws = new NodeWebSocket({ socket, key, head })
437
+ * ws.emitter.on('message', (text) => ws.send(`echo: ${text}`))
438
+ * ```
354
439
  */
355
440
  var NodeWebSocket = class {
356
441
  #emitter;
@@ -373,14 +458,25 @@ var NodeWebSocket = class {
373
458
  #closeTimer;
374
459
  #destroyed = false;
375
460
  #detached = false;
461
+ /**
462
+ * Creates a WebSocket wrapper over an already-upgraded Duplex socket.
463
+ *
464
+ * @remarks
465
+ * `key` selects the mode: present runs SERVER mode and writes the `101 Switching
466
+ * Protocols` handshake, omitted runs CLIENT mode and masks every outgoing frame.
467
+ * {@link NodeWebSocketOptions} describes every member.
468
+ *
469
+ * @param options - The {@link NodeWebSocketOptions} the wrapper is built from
470
+ * @throws A {@link WebSocketError} coded `OPTION` when `payload`, `timeout`, `key`, or `protocol` is refused, or when `protocol` is supplied without a server `key`, thrown before the wrapper writes to or assumes ownership of the `socket`
471
+ */
376
472
  constructor(options) {
377
473
  const payload = options.payload ?? 104857600;
378
- if (!Number.isSafeInteger(payload) || payload < 0) throw new RangeError("payload must be a non-negative safe integer");
474
+ if (!Number.isSafeInteger(payload) || payload < 0) throw new WebSocketError("OPTION", "payload must be a non-negative safe integer", { payload });
379
475
  const timeout = options.timeout ?? 3e4;
380
- if (!Number.isSafeInteger(timeout) || timeout < 0) throw new RangeError("timeout must be a non-negative safe integer");
381
- if (options.key !== void 0 && !isWebSocketKey(options.key)) throw new RangeError("key must be the canonical base64 encoding of 16 bytes");
382
- if (options.protocol !== void 0 && !isWebSocketProtocol(options.protocol)) throw new RangeError("protocol must be a valid WebSocket subprotocol token");
383
- if (options.protocol !== void 0 && options.key === void 0) throw new RangeError("protocol requires a server key");
476
+ if (!Number.isSafeInteger(timeout) || timeout < 0) throw new WebSocketError("OPTION", "timeout must be a non-negative safe integer", { timeout });
477
+ if (options.key !== void 0 && !isWebSocketKey(options.key)) throw new WebSocketError("OPTION", "key must be the canonical base64 encoding of 16 bytes", { key: options.key });
478
+ if (options.protocol !== void 0 && !isWebSocketProtocol(options.protocol)) throw new WebSocketError("OPTION", "protocol must be a valid WebSocket subprotocol token", { protocol: options.protocol });
479
+ if (options.protocol !== void 0 && options.key === void 0) throw new WebSocketError("OPTION", "protocol requires a server key", { protocol: options.protocol });
384
480
  this.#emitter = new _orkestrel_emitter.Emitter({
385
481
  ...options.on === void 0 ? {} : { on: options.on },
386
482
  ...options.error === void 0 ? {} : { error: options.error }
@@ -422,19 +518,27 @@ var NodeWebSocket = class {
422
518
  get readyState() {
423
519
  return this.#readyState;
424
520
  }
425
- send(data) {
521
+ send(message) {
426
522
  if (this.#readyState !== 1) return;
427
- this.#write(1, Buffer.from(data, "utf-8"));
523
+ this.#write(1, Buffer.from(message, "utf-8"));
428
524
  }
429
- ping(data) {
525
+ ping(payload) {
430
526
  if (this.#readyState !== 1) return;
431
- if (data !== void 0 && Buffer.byteLength(data, "utf-8") > 125) throw new RangeError("ping payload exceeds 125 bytes");
432
- this.#write(9, data === void 0 ? Buffer.alloc(0) : Buffer.from(data, "utf-8"));
527
+ const size = payload === void 0 ? 0 : Buffer.byteLength(payload, "utf-8");
528
+ if (size > 125) throw new WebSocketError("LIMIT", `ping payload exceeds 125 bytes`, {
529
+ size,
530
+ limit: 125
531
+ });
532
+ this.#write(9, payload === void 0 ? Buffer.alloc(0) : Buffer.from(payload, "utf-8"));
433
533
  }
434
534
  close(code, reason) {
435
535
  if (this.#readyState === 2 || this.#readyState === 3) return;
436
- if (code !== void 0 && !isCloseCode(code)) throw new RangeError("invalid close code");
437
- if (reason !== void 0 && Buffer.byteLength(reason, "utf-8") > 123) throw new RangeError(`close reason exceeds 123 bytes`);
536
+ if (code !== void 0 && !isCloseCode(code)) throw new WebSocketError("CLOSE", "invalid close code", { code });
537
+ const size = reason === void 0 ? 0 : Buffer.byteLength(reason, "utf-8");
538
+ if (size > 123) throw new WebSocketError("LIMIT", `close reason exceeds 123 bytes`, {
539
+ size,
540
+ limit: 123
541
+ });
438
542
  this.#readyState = 2;
439
543
  this.#code = code ?? 1e3;
440
544
  this.#reason = reason === void 0 || reason.length === 0 ? void 0 : reason;
@@ -456,13 +560,13 @@ var NodeWebSocket = class {
456
560
  }
457
561
  #drain() {
458
562
  for (;;) {
459
- if (isWebSocketFrameCanonical(this.#buffer) === false) {
563
+ if (matchesWebSocketCanonical(this.#buffer) === false) {
460
564
  this.#fail(WEBSOCKET_CLOSE_PROTOCOL);
461
565
  return;
462
566
  }
463
567
  const declared = measureWebSocketFrame(this.#buffer);
464
568
  if (declared !== void 0 && declared > this.#payload) {
465
- this.#fail(WEBSOCKET_CLOSE_TOOBIG);
569
+ this.#fail(WEBSOCKET_CLOSE_TOO_BIG);
466
570
  return;
467
571
  }
468
572
  const frame = parseWebSocketFrame(this.#buffer);
@@ -516,7 +620,7 @@ var NodeWebSocket = class {
516
620
  this.#fragments.push(payload);
517
621
  this.#fragmentBytes += payload.length;
518
622
  if (this.#fragmentBytes > this.#payload) {
519
- this.#fail(WEBSOCKET_CLOSE_TOOBIG);
623
+ this.#fail(WEBSOCKET_CLOSE_TOO_BIG);
520
624
  return;
521
625
  }
522
626
  if (!fin) return;
@@ -645,10 +749,10 @@ var NodeWebSocket = class {
645
749
  //#endregion
646
750
  //#region src/server/factories.ts
647
751
  /**
648
- * Create a server-native WebSocket over a raw upgraded `node:stream` Duplex socket.
752
+ * Creates a server-native WebSocket over a raw upgraded `node:stream` Duplex socket.
649
753
  *
650
754
  * @remarks
651
- * The construction entry point for the {@link NodeWebSocketInterface} (AGENTS §8). Pass
755
+ * The construction entry point for the {@link NodeWebSocketInterface}. Pass
652
756
  * the upgraded `socket` plus the client's `Sec-WebSocket-Key` as `key` to run in SERVER
653
757
  * mode — the wrapper writes the `101 Switching Protocols` handshake and sends unmasked
654
758
  * frames; omit `key` for CLIENT mode (no handshake, masked frames). This is the
@@ -659,6 +763,7 @@ var NodeWebSocket = class {
659
763
  * @param options - The {@link NodeWebSocketOptions} (`socket`, optional `key` / `head` /
660
764
  * `protocol` / `on`)
661
765
  * @returns A typed {@link NodeWebSocketInterface}
766
+ * @throws A `WebSocketError` coded `OPTION` when `payload`, `timeout`, `key`, or `protocol` is refused, thrown before the wrapper writes to or assumes ownership of the `socket`
662
767
  *
663
768
  * @example
664
769
  * ```ts
@@ -666,9 +771,14 @@ var NodeWebSocket = class {
666
771
  *
667
772
  * // In a node:http 'upgrade' handler — server mode, identified by the client key:
668
773
  * server.on('upgrade', (request, socket, head) => {
774
+ * const key = request.headers['sec-websocket-key']
775
+ * if (typeof key !== 'string') {
776
+ * socket.destroy()
777
+ * return
778
+ * }
669
779
  * const ws = createNodeWebSocket({
670
780
  * socket,
671
- * key: request.headers['sec-websocket-key'],
781
+ * key, // present => server mode + 101 handshake
672
782
  * head,
673
783
  * on: { message: (text) => ws.send(`echo: ${text}`) },
674
784
  * })
@@ -683,11 +793,11 @@ exports.NodeWebSocket = NodeWebSocket;
683
793
  exports.WEBSOCKET_CLOSE_INVALID = WEBSOCKET_CLOSE_INVALID;
684
794
  exports.WEBSOCKET_CLOSE_NORMAL = WEBSOCKET_CLOSE_NORMAL;
685
795
  exports.WEBSOCKET_CLOSE_PROTOCOL = WEBSOCKET_CLOSE_PROTOCOL;
686
- exports.WEBSOCKET_CLOSE_REASON_MAXLEN = WEBSOCKET_CLOSE_REASON_MAXLEN;
796
+ exports.WEBSOCKET_CLOSE_REASON_MAX_LENGTH = WEBSOCKET_CLOSE_REASON_MAX_LENGTH;
687
797
  exports.WEBSOCKET_CLOSE_TIMEOUT_MS = WEBSOCKET_CLOSE_TIMEOUT_MS;
688
- exports.WEBSOCKET_CLOSE_TOOBIG = WEBSOCKET_CLOSE_TOOBIG;
798
+ exports.WEBSOCKET_CLOSE_TOO_BIG = WEBSOCKET_CLOSE_TOO_BIG;
689
799
  exports.WEBSOCKET_CLOSE_UNSUPPORTED = WEBSOCKET_CLOSE_UNSUPPORTED;
690
- exports.WEBSOCKET_CONTROL_MAXLEN = WEBSOCKET_CONTROL_MAXLEN;
800
+ exports.WEBSOCKET_CONTROL_MAX_LENGTH = WEBSOCKET_CONTROL_MAX_LENGTH;
691
801
  exports.WEBSOCKET_FAIL_TIMEOUT_MS = WEBSOCKET_FAIL_TIMEOUT_MS;
692
802
  exports.WEBSOCKET_GUID = WEBSOCKET_GUID;
693
803
  exports.WEBSOCKET_MAX_PAYLOAD = WEBSOCKET_MAX_PAYLOAD;
@@ -702,13 +812,15 @@ exports.WEBSOCKET_READY_CLOSING = WEBSOCKET_READY_CLOSING;
702
812
  exports.WEBSOCKET_READY_CONNECTING = WEBSOCKET_READY_CONNECTING;
703
813
  exports.WEBSOCKET_READY_OPEN = WEBSOCKET_READY_OPEN;
704
814
  exports.WEBSOCKET_VERSION = WEBSOCKET_VERSION;
815
+ exports.WebSocketError = WebSocketError;
705
816
  exports.computeWebSocketAccept = computeWebSocketAccept;
706
817
  exports.createNodeWebSocket = createNodeWebSocket;
707
818
  exports.encodeWebSocketFrame = encodeWebSocketFrame;
708
819
  exports.isCloseCode = isCloseCode;
709
- exports.isWebSocketFrameCanonical = isWebSocketFrameCanonical;
820
+ exports.isWebSocketError = isWebSocketError;
710
821
  exports.isWebSocketKey = isWebSocketKey;
711
822
  exports.isWebSocketProtocol = isWebSocketProtocol;
823
+ exports.matchesWebSocketCanonical = matchesWebSocketCanonical;
712
824
  exports.measureWebSocketFrame = measureWebSocketFrame;
713
825
  exports.parseUTF8 = parseUTF8;
714
826
  exports.parseWebSocketFrame = parseWebSocketFrame;