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