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