@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.
- package/README.md +8 -3
- package/dist/src/server/index.cjs +317 -205
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +202 -109
- package/dist/src/server/index.d.ts +202 -109
- package/dist/src/server/index.js +312 -202
- package/dist/src/server/index.js.map +1 -1
- package/package.json +19 -15
package/dist/src/server/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
13
|
+
/** Names the WebSocket protocol version this wrapper speaks (`Sec-WebSocket-Version: 13`). */
|
|
14
14
|
var WEBSOCKET_VERSION = "13";
|
|
15
|
-
/**
|
|
15
|
+
/** Names the text frame opcode — a UTF-8 payload (RFC 6455 §5.6). */
|
|
16
16
|
var WEBSOCKET_OPCODE_TEXT = 1;
|
|
17
|
-
/**
|
|
17
|
+
/** Names the binary frame opcode — a raw byte payload (RFC 6455 §5.6). */
|
|
18
18
|
var WEBSOCKET_OPCODE_BINARY = 2;
|
|
19
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
27
|
+
/** Names the ready state for a connecting WebSocket (before the handshake completes). */
|
|
28
28
|
var WEBSOCKET_READY_CONNECTING = 0;
|
|
29
|
-
/**
|
|
29
|
+
/** Names the ready state for an open WebSocket (the handshake completed; frames flow). */
|
|
30
30
|
var WEBSOCKET_READY_OPEN = 1;
|
|
31
|
-
/**
|
|
31
|
+
/** Names the ready state for a closing WebSocket (a close frame was sent or received). */
|
|
32
32
|
var WEBSOCKET_READY_CLOSING = 2;
|
|
33
|
-
/**
|
|
33
|
+
/** Names the ready state for a closed WebSocket (the socket ended). */
|
|
34
34
|
var WEBSOCKET_READY_CLOSED = 3;
|
|
35
|
-
/**
|
|
35
|
+
/** Names the normal-closure status code (RFC 6455 §7.4.1) — the default `close` code. */
|
|
36
36
|
var WEBSOCKET_CLOSE_NORMAL = 1e3;
|
|
37
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
44
|
-
var
|
|
45
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
52
|
-
var
|
|
53
|
-
/**
|
|
54
|
-
var
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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))
|
|
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
|
-
*
|
|
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
|
|
123
|
-
*
|
|
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
|
-
*
|
|
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
|
|
240
|
-
*
|
|
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
|
-
*
|
|
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`
|
|
349
|
-
* isolates a throwing listener and routes the error to its own `error` handler
|
|
350
|
-
* option) — the socket never crashes. An underlying socket error emits the
|
|
351
|
-
* `error` event and terminates the wrapper. The untyped socket `data` is narrowed
|
|
352
|
-
* `Buffer` with a guard, never an assertion
|
|
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
|
|
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
|
|
380
|
-
if (options.key !== void 0 && !isWebSocketKey(options.key)) throw new
|
|
381
|
-
if (options.protocol !== void 0 && !isWebSocketProtocol(options.protocol)) throw new
|
|
382
|
-
if (options.protocol !== void 0 && options.key === void 0) throw new
|
|
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(
|
|
520
|
+
send(message) {
|
|
425
521
|
if (this.#readyState !== 1) return;
|
|
426
|
-
this.#write(1, Buffer.from(
|
|
522
|
+
this.#write(1, Buffer.from(message, "utf-8"));
|
|
427
523
|
}
|
|
428
|
-
ping(
|
|
524
|
+
ping(payload) {
|
|
429
525
|
if (this.#readyState !== 1) return;
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
436
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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}
|
|
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
|
|
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,
|
|
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
|