@orkestrel/websocket 0.0.11 → 0.0.13

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 CHANGED
@@ -1,9 +1,14 @@
1
1
  # @orkestrel/websocket
2
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.
3
+ > The server-native bidirectional transport: a lean, typed wrapper over a raw upgraded
4
+ > `node:stream` Duplex socket that speaks only the RFC 6455 wire protocol, owning the
5
+ > handshake, the masked and unmasked frame codec, ping and pong, and the close handshake,
6
+ > and surfacing every message on an owned `emitter`.
7
+
8
+ Take the socket a `node:http` upgrade handler gives you, pass it to the
9
+ `createNodeWebSocket` function, and read every message off the returned handle's
10
+ `emitter`. Its sole runtime dependency is `@orkestrel/emitter`, which supplies that
11
+ typed emitter. Part of the `@orkestrel` line.
7
12
 
8
13
  ## Install
9
14
 
@@ -24,7 +29,7 @@ import { createServer } from 'node:http'
24
29
  import { createNodeWebSocket } from '@orkestrel/websocket'
25
30
 
26
31
  // 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
32
+ // from there. Passing the client's `sec-websocket-key` selects server mode — the
28
33
  // wrapper writes the 101 handshake, marks the connection open, and decodes frames.
29
34
  createServer().on('upgrade', (request, socket, head) => {
30
35
  const key = request.headers['sec-websocket-key']
@@ -1,70 +1,186 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_contract = require("@orkestrel/contract");
2
3
  let node_crypto = require("node:crypto");
3
4
  let _orkestrel_emitter = require("@orkestrel/emitter");
4
5
  //#region src/server/constants.ts
5
6
  /**
6
- * Names 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.
7
+ * Names the accept GUID concatenated to a client's `Sec-WebSocket-Key` before the accept
8
+ * hash, '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'.
8
9
  *
9
10
  * @remarks
10
- * A fixed, spec-mandated constant (RFC 6455 §4.2.2) read only by
11
+ * The base64-encoded SHA-1 of that concatenation is the `Sec-WebSocket-Accept` response
12
+ * value. A fixed, spec-mandated constant (RFC 6455 §4.2.2) — read only by
11
13
  * {@link computeWebSocketAccept}.
12
14
  */
13
15
  var WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
14
- /** Names the WebSocket protocol version this wrapper speaks (`Sec-WebSocket-Version: 13`). */
16
+ /**
17
+ * Names the supported protocol version, '13'.
18
+ *
19
+ * @remarks
20
+ * The value this wrapper speaks, carried by the `Sec-WebSocket-Version` handshake header.
21
+ */
15
22
  var WEBSOCKET_VERSION = "13";
16
- /** Names the text frame opcode — a UTF-8 payload (RFC 6455 §5.6). */
23
+ /**
24
+ * Names the text frame opcode, 0x01.
25
+ *
26
+ * @remarks
27
+ * A UTF-8 payload (RFC 6455 §5.6).
28
+ */
17
29
  var WEBSOCKET_OPCODE_TEXT = 1;
18
- /** Names the binary frame opcode — a raw byte payload (RFC 6455 §5.6). */
30
+ /**
31
+ * Names the binary frame opcode, 0x02.
32
+ *
33
+ * @remarks
34
+ * A raw byte payload (RFC 6455 §5.6).
35
+ */
19
36
  var WEBSOCKET_OPCODE_BINARY = 2;
20
- /** Names the continuation frame opcode — the next fragment of an open data message (RFC 6455 §5.4). */
37
+ /**
38
+ * Names the continuation frame opcode, 0x00.
39
+ *
40
+ * @remarks
41
+ * The next fragment of an open data message (RFC 6455 §5.4).
42
+ */
21
43
  var WEBSOCKET_OPCODE_CONTINUATION = 0;
22
- /** Names the close frame opcode — a control frame ending the connection (RFC 6455 §5.5.1). */
44
+ /**
45
+ * Names the close frame opcode, 0x08.
46
+ *
47
+ * @remarks
48
+ * A control frame ending the connection (RFC 6455 §5.5.1).
49
+ */
23
50
  var WEBSOCKET_OPCODE_CLOSE = 8;
24
- /** Names the ping frame opcode — a control frame the peer must answer with a pong (RFC 6455 §5.5.2). */
51
+ /**
52
+ * Names the ping frame opcode, 0x09.
53
+ *
54
+ * @remarks
55
+ * A control frame the peer must answer with a pong (RFC 6455 §5.5.2).
56
+ */
25
57
  var WEBSOCKET_OPCODE_PING = 9;
26
- /** Names the pong frame opcode — a control frame answering a ping (RFC 6455 §5.5.3). */
58
+ /**
59
+ * Names the pong frame opcode, 0x0a.
60
+ *
61
+ * @remarks
62
+ * A control frame answering a ping (RFC 6455 §5.5.3).
63
+ */
27
64
  var WEBSOCKET_OPCODE_PONG = 10;
28
- /** Names the ready state for a connecting WebSocket (before the handshake completes). */
65
+ /**
66
+ * Names the connecting ready state, 0.
67
+ *
68
+ * @remarks
69
+ * The state a WebSocket holds before its handshake completes.
70
+ */
29
71
  var WEBSOCKET_READY_CONNECTING = 0;
30
- /** Names the ready state for an open WebSocket (the handshake completed; frames flow). */
72
+ /**
73
+ * Names the open ready state, 1.
74
+ *
75
+ * @remarks
76
+ * The state a WebSocket holds after the handshake completes and while frames flow.
77
+ */
31
78
  var WEBSOCKET_READY_OPEN = 1;
32
- /** Names the ready state for a closing WebSocket (a close frame was sent or received). */
79
+ /**
80
+ * Names the closing ready state, 2.
81
+ *
82
+ * @remarks
83
+ * The state a WebSocket holds after a close frame is sent or received.
84
+ */
33
85
  var WEBSOCKET_READY_CLOSING = 2;
34
- /** Names the ready state for a closed WebSocket (the socket ended). */
86
+ /**
87
+ * Names the closed ready state, 3.
88
+ *
89
+ * @remarks
90
+ * The state a WebSocket holds after the socket ends.
91
+ */
35
92
  var WEBSOCKET_READY_CLOSED = 3;
36
- /** Names the normal-closure status code (RFC 6455 §7.4.1) — the default `close` code. */
93
+ /**
94
+ * Names the normal-closure status code, 1000.
95
+ *
96
+ * @remarks
97
+ * The default `close` code (RFC 6455 §7.4.1).
98
+ */
37
99
  var WEBSOCKET_CLOSE_NORMAL = 1e3;
38
- /** Names the protocol-error status code (RFC 6455 §7.4.1) — a framing/state rule was violated. */
100
+ /**
101
+ * Names the protocol-error status code, 1002.
102
+ *
103
+ * @remarks
104
+ * Sent when a framing or state rule was violated (RFC 6455 §7.4.1).
105
+ */
39
106
  var WEBSOCKET_CLOSE_PROTOCOL = 1002;
40
- /** Names the unsupported-data status code (RFC 6455 §7.4.1) — the endpoint received a data type it cannot accept, for example binary on a text-only endpoint. */
107
+ /**
108
+ * Names the unsupported-data status code, 1003.
109
+ *
110
+ * @remarks
111
+ * Sent when the endpoint received a data type it cannot accept (RFC 6455 §7.4.1), for
112
+ * example binary on a text-only endpoint.
113
+ */
41
114
  var WEBSOCKET_CLOSE_UNSUPPORTED = 1003;
42
- /** Names the invalid-frame-payload-data status code (RFC 6455 §7.4.1) — for example non-UTF-8 text or an unparseable close reason. */
115
+ /**
116
+ * Names the invalid-frame-payload-data status code, 1007.
117
+ *
118
+ * @remarks
119
+ * Sent for non-UTF-8 text or an unparseable close reason (RFC 6455 §7.4.1).
120
+ */
43
121
  var WEBSOCKET_CLOSE_INVALID = 1007;
44
- /** Names the message-too-big status code (RFC 6455 §7.4.1) — a reassembled message exceeded the payload cap. */
122
+ /**
123
+ * Names the message-too-big status code, 1009.
124
+ *
125
+ * @remarks
126
+ * Sent when a reassembled message exceeded the payload cap (RFC 6455 §7.4.1).
127
+ */
45
128
  var WEBSOCKET_CLOSE_TOO_BIG = 1009;
46
- /** Names the default maximum inbound single-frame length AND reassembled-message total byte count (100 MiB — the `ws` package default). */
129
+ /**
130
+ * Names the default cap on both an inbound frame's declared length and a reassembled
131
+ * message's total byte count, 104,857,600 bytes (100 MiB).
132
+ *
133
+ * @remarks
134
+ * The same value the `ws` package defaults to. Either breach closes
135
+ * {@link WEBSOCKET_CLOSE_TOO_BIG}.
136
+ */
47
137
  var WEBSOCKET_MAX_PAYLOAD = 104857600;
48
- /** Names the default close-handshake timeout in milliseconds — how long `close()` waits for the peer's echo before tearing the socket down. */
138
+ /**
139
+ * Names the default close-handshake timeout, 30,000 milliseconds — how long `close` waits
140
+ * for the peer's echo.
141
+ *
142
+ * @remarks
143
+ * After it expires the wrapper tears the socket down, so a silent peer cannot leak the
144
+ * handle open.
145
+ */
49
146
  var WEBSOCKET_CLOSE_TIMEOUT_MS = 3e4;
50
- /** Names the post-`#fail` flush grace in milliseconds — how long a validation-breach close frame is given to flush through the socket's write buffer before the hard `destroy()` fallback fires (the normal path destroys sooner, on the `end()` flush callback). */
147
+ /**
148
+ * Names the flush grace, 1,000 milliseconds, a validation-breach close frame is given
149
+ * before the hard teardown fallback destroys the socket.
150
+ *
151
+ * @remarks
152
+ * Armed after `#fail` writes the close frame, so the frame drains through the socket's
153
+ * write buffer rather than being discarded. The normal path destroys sooner, on the
154
+ * `end()` flush callback.
155
+ */
51
156
  var WEBSOCKET_FAIL_TIMEOUT_MS = 1e3;
52
- /** Names the maximum control-frame payload length in bytes (RFC 6455 §5.5). */
157
+ /**
158
+ * Names the maximum control-frame payload length, 125 bytes.
159
+ *
160
+ * @remarks
161
+ * The cap RFC 6455 §5.5 sets on every control frame's payload.
162
+ */
53
163
  var WEBSOCKET_CONTROL_MAX_LENGTH = 125;
54
- /** Names the maximum UTF-8 close-reason length after the two-byte status code. */
164
+ /**
165
+ * Names the maximum UTF-8 close-reason length after the two-byte status code, 123.
166
+ *
167
+ * @remarks
168
+ * What is left of {@link WEBSOCKET_CONTROL_MAX_LENGTH} after the close frame's status
169
+ * code.
170
+ */
55
171
  var WEBSOCKET_CLOSE_REASON_MAX_LENGTH = 123;
56
172
  //#endregion
57
173
  //#region src/server/errors.ts
58
174
  /**
59
- * Represents an error thrown by the WebSocket wrapper for a refused caller-supplied value.
175
+ * Represents an error the WebSocket wrapper throws for a refused caller-supplied value,
176
+ * carrying a machine-readable `code` and an optional `context`.
60
177
  *
61
178
  * @remarks
62
- * Carries a {@link WebSocketErrorCode} and an optional `context` record holding the
63
- * refused value under a key naming it: an `'OPTION'` carries the offending option
64
- * (`payload`, `timeout`, `key`, or `protocol`), a `'LIMIT'` carries `size` and the
65
- * `limit` it exceeded, a `'CLOSE'` carries the refused close `code`, and a `'FRAME'`
66
- * carries `opcode` or the mask's `size`. Narrow a caught value with
67
- * {@link isWebSocketError}.
179
+ * The `code` is a {@link WebSocketErrorCode}; the `context` record holds the refused
180
+ * value under a key naming it: an `'OPTION'` carries the offending option (`payload`,
181
+ * `timeout`, `key`, or `protocol`), a `'LIMIT'` carries `size` and the `limit` it
182
+ * exceeded, a `'CLOSE'` carries the refused close `code`, and a `'FRAME'` carries
183
+ * `opcode` or the mask's `size`. Narrow a caught value with {@link isWebSocketError}.
68
184
  *
69
185
  * @example
70
186
  * ```ts
@@ -95,7 +211,8 @@ var WebSocketError = class extends Error {
95
211
  }
96
212
  };
97
213
  /**
98
- * Checks whether a value is a {@link WebSocketError}.
214
+ * Checks whether a caught value is a {@link WebSocketError}, narrowing it so a `catch` can
215
+ * branch on `error.code`.
99
216
  *
100
217
  * @param value - The value to test (typically a `catch` binding)
101
218
  * @returns True if `value` is a `WebSocketError`; false otherwise
@@ -112,7 +229,7 @@ var WebSocketError = class extends Error {
112
229
  * ```
113
230
  */
114
231
  function isWebSocketError(value) {
115
- return value instanceof WebSocketError;
232
+ return (0, _orkestrel_contract.isInstance)(value, WebSocketError);
116
233
  }
117
234
  //#endregion
118
235
  //#region src/server/helpers.ts
@@ -131,15 +248,15 @@ function computeWebSocketAccept(key) {
131
248
  return (0, node_crypto.createHash)("sha1").update(key + WEBSOCKET_GUID).digest("base64");
132
249
  }
133
250
  /**
134
- * Reads the declared payload length off the front of a buffer, without buffering or
135
- * reading the payload itself.
251
+ * Reads the declared payload length off the front of a buffer without buffering or
252
+ * reading the payload itself, answering `undefined` until the length field is complete.
136
253
  *
137
254
  * @remarks
138
255
  * Decodes only byte 1's 7-bit length field, extended by the 16-bit (`126`) or 64-bit
139
256
  * (`127`) form exactly like `parseWebSocketFrame` — but stops there, so a caller
140
257
  * can reject an over-cap frame the moment its length is known, before the payload
141
- * bytes have even arrived. Returns `undefined` until the length field itself is fully
142
- * buffered (mirrors the parser's incomplete-buffer contract). Pure; never throws.
258
+ * bytes have even arrived. The incomplete-buffer contract mirrors the parser's. Pure;
259
+ * never throws.
143
260
  *
144
261
  * @param buffer - The accumulation buffer to read the next frame's length from
145
262
  * @returns The declared payload length, or `undefined` when the buffer is too short to know it yet
@@ -166,14 +283,14 @@ function measureWebSocketFrame(buffer) {
166
283
  return length;
167
284
  }
168
285
  /**
169
- * Checks whether the next frame uses the shortest valid RFC 6455 payload-length encoding.
286
+ * Checks whether the next frame uses the shortest valid RFC 6455 payload-length
287
+ * encoding, answering `undefined` until its length prefix is complete.
170
288
  *
171
289
  * @remarks
172
- * Returns `undefined` until the complete length prefix is buffered. The 16-bit form
173
- * is canonical only for lengths at least 126; the 64-bit form only for lengths at
174
- * least 65,536 and with its most-significant bit clear (RFC 6455 §5.2). Reads the same
175
- * length prefix as {@link measureWebSocketFrame}, under the same incomplete-buffer
176
- * contract. Pure; never throws.
290
+ * The 16-bit form is canonical only for lengths at least 126; the 64-bit form only for
291
+ * lengths at least 65,536 and with its most-significant bit clear (RFC 6455 §5.2). Reads
292
+ * the same length prefix as {@link measureWebSocketFrame}, under the same
293
+ * incomplete-buffer contract. Pure; never throws.
177
294
  *
178
295
  * @param buffer - The accumulation buffer containing the next frame header
179
296
  * @returns Its canonicality, or `undefined` while the length prefix is incomplete
@@ -206,7 +323,7 @@ function matchesWebSocketCanonical(buffer) {
206
323
  * the 7-bit form below 126, the `126` + 16-bit form below 65 536, or the `127` +
207
324
  * 64-bit form beyond; when `masked` is set the mask bit is set, a 4-byte key (supplied
208
325
  * through `options.mask`, else random) is written, and the payload is XOR-masked. Server→
209
- * client frames are unmasked (the default); pass `masked: true` to encode a CLIENT
326
+ * client frames are unmasked (the default); pass `masked: true` to encode a client
210
327
  * frame (for example to feed the parser in a test). A `string` payload is encoded as
211
328
  * UTF-8. Returns one contiguous `Buffer` (header + payload), so the wrapper writes it
212
329
  * with a single `socket.write`. Pure.
@@ -218,10 +335,10 @@ function matchesWebSocketCanonical(buffer) {
218
335
  * @throws A {@link WebSocketError} coded `FRAME` when `opcode` is outside the four-bit wire field, when `options.mask` is not 4 bytes, or when `options.mask` is supplied without `masked: true`
219
336
  */
220
337
  function encodeWebSocketFrame(opcode, payload, options) {
221
- if (!Number.isInteger(opcode) || opcode < 0 || opcode > 15) throw new WebSocketError("FRAME", "opcode must be an integer between 0 and 15", { opcode });
338
+ if (!(0, _orkestrel_contract.isInteger)(opcode) || opcode < 0 || opcode > 15) throw new WebSocketError("FRAME", "opcode must be an integer between 0 and 15", { opcode });
222
339
  if (options?.mask !== void 0 && options.mask.length !== 4) throw new WebSocketError("FRAME", "mask must contain exactly 4 bytes", { size: options.mask.length });
223
340
  if (options?.mask !== void 0 && options.masked !== true) throw new WebSocketError("FRAME", "mask requires masked: true");
224
- const body = typeof payload === "string" ? Buffer.from(payload, "utf-8") : payload;
341
+ const body = (0, _orkestrel_contract.isString)(payload) ? Buffer.from(payload, "utf-8") : payload;
225
342
  const length = body.length;
226
343
  const masked = options?.masked === true;
227
344
  const mask = masked ? options?.mask ?? (0, node_crypto.randomBytes)(4) : void 0;
@@ -285,7 +402,8 @@ function isWebSocketProtocol(protocol) {
285
402
  return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(protocol);
286
403
  }
287
404
  /**
288
- * Checks whether a numeric value is a valid RFC 6455 close status code to RECEIVE (§7.4.1).
405
+ * Checks whether a numeric value is a close status code an RFC 6455 endpoint may
406
+ * receive (§7.4.1).
289
407
  *
290
408
  * @remarks
291
409
  * True for `1000`–`1003`, `1007`–`1014`, and the application range `3000`–`4999`; false
@@ -306,7 +424,7 @@ function isWebSocketProtocol(protocol) {
306
424
  * ```
307
425
  */
308
426
  function isCloseCode(code) {
309
- if (!Number.isInteger(code)) return false;
427
+ if (!(0, _orkestrel_contract.isInteger)(code)) return false;
310
428
  if (code >= 1e3 && code <= 1003) return true;
311
429
  if (code >= 1007 && code <= 1014) return true;
312
430
  if (code >= 3e3 && code <= 4999) return true;
@@ -315,18 +433,19 @@ function isCloseCode(code) {
315
433
  //#endregion
316
434
  //#region src/server/parsers.ts
317
435
  /**
318
- * Decodes a single RFC 6455 frame from the front of a buffer.
436
+ * Decodes a single RFC 6455 frame from the front of a buffer, answering `undefined`
437
+ * while the buffer is incomplete so the caller accumulates and retries.
319
438
  *
320
439
  * @remarks
321
440
  * Reads the FIN bit and opcode (byte 0), the mask bit and 7-bit payload length (byte
322
441
  * 1) — extended to a 16-bit length when the 7-bit field is `126`, or a 64-bit length
323
442
  * when it is `127` — the optional 4-byte mask key, then the payload, XOR-unmasking it
324
- * against the key when the mask bit is set (client→server frames MUST be masked, RFC
443
+ * against the key when the mask bit is set (client→server frames must be masked, RFC
325
444
  * 6455 §5.3; an unmasked frame still decodes, leaving the payload as-is, so the caller
326
- * can enforce policy). Returns `undefined` the moment the buffer is too short for the
327
- * part it is up to (the length prefix, the mask, or the full payload) — the signal to
328
- * the caller to read more bytes and retry. `consumed` is the total bytes the frame
329
- * occupied, so the caller slices the remainder. Pure; never throws on a short buffer.
445
+ * can enforce policy). The incomplete answer comes the moment the buffer is too short
446
+ * for the part it is up to: the length prefix, the mask, or the full payload.
447
+ * `consumed` is the total bytes the frame occupied, so the caller slices the remainder.
448
+ * Pure; never throws on a short buffer.
330
449
  *
331
450
  * @param buffer - The accumulation buffer to decode the next frame from
332
451
  * @returns The parsed {@link WebSocketFrame}, or `undefined` when the buffer is incomplete
@@ -378,12 +497,13 @@ function parseWebSocketFrame(buffer) {
378
497
  };
379
498
  }
380
499
  /**
381
- * Decodes a byte sequence as strict UTF-8, or signals it is malformed.
500
+ * Decodes a byte sequence as strict UTF-8, answering `undefined` when the sequence is
501
+ * malformed.
382
502
  *
383
503
  * @remarks
384
- * Wraps `TextDecoder('utf-8', { fatal: true })` in a try/catch so a malformed sequence
385
- * returns `undefined` instead of throwing — a guard-adjacent coercer never throws on bad
386
- * input. Pure.
504
+ * Wraps `TextDecoder('utf-8', { fatal: true })` in a try/catch, so a malformed sequence
505
+ * returns rather than throwing — a guard-adjacent coercer never throws on bad input.
506
+ * Pure.
387
507
  *
388
508
  * @param bytes - The raw bytes to decode
389
509
  * @returns The decoded string, or `undefined` when `bytes` is not valid UTF-8
@@ -404,18 +524,19 @@ function parseUTF8(bytes) {
404
524
  //#endregion
405
525
  //#region src/server/NodeWebSocket.ts
406
526
  /**
407
- * Represents a server-native WebSocket over a raw upgraded `node:stream` Duplex — the lean
408
- * wrapper around the RFC 6455 wire protocol.
527
+ * Implements the wrapper contract over a raw upgraded `node:stream` Duplex socket,
528
+ * driving the RFC 6455 handshake, the frame codec, auto-pong, and the close handshake,
529
+ * and surfacing every event on an owned `emitter`.
409
530
  *
410
531
  * @remarks
411
- * Created by `createNodeWebSocket`. When given a client `key` it runs in SERVER mode —
532
+ * Created by `createNodeWebSocket`. When given a client `key` it runs in server mode —
412
533
  * it writes the `101 Switching Protocols` handshake (`computeWebSocketAccept(key)`) and
413
- * emits `open`; given no key it runs in CLIENT mode (no handshake, frames masked). It
534
+ * emits `open`; given no key it runs in client mode (no handshake, frames masked). It
414
535
  * then listens on the socket's `data`, accumulating bytes in `#buffer` and decoding
415
536
  * every complete frame with {@link parseWebSocketFrame} (slicing `consumed` and
416
- * re-parsing the remainder): a TEXT frame — reassembling continuation fragments across
417
- * `fin: false` frames — decodes to UTF-8 and emits `message`; a PING is auto-answered
418
- * with a PONG and emits `ping`; a PONG emits `pong`; a CLOSE is echoed and ends the
537
+ * re-parsing the remainder): a text frame — reassembling continuation fragments across
538
+ * `fin: false` frames — decodes to UTF-8 and emits `message`; a ping is auto-answered
539
+ * with a pong and emits `ping`; a pong emits `pong`; a close frame is echoed and ends the
419
540
  * socket, emitting `close`. `send` writes a text frame, `ping` a ping, `close` a close
420
541
  * frame; `destroy` tears down immediately. It owns a typed `#emitter` by composition, and
421
542
  * the emitter isolates a throwing listener and routes the error to its own `error` handler
@@ -462,8 +583,8 @@ var NodeWebSocket = class {
462
583
  * Creates a WebSocket wrapper over an already-upgraded Duplex socket.
463
584
  *
464
585
  * @remarks
465
- * `key` selects the mode: present runs SERVER mode and writes the `101 Switching
466
- * Protocols` handshake, omitted runs CLIENT mode and masks every outgoing frame.
586
+ * `key` selects the mode: present runs server mode and writes the `101 Switching
587
+ * Protocols` handshake, omitted runs client mode and masks every outgoing frame.
467
588
  * {@link NodeWebSocketOptions} describes every member.
468
589
  *
469
590
  * @param options - The {@link NodeWebSocketOptions} the wrapper is built from
@@ -471,9 +592,9 @@ var NodeWebSocket = class {
471
592
  */
472
593
  constructor(options) {
473
594
  const payload = options.payload ?? 104857600;
474
- if (!Number.isSafeInteger(payload) || payload < 0) throw new WebSocketError("OPTION", "payload must be a non-negative safe integer", { payload });
595
+ if (!(0, _orkestrel_contract.isNumber)(payload) || !Number.isSafeInteger(payload) || payload < 0) throw new WebSocketError("OPTION", "payload must be a non-negative safe integer", { payload });
475
596
  const timeout = options.timeout ?? 3e4;
476
- if (!Number.isSafeInteger(timeout) || timeout < 0) throw new WebSocketError("OPTION", "timeout must be a non-negative safe integer", { timeout });
597
+ if (!(0, _orkestrel_contract.isNumber)(timeout) || !Number.isSafeInteger(timeout) || timeout < 0) throw new WebSocketError("OPTION", "timeout must be a non-negative safe integer", { timeout });
477
598
  if (options.key !== void 0 && !isWebSocketKey(options.key)) throw new WebSocketError("OPTION", "key must be the canonical base64 encoding of 16 bytes", { key: options.key });
478
599
  if (options.protocol !== void 0 && !isWebSocketProtocol(options.protocol)) throw new WebSocketError("OPTION", "protocol must be a valid WebSocket subprotocol token", { protocol: options.protocol });
479
600
  if (options.protocol !== void 0 && options.key === void 0) throw new WebSocketError("OPTION", "protocol requires a server key", { protocol: options.protocol });
@@ -743,33 +864,31 @@ var NodeWebSocket = class {
743
864
  }
744
865
  #bytes(chunk) {
745
866
  if (Buffer.isBuffer(chunk)) return chunk;
746
- if (typeof chunk === "string") return Buffer.from(chunk, "utf-8");
867
+ if ((0, _orkestrel_contract.isString)(chunk)) return Buffer.from(chunk, "utf-8");
747
868
  }
748
869
  };
749
870
  //#endregion
750
871
  //#region src/server/factories.ts
751
872
  /**
752
- * Creates a server-native WebSocket over a raw upgraded `node:stream` Duplex socket.
873
+ * Creates a server-native WebSocket over a raw upgraded `node:stream` Duplex socket
874
+ * server mode when a `key` is given, client mode otherwise.
753
875
  *
754
876
  * @remarks
755
- * The construction entry point for the {@link NodeWebSocketInterface}. Pass
756
- * the upgraded `socket` plus the client's `Sec-WebSocket-Key` as `key` to run in SERVER
757
- * mode the wrapper writes the `101 Switching Protocols` handshake and sends unmasked
758
- * frames; omit `key` for CLIENT mode (no handshake, masked frames). This is the
759
- * lean-native handle; it speaks only the WebSocket wire protocol — an MCP transport (the
760
- * later chunk) is built ON it. It is the WebSocket counterpart to
761
- * `createSQLiteDatabase` / `createIndexedDBDatabase`.
877
+ * The construction entry point for the {@link NodeWebSocketInterface}. In server mode the
878
+ * wrapper writes the `101 Switching Protocols` handshake and sends unmasked frames; in
879
+ * client mode it writes no handshake and masks every outgoing frame. This is the
880
+ * lean-native handle: it speaks the WebSocket wire protocol and nothing above it, so a
881
+ * message transport is built on it rather than into it.
762
882
  *
763
883
  * @param options - The {@link NodeWebSocketOptions} (`socket`, optional `key` / `head` /
764
884
  * `protocol` / `on`)
765
885
  * @returns A typed {@link NodeWebSocketInterface}
766
886
  * @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`
767
887
  *
768
- * @example
888
+ * @example Accept an upgrade and echo messages (server mode)
769
889
  * ```ts
770
- * import { createNodeWebSocket } from '@src/server'
890
+ * import { createNodeWebSocket } from '@orkestrel/websocket'
771
891
  *
772
- * // In a node:http 'upgrade' handler — server mode, identified by the client key:
773
892
  * server.on('upgrade', (request, socket, head) => {
774
893
  * const key = request.headers['sec-websocket-key']
775
894
  * if (typeof key !== 'string') {
@@ -778,10 +897,12 @@ var NodeWebSocket = class {
778
897
  * }
779
898
  * const ws = createNodeWebSocket({
780
899
  * socket,
781
- * key, // present => server mode + 101 handshake
782
- * head,
783
- * on: { message: (text) => ws.send(`echo: ${text}`) },
900
+ * key,
901
+ * head, // any bytes already buffered after the upgrade headers
902
+ * on: { message: (text) => ws.send(`echo: ${text}`) }, // wired before the first frame arrives
784
903
  * })
904
+ * ws.emitter.on('message', (text) => log('echoed', text)) // a second observer of the same event
905
+ * ws.emitter.on('close', (code, reason) => log('closed', code, reason))
785
906
  * })
786
907
  * ```
787
908
  */