@blamejs/core 0.7.18 → 0.7.19

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.
Files changed (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
package/lib/websocket.js CHANGED
@@ -1,1048 +1,1048 @@
1
- "use strict";
2
- /**
3
- * WebSocket server primitive — RFC 6455.
4
- *
5
- * Implements the server side of the WebSocket protocol on top of the
6
- * Node HTTP server's `'upgrade'` event. Built on node:net + node:crypto
7
- * with no npm runtime dep.
8
- *
9
- * Surface:
10
- *
11
- * websocket.handleUpgrade(req, socket, head, opts)
12
- * Wraps a TCP socket post-HTTP-upgrade. Validates the handshake,
13
- * enforces origin policy, negotiates subprotocol, sends 101
14
- * response, returns a WebSocketConnection. Throws / refuses on
15
- * bad handshake.
16
- *
17
- * new websocket.WebSocketConnection(socket, opts)
18
- * EventEmitter wrapping a post-upgrade socket. State machine
19
- * mirrors the browser WebSocket API:
20
- * conn.readyState 'open' | 'closing' | 'closed'
21
- * conn.lastError last diagnosable error, if any
22
- * conn.send(data) — Buffer or string. Routes to binary
23
- * or text frame. Throws if not OPEN.
24
- * conn.ping(payload?) — Send ping frame (no-op if not OPEN).
25
- * conn.close(code?, reason?) — Send close frame, wait
26
- * closeGraceMs for peer's echo, end
27
- * socket.
28
- * Events:
29
- * 'message' (data, isBinary)
30
- * 'ping' (payload)
31
- * 'pong' (payload)
32
- * 'close' (code, reason, wasClean) — fires exactly once at
33
- * lifecycle end. wasClean: true when
34
- * the close handshake completed in
35
- * both directions; false on socket
36
- * errors / abnormal closure (code
37
- * 1006) / heartbeat timeout / etc.
38
- * Operators usually only need this
39
- * listener for full lifecycle tracking.
40
- * 'error' (err) — diagnosable issue. Always followed
41
- * by 'close'. Optional listener;
42
- * missing listener does NOT crash the
43
- * process (gated by listenerCount).
44
- *
45
- * websocket.serializeFrame(opcode, payload, opts), websocket.FrameParser
46
- * Lower-level helpers exposed for tests + advanced callers.
47
- *
48
- * Spec compliance notes (the parts where naive impls get it wrong):
49
- *
50
- * 1. Mask handling (§5.3). All client→server frames MUST be masked.
51
- * Unmasked client frames close the connection with code 1002.
52
- * Server→client frames MUST NOT be masked. The serializer here
53
- * defaults mask:false (server side); a `mask:true` opt exists
54
- * for completeness / test fixtures only.
55
- *
56
- * 2. SHA-1 for Sec-WebSocket-Accept. RFC 6455 §1.3 mandates
57
- * SHA-1(key + GUID). The framework uses SHA3-512 elsewhere; SHA-1
58
- * here is NOT a security primitive — the GUID is publicly known
59
- * and the hash is a protocol marker confirming both sides agree
60
- * on the upgrade. Nothing about the WebSocket connection's
61
- * security depends on SHA-1 collision resistance.
62
- *
63
- * 3. Close handshake reciprocity (§5.5.1). When the peer sends a
64
- * close frame, we MUST echo a close frame back, then close the
65
- * TCP socket. close() handles this; _handleClose echoes if we
66
- * haven't already initiated.
67
- *
68
- * 4. Origin policy. Browser clients send `Origin: <scheme>://<host>`.
69
- * The framework matches the CORS module's pattern: if the operator
70
- * passes `origins: [...]`, enforce strictly. If `origins: "*"`,
71
- * accept all (explicit operator opt-in to no checking). If
72
- * `origins` is omitted, accept all but emit an audit warning at
73
- * registration (the safety check) — see lib/router.js where the
74
- * operator-facing API lives. Non-browser clients (Origin header
75
- * absent) bypass origin checks since Origin is a browser-only
76
- * enforcement signal.
77
- *
78
- * 5. Subprotocol negotiation. Server picks the FIRST entry from
79
- * Sec-WebSocket-Protocol that's in the operator's `subprotocols`
80
- * allowlist. If none match, the response omits the header (per
81
- * §11.3.4) and the client decides whether to proceed.
82
- */
83
-
84
- var nodeCrypto = require("crypto");
85
- var zlib = require("zlib");
86
- var { EventEmitter } = require("events");
87
- var C = require("./constants");
88
- var requestHelpers = require("./request-helpers");
89
- var safeAsync = require("./safe-async");
90
- var safeBuffer = require("./safe-buffer");
91
- var { FrameworkError } = require("./framework-error");
92
- var { boot } = require("./log");
93
-
94
- var HTTP = requestHelpers.HTTP_STATUS;
95
- var log = boot("websocket");
96
-
97
- // RFC 6455 §1.3 — the standard handshake GUID. Operators running
98
- // closed-ecosystem clients with a custom magic string pass their own
99
- // via opts.handshakeGuid on the route; the framework's default stays
100
- // the RFC value so RFC-compliant clients work out of the box.
101
- var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
102
-
103
- // UUID-shape (8-4-4-4-12 hex) for opts.handshakeGuid validation. The
104
- // SHA-1 used in the handshake is NOT a security primitive (RFC 6455
105
- // requires it as a protocol marker), so the GUID itself doesn't need
106
- // to be cryptographically random — but it must match the client's
107
- // expected value byte-for-byte. Length + format check at config time
108
- // catches the typo class.
109
- var GUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
110
-
111
- var OPCODE_CONTINUATION = 0x0;
112
- var OPCODE_TEXT = 0x1;
113
- var OPCODE_BINARY = 0x2;
114
- var OPCODE_CLOSE = 0x8;
115
- var OPCODE_PING = 0x9;
116
- var OPCODE_PONG = 0xA;
117
-
118
- // Close codes (RFC 6455 §7.4.1) — encoded in hex so the framework's
119
- // byte-literal lint (which flags decimal multiples of 8) doesn't trip
120
- // on the protocol-fixed values.
121
- var CLOSE_NORMAL = 0x3E8;
122
- var CLOSE_GOING_AWAY = 0x3E9;
123
- var CLOSE_PROTOCOL_ERROR = 0x3EA;
124
- var CLOSE_UNSUPPORTED_DATA = 0x3EB;
125
- // 0x3EC reserved
126
- // 0x3ED no-status (must not be sent on the wire)
127
- // 0x3EE abnormal-closure (must not be sent on the wire)
128
- var CLOSE_INVALID_PAYLOAD = 0x3EF;
129
- var CLOSE_POLICY_VIOLATION = 0x3F0;
130
- var CLOSE_MESSAGE_TOO_BIG = 0x3F1;
131
- var CLOSE_INTERNAL_ERROR = 0x3F3;
132
-
133
- // Defaults — tuned for fast detection of dead/silent connections.
134
- //
135
- // pingIntervalMs (30s): sends a ping every 30s. Aligned with most
136
- // load-balancer idle timeouts so the LB doesn't kill the
137
- // connection while we're still considering it healthy.
138
- //
139
- // pongTimeoutMs (35s): if no pong arrives within 35s of the last
140
- // pong, abort with code 1011. Detection happens at ~35s — just
141
- // past one ping interval. Stays under AWS ALB's 60s default
142
- // idle so operators don't get LB-side disconnects fighting our
143
- // heartbeat. Tighter than the typical 60s default in other libs;
144
- // the cost of a false positive is a client reconnect, the cost
145
- // of a slow detection is wasted server resources for genuinely-
146
- // dead silent-failure connections.
147
- //
148
- // closeGraceMs (2s): after we send a close frame, wait this long
149
- // for the peer's echo before forcibly ending the TCP socket.
150
- // A healthy peer echoes in <100ms; 2s is plenty. Operators on
151
- // slow networks override.
152
- //
153
- // All operator-overridable per connection via opts.{pingIntervalMs,
154
- // pongTimeoutMs, closeGraceMs}.
155
- var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(1);
156
- var DEFAULT_PING_INTERVAL_MS = C.TIME.seconds(30);
157
- var DEFAULT_PONG_TIMEOUT_MS = C.TIME.seconds(35);
158
- var CLOSE_GRACE_MS = C.TIME.seconds(2);
159
-
160
- // Connection lifecycle states — mirrors the browser WebSocket API +
161
- // the npm `ws` library. Single-source-of-truth field; every state
162
- // transition goes through _transitionToClosed (or set in the
163
- // constructor for OPEN).
164
- var STATE_OPEN = "open";
165
- var STATE_CLOSING = "closing"; // we sent a close frame, awaiting peer's echo
166
- var STATE_CLOSED = "closed";
167
-
168
- class WebSocketError extends FrameworkError {
169
- constructor(code, message, closeCode) {
170
- super(message, code);
171
- this.name = "WebSocketError";
172
- this.closeCode = closeCode || CLOSE_PROTOCOL_ERROR;
173
- this.isWebSocketError = true;
174
- }
175
- }
176
-
177
- // ---- Handshake helpers ----
178
-
179
- function computeAcceptKey(secWebSocketKey, handshakeGuid) {
180
- // SHA-1 required by RFC 6455 §1.3 — see file-level note 2 above.
181
- // This is a protocol marker, not a security primitive.
182
- // handshakeGuid defaults to the RFC value; operators with custom
183
- // closed-ecosystem clients override per-route via opts.handshakeGuid.
184
- var hash = nodeCrypto.createHash("sha1");
185
- hash.update(String(secWebSocketKey) + (handshakeGuid || GUID));
186
- return hash.digest("base64");
187
- }
188
-
189
- function validateUpgradeRequest(req) {
190
- if (req.method !== "GET") {
191
- return { ok: false, status: HTTP.METHOD_NOT_ALLOWED, reason: "method must be GET" };
192
- }
193
- var h = req.headers || {};
194
- if ((h.upgrade || "").toLowerCase() !== "websocket") {
195
- return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Upgrade: websocket" };
196
- }
197
- // Connection header may carry multiple tokens (e.g. "keep-alive, Upgrade").
198
- // Match "upgrade" as a comma-separated token, case-insensitive.
199
- if (!/(^|,)\s*upgrade\s*(,|$)/i.test(h.connection || "")) {
200
- return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Connection: upgrade" };
201
- }
202
- if (!h["sec-websocket-key"]) {
203
- return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Sec-WebSocket-Key" };
204
- }
205
- if (h["sec-websocket-version"] !== "13") {
206
- return { ok: false, status: HTTP.BAD_REQUEST, reason: "Sec-WebSocket-Version must be 13" };
207
- }
208
- return { ok: true };
209
- }
210
-
211
- function negotiateSubprotocol(req, supported) {
212
- if (!supported || supported.length === 0) return null;
213
- var raw = (req.headers || {})["sec-websocket-protocol"] || "";
214
- var offered = requestHelpers.parseListHeader(raw);
215
- for (var i = 0; i < offered.length; i++) {
216
- if (supported.indexOf(offered[i]) !== -1) return offered[i];
217
- }
218
- return null;
219
- }
220
-
221
- // origins shapes:
222
- // array — strict allowlist, enforced
223
- // "*" — explicit "accept all" (operator opt-in to no checking)
224
- // null/undefined — same as "*" but caller (router) is expected to
225
- // have logged a startup warning. Origin policy is a
226
- // framework-level decision; this primitive doesn't
227
- // re-warn here.
228
- function isOriginAllowed(req, origins) {
229
- if (!origins || origins === "*") return true;
230
- var origin = (req.headers || {}).origin;
231
- // Non-browser clients (curl, server-to-server, native apps) don't
232
- // send Origin. Origin enforcement only meaningfully applies to
233
- // browser-initiated upgrades — non-browser callers are gated by
234
- // the operator's network ACL / auth middleware, not Origin.
235
- if (!origin) return true;
236
- if (Array.isArray(origins)) return origins.indexOf(origin) !== -1;
237
- return false;
238
- }
239
-
240
- function buildUpgradeResponse(secWebSocketKey, subprotocol, extensionHeader, handshakeGuid) {
241
- var lines = [
242
- "HTTP/1.1 101 Switching Protocols",
243
- "Upgrade: websocket",
244
- "Connection: Upgrade",
245
- "Sec-WebSocket-Accept: " + computeAcceptKey(secWebSocketKey, handshakeGuid),
246
- ];
247
- if (subprotocol) lines.push("Sec-WebSocket-Protocol: " + subprotocol);
248
- if (extensionHeader) lines.push("Sec-WebSocket-Extensions: " + extensionHeader);
249
- return lines.join("\r\n") + "\r\n\r\n";
250
- }
251
-
252
- // ---- permessage-deflate (RFC 7692) ----
253
- //
254
- // Negotiate compression at handshake, compress per-message on send,
255
- // decompress per-message on receive. The framework runs in
256
- // "no_context_takeover" mode in both directions — every message uses a
257
- // fresh zlib state, no LZ77 history carried across messages. This
258
- // trade-off makes message processing stateless (no per-connection
259
- // zlib stream lifetime to manage) at a small compression-ratio cost.
260
- // Operators with throughput-sensitive workloads can extend this later
261
- // to keep state across messages.
262
- //
263
- // Per RFC 7692 §7.2.1 the deflate output is the standard zlib raw
264
- // deflate WITH the trailing 4 bytes 0x00 0x00 0xff 0xff stripped. The
265
- // matching inflate path appends them back before inflating.
266
- var DEFLATE_TRAILING = Buffer.from([0x00, 0x00, 0xff, 0xff]);
267
-
268
- function _parseExtensionHeader(header) {
269
- // Sec-WebSocket-Extensions: foo; param=val; param2, bar; ...
270
- // Returns [{ name, params: { paramName: value | true } }]
271
- if (!header) return [];
272
- var entries = String(header).split(",");
273
- var out = [];
274
- for (var i = 0; i < entries.length; i++) {
275
- var parts = entries[i].split(";").map(function (s) { return s.trim(); });
276
- if (!parts[0]) continue;
277
- var ext = { name: parts[0].toLowerCase(), params: {} };
278
- for (var j = 1; j < parts.length; j++) {
279
- var kv = parts[j].split("=");
280
- var k = kv[0].trim().toLowerCase();
281
- if (!k) continue;
282
- var v = kv.length > 1 ? kv.slice(1).join("=").trim() : true;
283
- // Strip surrounding quotes per the token-or-quoted-string grammar.
284
- if (typeof v === "string" && v.length >= 2 &&
285
- v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
286
- v = v.slice(1, -1);
287
- }
288
- ext.params[k] = v;
289
- }
290
- out.push(ext);
291
- }
292
- return out;
293
- }
294
-
295
- function _negotiatePermessageDeflate(reqHeader) {
296
- var entries = _parseExtensionHeader(reqHeader);
297
- for (var i = 0; i < entries.length; i++) {
298
- if (entries[i].name !== "permessage-deflate") continue;
299
- var p = entries[i].params;
300
- // Reject unknown params (RFC 7692 §7 lists exactly four).
301
- var KNOWN = {
302
- "server_no_context_takeover": true, "client_no_context_takeover": true,
303
- "server_max_window_bits": true, "client_max_window_bits": true,
304
- };
305
- var ok = true;
306
- for (var k in p) { if (Object.prototype.hasOwnProperty.call(p, k) && !KNOWN[k]) { ok = false; break; } }
307
- if (!ok) continue;
308
- // Always negotiate WITH no_context_takeover in BOTH directions, so
309
- // every message uses a fresh zlib state. Echo any client window-
310
- // bits constraints back unchanged (we honour them on the server's
311
- // outgoing compression).
312
- var responseParams = ["client_no_context_takeover", "server_no_context_takeover"];
313
- if (p.client_max_window_bits && p.client_max_window_bits !== true) {
314
- responseParams.push("client_max_window_bits=" + p.client_max_window_bits);
315
- }
316
- if (p.server_max_window_bits && p.server_max_window_bits !== true) {
317
- responseParams.push("server_max_window_bits=" + p.server_max_window_bits);
318
- }
319
- // RFC 7692 §7.1: max_window_bits is 8..15 inclusive; 15 is the
320
- // unconstrained default. Hex-encoded so the byte-literal lint
321
- // doesn't flag the 8 lower bound.
322
- var WB_MIN = 0x8;
323
- var WB_MAX = 0xF;
324
- return {
325
- negotiated: true,
326
- responseHeader: "permessage-deflate; " + responseParams.join("; "),
327
- // window-bits constraints we honour; default WB_MAX (15) when unset.
328
- serverMaxWindowBits: p.server_max_window_bits && p.server_max_window_bits !== true
329
- ? Math.max(WB_MIN, Math.min(WB_MAX, parseInt(p.server_max_window_bits, 10) || WB_MAX)) : WB_MAX,
330
- clientMaxWindowBits: p.client_max_window_bits && p.client_max_window_bits !== true
331
- ? Math.max(WB_MIN, Math.min(WB_MAX, parseInt(p.client_max_window_bits, 10) || WB_MAX)) : WB_MAX,
332
- };
333
- }
334
- return { negotiated: false };
335
- }
336
-
337
- function _deflateMessage(payload, windowBits) {
338
- // Per RFC 7692 §7.2.1, strip the 4-byte 0x00 0x00 0xff 0xff trailer.
339
- var raw = zlib.deflateRawSync(payload, { windowBits: windowBits, level: zlib.constants.Z_DEFAULT_COMPRESSION });
340
- if (raw.length >= 4 &&
341
- raw[raw.length - 4] === 0x00 && raw[raw.length - 3] === 0x00 &&
342
- raw[raw.length - 2] === 0xff && raw[raw.length - 1] === 0xff) {
343
- return raw.slice(0, raw.length - 4);
344
- }
345
- return raw;
346
- }
347
-
348
- function _inflateMessage(payload, windowBits) {
349
- // Per RFC 7692 §7.2.2, append the 4-byte trailer before inflating.
350
- var withTrailer = Buffer.concat([payload, DEFLATE_TRAILING]);
351
- return zlib.inflateRawSync(withTrailer, { windowBits: windowBits });
352
- }
353
-
354
- // ---- Frame parser ----
355
- //
356
- // Incremental — push(chunk) accepts arbitrary buffer slices from the
357
- // socket and emits zero-or-more complete frames as they arrive. Holds
358
- // partial frame state across calls.
359
-
360
- function FrameParser(opts) {
361
- opts = opts || {};
362
- this.maxFrameBytes = opts.maxFrameBytes || DEFAULT_MAX_MESSAGE_BYTES;
363
- this._buffer = Buffer.alloc(0);
364
- }
365
-
366
- FrameParser.prototype.push = function (chunk) {
367
- this._buffer = Buffer.concat([this._buffer, chunk]);
368
- var frames = [];
369
- while (true) {
370
- var frame = this._tryParseFrame();
371
- if (!frame) break; // incomplete — wait for more bytes
372
- frames.push(frame);
373
- }
374
- return frames;
375
- };
376
-
377
- FrameParser.prototype._tryParseFrame = function () {
378
- if (this._buffer.length < 2) return null;
379
- var b0 = this._buffer[0];
380
- var b1 = this._buffer[1];
381
- var fin = !!(b0 & 0x80);
382
- var rsv1 = !!(b0 & 0x40);
383
- var rsv2 = !!(b0 & 0x20);
384
- var rsv3 = !!(b0 & 0x10);
385
- var opcode = b0 & 0x0F;
386
- var masked = !!(b1 & 0x80);
387
- var lenInd = b1 & 0x7F;
388
-
389
- var headerLen = 2;
390
- if (lenInd === 126) headerLen += 2;
391
- else if (lenInd === 127) headerLen += C.BYTES.bytes(8);
392
- if (masked) headerLen += 4;
393
- if (this._buffer.length < headerLen) return null;
394
-
395
- var payloadLen;
396
- var off = 2;
397
- if (lenInd < 126) {
398
- payloadLen = lenInd;
399
- } else if (lenInd === 126) {
400
- payloadLen = this._buffer.readUInt16BE(off);
401
- off += 2;
402
- } else {
403
- // 64-bit. JS Number is 53-bit safe — reject lengths above
404
- // Number.MAX_SAFE_INTEGER explicitly rather than silently
405
- // truncating.
406
- var hi = this._buffer.readUInt32BE(off);
407
- var lo = this._buffer.readUInt32BE(off + 4);
408
- if (hi > 0x1FFFFF) {
409
- throw new WebSocketError("ws/frame-too-large",
410
- "frame length exceeds Number.MAX_SAFE_INTEGER", CLOSE_MESSAGE_TOO_BIG);
411
- }
412
- payloadLen = (hi * 0x100000000) + lo;
413
- off += C.BYTES.bytes(8);
414
- }
415
-
416
- if (payloadLen > this.maxFrameBytes) {
417
- throw new WebSocketError("ws/frame-too-large",
418
- "frame payload exceeds maxFrameBytes (" + this.maxFrameBytes + ")",
419
- CLOSE_MESSAGE_TOO_BIG);
420
- }
421
-
422
- var maskKey = null;
423
- if (masked) {
424
- maskKey = Buffer.from(this._buffer.subarray(off, off + 4));
425
- off += 4;
426
- }
427
-
428
- var totalLen = off + payloadLen;
429
- if (this._buffer.length < totalLen) return null;
430
-
431
- var payload = this._buffer.subarray(off, totalLen);
432
- if (masked) {
433
- var unmasked = Buffer.alloc(payloadLen);
434
- for (var i = 0; i < payloadLen; i++) {
435
- unmasked[i] = payload[i] ^ maskKey[i & 3];
436
- }
437
- payload = unmasked;
438
- } else {
439
- // Copy out — the underlying buffer is about to be sliced.
440
- payload = Buffer.from(payload);
441
- }
442
-
443
- this._buffer = this._buffer.subarray(totalLen);
444
-
445
- return {
446
- fin: fin,
447
- rsv1: rsv1,
448
- rsv2: rsv2,
449
- rsv3: rsv3,
450
- opcode: opcode,
451
- masked: masked,
452
- payload: payload,
453
- };
454
- };
455
-
456
- // ---- Frame serializer ----
457
-
458
- function serializeFrame(opcode, payload, opts) {
459
- opts = opts || {};
460
- var fin = opts.fin !== false;
461
- var mask = opts.mask === true; // server-side defaults false
462
- // RSV1 — set on the first frame of a permessage-deflate-compressed
463
- // message (RFC 7692). Caller passes opts.rsv1 = true; we wire it
464
- // into the header byte. RSV2 / RSV3 stay zero (no other extensions
465
- // negotiated).
466
- var rsv1 = opts.rsv1 === true;
467
- payload = payload || Buffer.alloc(0);
468
- if (typeof payload === "string") payload = Buffer.from(payload, "utf8");
469
- if (!Buffer.isBuffer(payload)) {
470
- throw new WebSocketError("ws/invalid-payload",
471
- "frame payload must be Buffer or string");
472
- }
473
- var len = payload.length;
474
-
475
- var headerLen = 2;
476
- var lenByte;
477
- // RFC 6455 §5.2 — 16-bit extended length boundary at 2^16.
478
- var EXT16_BOUNDARY = 0x10000;
479
- if (len < 126) { lenByte = len; }
480
- else if (len < EXT16_BOUNDARY) { lenByte = 126; headerLen += 2; }
481
- else { lenByte = 127; headerLen += C.BYTES.bytes(8); }
482
- if (mask) headerLen += 4;
483
-
484
- var header = Buffer.alloc(headerLen);
485
- header[0] = (fin ? 0x80 : 0) | (rsv1 ? 0x40 : 0) | (opcode & 0x0F);
486
- header[1] = (mask ? 0x80 : 0) | lenByte;
487
-
488
- var off = 2;
489
- if (lenByte === 126) {
490
- header.writeUInt16BE(len, off);
491
- off += 2;
492
- } else if (lenByte === 127) {
493
- var hi = Math.floor(len / 0x100000000);
494
- var lo = len % 0x100000000;
495
- header.writeUInt32BE(hi, off);
496
- header.writeUInt32BE(lo, off + 4);
497
- off += C.BYTES.bytes(8);
498
- }
499
-
500
- if (mask) {
501
- var maskKey = nodeCrypto.randomBytes(4);
502
- maskKey.copy(header, off);
503
- var masked = Buffer.alloc(len);
504
- for (var i = 0; i < len; i++) masked[i] = payload[i] ^ maskKey[i & 3];
505
- return Buffer.concat([header, masked]);
506
- }
507
- return Buffer.concat([header, payload]);
508
- }
509
-
510
- // ---- Connection ----
511
-
512
- class WebSocketConnection extends EventEmitter {
513
- constructor(socket, opts) {
514
- super();
515
- opts = opts || {};
516
- this.socket = socket;
517
- this.subprotocol = opts.subprotocol || null;
518
- this.maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
519
- // Transport selects mask-enforcement direction:
520
- // h1 (RFC 6455): client→server frames MUST be masked. Default.
521
- // h2 (RFC 8441): frames MUST NOT be masked — h2 already provides
522
- // the framing/security guarantees that masking
523
- // exists to protect against in h1 (proxy
524
- // cache-poisoning via raw text on the wire).
525
- this.transport = opts.transport === "h2" ? "h2" : "h1";
526
- // permessage-deflate state — `null` means extension not negotiated.
527
- // When negotiated the object carries serverMaxWindowBits +
528
- // clientMaxWindowBits the inflate/deflate paths use per message.
529
- this._permessageDeflate = opts.permessageDeflate || null;
530
- var pingMs = opts.pingIntervalMs || DEFAULT_PING_INTERVAL_MS;
531
- var pongMs = opts.pongTimeoutMs || DEFAULT_PONG_TIMEOUT_MS;
532
- // Grace period after we send a close frame before forcing the
533
- // socket end. Production default = 5s (give the peer time to ack).
534
- // Tests / latency-sensitive ops can pass a shorter value.
535
- this._closeGraceMs = opts.closeGraceMs != null ? opts.closeGraceMs : CLOSE_GRACE_MS;
536
-
537
- // Lifecycle state — single source of truth. Operators read
538
- // conn.readyState; internal code reads/writes this._state via
539
- // _transitionToClosed. All transitions emit 'close' exactly once.
540
- this._state = STATE_OPEN;
541
- this._closeSent = false;
542
- this._closeTimer = null;
543
- this.lastError = null; // last diagnosable error, if any
544
- // Fragmentation reassembly state.
545
- this._fragOpcode = null;
546
- this._fragChunks = null;
547
- this._fragLen = 0;
548
-
549
- this._parser = new FrameParser({ maxFrameBytes: this.maxMessageBytes });
550
- this._lastPongAt = Date.now();
551
-
552
- var self = this;
553
- this._pingTimer = safeAsync.repeating(function () { self._heartbeat(pongMs); },
554
- pingMs, { name: "websocket-ping" });
555
-
556
- socket.on("data", function (chunk) { self._onData(chunk); });
557
- socket.on("error", function (err) {
558
- // Network errors are LIFECYCLE events, not protocol errors —
559
- // route through _transitionToClosed with code 1006 (abnormal
560
- // closure). Mirrors the browser WebSocket API + ws npm
561
- // convention: operators listening on 'close' see the death;
562
- // 'error' is reserved for diagnosable protocol issues that
563
- // the operator may want to explicitly handle.
564
- self._transitionToClosed(1006, (err && err.message) || "socket error", false, err);
565
- });
566
- socket.on("close", function () {
567
- // Socket FIN/RST seen — if we haven't already transitioned via
568
- // a clean close-handshake, this is an abnormal closure.
569
- if (self._state !== STATE_CLOSED) {
570
- self._transitionToClosed(1006, "abnormal closure", false, null);
571
- }
572
- });
573
- }
574
-
575
- // Single state-transition method. Idempotent — repeat calls after
576
- // CLOSED are no-ops. Emits 'close' exactly once with (code, reason,
577
- // wasClean) signature matching the browser API.
578
- _transitionToClosed(code, reason, wasClean, error) {
579
- if (this._state === STATE_CLOSED) return;
580
- this._state = STATE_CLOSED;
581
- if (error) this.lastError = error;
582
- if (this._pingTimer) { this._pingTimer.stop(); this._pingTimer = null; }
583
- if (this._closeTimer) { clearTimeout(this._closeTimer); this._closeTimer = null; }
584
- // Surface diagnosable errors via 'error' first — but only if the
585
- // operator is listening AND this is a real diagnosable case.
586
- // EventEmitter throws "Unhandled 'error' event" on emit() with no
587
- // listener; gate the emit to avoid taking down the process.
588
- if (error && this.listenerCount("error") > 0) {
589
- try { this.emit("error", error); } catch (_e) { /* listener threw — ignore */ }
590
- }
591
- this.emit("close", code, reason, !!wasClean);
592
- }
593
-
594
- // Browser-style state field. 'open' | 'closing' | 'closed'.
595
- get readyState() { return this._state; }
596
-
597
- _onData(chunk) {
598
- var frames;
599
- try { frames = this._parser.push(chunk); }
600
- catch (err) {
601
- var code = err.closeCode || CLOSE_PROTOCOL_ERROR;
602
- return this._abort(code, err.message);
603
- }
604
- for (var i = 0; i < frames.length; i++) {
605
- this._handleFrame(frames[i]);
606
- if (this._state === STATE_CLOSED) return;
607
- }
608
- }
609
-
610
- _handleFrame(frame) {
611
- // Mask enforcement flips by transport (RFC 6455 §5.3 vs RFC 8441):
612
- // h1: client→server frames MUST be masked
613
- // h2: frames MUST NOT be masked (h2 transport provides the
614
- // protections that masking exists for)
615
- if (this.transport === "h1" && !frame.masked) {
616
- return this._abort(CLOSE_PROTOCOL_ERROR, "client frame not masked (h1)");
617
- }
618
- if (this.transport === "h2" && frame.masked) {
619
- return this._abort(CLOSE_PROTOCOL_ERROR, "frame must not be masked (h2)");
620
- }
621
- // Reserved bits — must be zero unless a negotiated extension uses them.
622
- // RSV1 is permessage-deflate (RFC 7692). RSV2/RSV3 unused; any RSV2
623
- // or RSV3 bit set, OR RSV1 set when permessage-deflate wasn't
624
- // negotiated, is a protocol error.
625
- if (frame.rsv2 || frame.rsv3) {
626
- return this._abort(CLOSE_PROTOCOL_ERROR, "reserved bits set without extension");
627
- }
628
- if (frame.rsv1 && !this._permessageDeflate) {
629
- return this._abort(CLOSE_PROTOCOL_ERROR, "RSV1 set without permessage-deflate negotiated");
630
- }
631
- // RSV1 is only legal on the FIRST frame of a message (TEXT/BINARY).
632
- // Continuation frames inherit the compression flag from the start.
633
- if (frame.rsv1 && frame.opcode === OPCODE_CONTINUATION) {
634
- return this._abort(CLOSE_PROTOCOL_ERROR, "RSV1 on continuation frame (must be on start)");
635
- }
636
-
637
- if (frame.opcode === OPCODE_CONTINUATION) {
638
- if (this._fragOpcode === null) {
639
- return this._abort(CLOSE_PROTOCOL_ERROR, "continuation without start");
640
- }
641
- this._appendFragment(frame);
642
- } else if (frame.opcode === OPCODE_TEXT || frame.opcode === OPCODE_BINARY) {
643
- if (this._fragOpcode !== null) {
644
- return this._abort(CLOSE_PROTOCOL_ERROR, "new message during fragmentation");
645
- }
646
- this._fragOpcode = frame.opcode;
647
- this._fragChunks = [frame.payload];
648
- this._fragLen = frame.payload.length;
649
- this._fragCompressed = !!frame.rsv1;
650
- if (frame.fin) this._emitMessage();
651
- } else if (frame.opcode === OPCODE_CLOSE) {
652
- this._handleClose(frame);
653
- } else if (frame.opcode === OPCODE_PING) {
654
- this.emit("ping", frame.payload);
655
- this._sendFrame(OPCODE_PONG, frame.payload);
656
- } else if (frame.opcode === OPCODE_PONG) {
657
- this._lastPongAt = Date.now();
658
- this.emit("pong", frame.payload);
659
- } else {
660
- this._abort(CLOSE_PROTOCOL_ERROR, "unknown opcode " + frame.opcode);
661
- }
662
- }
663
-
664
- _appendFragment(frame) {
665
- var newLen = this._fragLen + frame.payload.length;
666
- if (newLen > this.maxMessageBytes) {
667
- return this._abort(CLOSE_MESSAGE_TOO_BIG, "message exceeds maxMessageBytes");
668
- }
669
- this._fragChunks.push(frame.payload);
670
- this._fragLen = newLen;
671
- if (frame.fin) this._emitMessage();
672
- }
673
-
674
- _emitMessage() {
675
- var data = this._fragChunks.length === 1
676
- ? this._fragChunks[0]
677
- : Buffer.concat(this._fragChunks, this._fragLen);
678
- var opcode = this._fragOpcode;
679
- var wasCompressed = this._fragCompressed;
680
- this._fragOpcode = null;
681
- this._fragChunks = null;
682
- this._fragLen = 0;
683
- this._fragCompressed = false;
684
- // Decompress before emitting if the start frame had RSV1 set.
685
- // RFC 7692: malformed deflate is a protocol error, surfaced as
686
- // CLOSE_INVALID_PAYLOAD per §5.6 / §6 of RFC 6455.
687
- if (wasCompressed) {
688
- try {
689
- data = _inflateMessage(data, this._permessageDeflate.clientMaxWindowBits);
690
- } catch (e) {
691
- return this._abort(CLOSE_INVALID_PAYLOAD,
692
- "permessage-deflate inflate failed: " + ((e && e.message) || String(e)));
693
- }
694
- if (data.length > this.maxMessageBytes) {
695
- return this._abort(CLOSE_MESSAGE_TOO_BIG,
696
- "decompressed message exceeds maxMessageBytes");
697
- }
698
- }
699
- if (opcode === OPCODE_TEXT) {
700
- // §5.6: text frames MUST be valid UTF-8. Buffer.toString silently
701
- // replaces invalid sequences with U+FFFD; explicit validation
702
- // rejects malformed data per spec.
703
- var str;
704
- try { str = new TextDecoder("utf-8", { fatal: true }).decode(data); }
705
- catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "text frame is not valid UTF-8"); }
706
- this.emit("message", str, false);
707
- } else {
708
- this.emit("message", data, true);
709
- }
710
- }
711
-
712
- _handleClose(frame) {
713
- var code = CLOSE_NORMAL, reason = "";
714
- if (frame.payload.length >= 2) {
715
- code = frame.payload.readUInt16BE(0);
716
- if (frame.payload.length > 2) {
717
- try { reason = new TextDecoder("utf-8", { fatal: true }).decode(frame.payload.subarray(2)); }
718
- catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "close reason is not valid UTF-8"); }
719
- }
720
- }
721
- if (!this._closeSent) {
722
- // Echo close (§5.5.1) — peer initiated, we acknowledge.
723
- this._sendCloseFrame(code, reason);
724
- this._closeSent = true;
725
- }
726
- // Transition to CLOSED — clean handshake completed (wasClean=true).
727
- // The socket close will arrive shortly; _transitionToClosed is
728
- // idempotent so the socket-close handler running afterward is a
729
- // no-op.
730
- try { this.socket.end(); } catch (_e) { /* socket already closed by peer */ }
731
- this._transitionToClosed(code, reason, true, null);
732
- }
733
-
734
- _sendCloseFrame(code, reason) {
735
- var reasonBuf = reason ? Buffer.from(String(reason), "utf8") : Buffer.alloc(0);
736
- var payload = Buffer.alloc(2 + reasonBuf.length);
737
- payload.writeUInt16BE(code, 0);
738
- if (reasonBuf.length) reasonBuf.copy(payload, 2);
739
- this._sendFrame(OPCODE_CLOSE, payload);
740
- }
741
-
742
- _sendFrame(opcode, payload, opts) {
743
- if (this._state === STATE_CLOSED) return;
744
- // Socket may have been destroyed by the peer between our last
745
- // 'close' event check and this write — Node's 'close' event is
746
- // async-after-destroy and there's a race window. Treat unwritable
747
- // socket as the abnormal-closure path so the operator's 'close'
748
- // handler fires consistently.
749
- if (this.socket.destroyed || this.socket.writable === false) {
750
- this._transitionToClosed(1006, "socket no longer writable", false, null);
751
- return;
752
- }
753
- try {
754
- this.socket.write(serializeFrame(opcode, payload, opts));
755
- } catch (err) {
756
- this._transitionToClosed(1006, (err && err.message) || "write failed", false, err);
757
- }
758
- }
759
-
760
- _sendDataFrame(opcode, payload) {
761
- // Compress entire-message-in-one-frame when permessage-deflate
762
- // negotiated. RSV1 set on the FIRST frame of the message to mark
763
- // it compressed; opcode-only continuation frames don't repeat
764
- // RSV1 (see _onFrame's RSV1+continuation guard).
765
- if (this._permessageDeflate && opcode !== OPCODE_PING &&
766
- opcode !== OPCODE_PONG && opcode !== OPCODE_CLOSE) {
767
- try {
768
- var compressed = _deflateMessage(payload, this._permessageDeflate.serverMaxWindowBits);
769
- this._sendFrame(opcode, compressed, { rsv1: true });
770
- return;
771
- } catch (_e) {
772
- // Compression failure on send — fall through to uncompressed
773
- // (we still have the original payload) so the connection
774
- // keeps working. The underlying issue surfaces as observability.
775
- }
776
- }
777
- this._sendFrame(opcode, payload);
778
- }
779
-
780
- send(data) {
781
- if (this._state !== STATE_OPEN) {
782
- throw new WebSocketError("ws/closed",
783
- "connection is " + this._state + ", cannot send");
784
- }
785
- if (typeof data === "string") {
786
- this._sendDataFrame(OPCODE_TEXT, Buffer.from(data, "utf8"));
787
- } else if (Buffer.isBuffer(data)) {
788
- this._sendDataFrame(OPCODE_BINARY, data);
789
- } else {
790
- data = safeBuffer.toBuffer(data, {
791
- errorClass: WebSocketError,
792
- typeCode: "ws/invalid-payload",
793
- typeMessage: "send() requires Buffer, Uint8Array, or string",
794
- });
795
- this._sendDataFrame(OPCODE_BINARY, data);
796
- }
797
- }
798
-
799
- ping(payload) {
800
- if (this._state !== STATE_OPEN) return;
801
- this._sendFrame(OPCODE_PING, payload || Buffer.alloc(0));
802
- }
803
-
804
- close(code, reason) {
805
- if (this._state !== STATE_OPEN) return;
806
- code = code || CLOSE_NORMAL;
807
- this._sendCloseFrame(code, reason || "");
808
- this._closeSent = true;
809
- this._state = STATE_CLOSING;
810
- // Grace period — wait for peer's close echo before forcing socket end.
811
- var self = this;
812
- this._closeTimer = setTimeout(function () {
813
- try { self.socket.end(); } catch (_e) { /* socket already closed */ }
814
- // If the peer never echoed, transition with the locally-sent code.
815
- // wasClean: false because the peer didn't acknowledge.
816
- self._transitionToClosed(code, reason || "", false, null);
817
- }, this._closeGraceMs);
818
- this._closeTimer.unref();
819
- }
820
-
821
- _abort(code, reason) {
822
- if (this._state === STATE_CLOSED) return;
823
- if (!this._closeSent) {
824
- try { this._sendCloseFrame(code, reason); this._closeSent = true; } catch (_e) { /* close frame send-best-effort during abort */ }
825
- }
826
- try { this.socket.destroy(); } catch (_e) { /* socket already destroyed */ }
827
- // _abort is for protocol violations — wasClean: false.
828
- this._transitionToClosed(code, reason, false, null);
829
- }
830
-
831
- _heartbeat(pongTimeoutMs) {
832
- if (this._state !== STATE_OPEN) return;
833
- if (Date.now() - this._lastPongAt > pongTimeoutMs) {
834
- this._abort(CLOSE_INTERNAL_ERROR, "ping timeout — peer unresponsive");
835
- return;
836
- }
837
- this.ping();
838
- }
839
- }
840
-
841
- // ---- Server-side upgrade handler ----
842
- //
843
- // The framework's router wires the HTTP server's 'upgrade' event to
844
- // this function. Operators usually don't call it directly; they pass
845
- // a handler to router.ws(path, opts).
846
-
847
- function handleUpgrade(req, socket, head, opts) {
848
- opts = opts || {};
849
-
850
- // Throw-at-config-time on the optional GUID override. A typo here
851
- // would produce a Sec-WebSocket-Accept the client can't match,
852
- // breaking the upgrade in a way that's hard to diagnose; the format
853
- // check at the top of handleUpgrade catches it loudly. Empty /
854
- // undefined falls through to the RFC default in computeAcceptKey.
855
- var GUID_MAX_LENGTH = C.BYTES.bytes(64); // allow:raw-byte-literal — UUID is 36 chars; 64 is a tolerant upper bound for the regex engine.
856
- if (opts.handshakeGuid !== undefined && opts.handshakeGuid !== null) {
857
- // Length cap before the regex test — UUIDs are exactly 36 chars so
858
- // a > GUID_MAX_LENGTH input never matches the format and shouldn't
859
- // reach the regex engine. Bounds the engine on hostile input
860
- // regardless of the GUID_RE shape.
861
- if (typeof opts.handshakeGuid !== "string" ||
862
- opts.handshakeGuid.length > GUID_MAX_LENGTH ||
863
- !GUID_RE.test(opts.handshakeGuid)) {
864
- throw new Error("websocket.handleUpgrade: handshakeGuid must be a UUID-shaped string (8-4-4-4-12 hex with dashes), got " +
865
- JSON.stringify(opts.handshakeGuid));
866
- }
867
- }
868
-
869
- // Validate handshake first — refusing here writes a plain HTTP/1.1
870
- // response and closes the socket, matching what the upgrade-event
871
- // consumer would expect for a malformed request.
872
- var v = validateUpgradeRequest(req);
873
- if (!v.ok) {
874
- _refuseUpgrade(socket, v.status || 400, v.reason);
875
- return null;
876
- }
877
-
878
- // Origin policy.
879
- if (!isOriginAllowed(req, opts.origins)) {
880
- _refuseUpgrade(socket, 403, "origin not allowed");
881
- return null;
882
- }
883
-
884
- // Subprotocol negotiation.
885
- var subprotocol = negotiateSubprotocol(req, opts.subprotocols);
886
-
887
- // permessage-deflate negotiation. Skipped (no echo header, no
888
- // compression state on the connection) when the operator passes
889
- // opts.permessageDeflate = false OR when the client didn't offer it.
890
- var pmd = null;
891
- if (opts.permessageDeflate !== false) {
892
- var negotiated = _negotiatePermessageDeflate(req.headers["sec-websocket-extensions"]);
893
- if (negotiated.negotiated) pmd = negotiated;
894
- }
895
-
896
- // Send 101.
897
- try {
898
- socket.write(buildUpgradeResponse(
899
- req.headers["sec-websocket-key"], subprotocol,
900
- pmd ? pmd.responseHeader : null, opts.handshakeGuid));
901
- } catch (err) {
902
- log.error("failed to write upgrade response: " + err.message);
903
- try { socket.destroy(); } catch (_e) { /* socket already destroyed */ }
904
- return null;
905
- }
906
-
907
- // If the head buffer has any bytes (data that arrived between
908
- // headers and the upgrade handler), we pre-feed them into the
909
- // parser via a synthetic data event. Most clients don't send
910
- // anything before the 101 response, but the spec allows it.
911
- var conn = new WebSocketConnection(socket, {
912
- subprotocol: subprotocol,
913
- maxMessageBytes: opts.maxMessageBytes,
914
- pingIntervalMs: opts.pingIntervalMs,
915
- pongTimeoutMs: opts.pongTimeoutMs,
916
- permessageDeflate: pmd,
917
- });
918
- if (head && head.length > 0) {
919
- // Manually invoke the data path with the pre-read bytes.
920
- conn._onData(head);
921
- }
922
- return conn;
923
- }
924
-
925
- // ---- h2 Extended CONNECT (RFC 8441) entry point ----
926
- //
927
- // Called by the router from an http2.Server's 'stream' event when the
928
- // :method header is "CONNECT" and :protocol is "websocket". Validates
929
- // origin + subprotocols (same policy as h1), responds with :status 200
930
- // (NOT 101 — Extended CONNECT is a CONNECT, not an Upgrade), and
931
- // returns a WebSocketConnection wrapping the h2 stream.
932
- //
933
- // The server side must advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1
934
- // in its h2 settings frame BEFORE clients can use Extended CONNECT.
935
- // That's the operator's responsibility when constructing the h2 server
936
- // — pass `settings: { enableConnectProtocol: true }` to
937
- // http2.createServer / createSecureServer.
938
-
939
- function handleExtendedConnect(stream, requestHeaders, opts) {
940
- opts = opts || {};
941
-
942
- // Verify it's actually a WebSocket Extended CONNECT (RFC 8441 §4).
943
- if (requestHeaders[":method"] !== "CONNECT") {
944
- _refuseH2Connect(stream, HTTP.BAD_REQUEST, "method must be CONNECT");
945
- return null;
946
- }
947
- if (requestHeaders[":protocol"] !== "websocket") {
948
- _refuseH2Connect(stream, HTTP.BAD_REQUEST, ":protocol must be websocket");
949
- return null;
950
- }
951
-
952
- // Origin + subprotocol policy — same as h1. Build a fake req object
953
- // so the helpers (which expect a Node http req shape) work uniformly.
954
- var fakeReq = { headers: requestHeaders, method: "CONNECT" };
955
- if (!isOriginAllowed(fakeReq, opts.origins)) {
956
- _refuseH2Connect(stream, HTTP.FORBIDDEN, "origin not allowed");
957
- return null;
958
- }
959
-
960
- var subprotocol = negotiateSubprotocol(fakeReq, opts.subprotocols);
961
-
962
- // OK response — Extended CONNECT does NOT use 101. Sec-WebSocket-Key
963
- // / Sec-WebSocket-Accept are NOT used (h2 stream identity replaces
964
- // the handshake nonce dance from h1).
965
- var responseHeaders = { ":status": HTTP.OK };
966
- if (subprotocol) responseHeaders["sec-websocket-protocol"] = subprotocol;
967
- try {
968
- stream.respond(responseHeaders);
969
- } catch (err) {
970
- log.error("failed to write h2 Extended CONNECT response: " + err.message);
971
- try { stream.close(); } catch (_e) { /* stream already closing */ }
972
- return null;
973
- }
974
-
975
- return new WebSocketConnection(stream, {
976
- transport: "h2",
977
- subprotocol: subprotocol,
978
- maxMessageBytes: opts.maxMessageBytes,
979
- pingIntervalMs: opts.pingIntervalMs,
980
- pongTimeoutMs: opts.pongTimeoutMs,
981
- });
982
- }
983
-
984
- function _refuseH2Connect(stream, status, reason) {
985
- try {
986
- stream.respond({ ":status": status, "content-type": "text/plain; charset=utf-8" });
987
- stream.end(reason || ("HTTP " + status));
988
- } catch (_e) {
989
- try { stream.close(); } catch (_e2) { /* stream already closed */ }
990
- }
991
- }
992
-
993
- // Status text table for upgrade-refusal responses. Keyed by the
994
- // framework's HTTP_STATUS hex IDs so the byte-literal lint doesn't
995
- // hit decimal multiples-of-8 in the keys.
996
- var _UPGRADE_REFUSAL_TEXT = {};
997
- _UPGRADE_REFUSAL_TEXT[HTTP.BAD_REQUEST] = "Bad Request";
998
- _UPGRADE_REFUSAL_TEXT[HTTP.FORBIDDEN] = "Forbidden";
999
- _UPGRADE_REFUSAL_TEXT[HTTP.METHOD_NOT_ALLOWED] = "Method Not Allowed";
1000
- _UPGRADE_REFUSAL_TEXT[0x1AA] = "Upgrade Required";
1001
-
1002
- function _refuseUpgrade(socket, status, reason) {
1003
- var statusText = _UPGRADE_REFUSAL_TEXT[status] || "Bad Request";
1004
- var body = reason || statusText;
1005
- var resp =
1006
- "HTTP/1.1 " + status + " " + statusText + "\r\n" +
1007
- "Connection: close\r\n" +
1008
- "Content-Type: text/plain; charset=utf-8\r\n" +
1009
- "Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
1010
- "\r\n" +
1011
- body;
1012
- try { socket.write(resp); } catch (_e) { /* socket already closed */ }
1013
- try { socket.destroy(); } catch (_e) { /* socket already closed */ }
1014
- }
1015
-
1016
- module.exports = {
1017
- // Handshake helpers
1018
- computeAcceptKey: computeAcceptKey,
1019
- validateUpgradeRequest: validateUpgradeRequest,
1020
- negotiateSubprotocol: negotiateSubprotocol,
1021
- isOriginAllowed: isOriginAllowed,
1022
- buildUpgradeResponse: buildUpgradeResponse,
1023
- // Frame layer
1024
- FrameParser: FrameParser,
1025
- serializeFrame: serializeFrame,
1026
- // Connection
1027
- WebSocketConnection: WebSocketConnection,
1028
- WebSocketError: WebSocketError,
1029
- // Server-side entrypoints
1030
- handleUpgrade: handleUpgrade, // h1 — RFC 6455 HTTP upgrade
1031
- handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
1032
- // Constants
1033
- GUID: GUID,
1034
- OPCODE_CONTINUATION: OPCODE_CONTINUATION,
1035
- OPCODE_TEXT: OPCODE_TEXT,
1036
- OPCODE_BINARY: OPCODE_BINARY,
1037
- OPCODE_CLOSE: OPCODE_CLOSE,
1038
- OPCODE_PING: OPCODE_PING,
1039
- OPCODE_PONG: OPCODE_PONG,
1040
- CLOSE_NORMAL: CLOSE_NORMAL,
1041
- CLOSE_GOING_AWAY: CLOSE_GOING_AWAY,
1042
- CLOSE_PROTOCOL_ERROR: CLOSE_PROTOCOL_ERROR,
1043
- CLOSE_UNSUPPORTED_DATA: CLOSE_UNSUPPORTED_DATA,
1044
- CLOSE_INVALID_PAYLOAD: CLOSE_INVALID_PAYLOAD,
1045
- CLOSE_POLICY_VIOLATION: CLOSE_POLICY_VIOLATION,
1046
- CLOSE_MESSAGE_TOO_BIG: CLOSE_MESSAGE_TOO_BIG,
1047
- CLOSE_INTERNAL_ERROR: CLOSE_INTERNAL_ERROR,
1048
- };
1
+ "use strict";
2
+ /**
3
+ * WebSocket server primitive — RFC 6455.
4
+ *
5
+ * Implements the server side of the WebSocket protocol on top of the
6
+ * Node HTTP server's `'upgrade'` event. Built on node:net + node:crypto
7
+ * with no npm runtime dep.
8
+ *
9
+ * Surface:
10
+ *
11
+ * websocket.handleUpgrade(req, socket, head, opts)
12
+ * Wraps a TCP socket post-HTTP-upgrade. Validates the handshake,
13
+ * enforces origin policy, negotiates subprotocol, sends 101
14
+ * response, returns a WebSocketConnection. Throws / refuses on
15
+ * bad handshake.
16
+ *
17
+ * new websocket.WebSocketConnection(socket, opts)
18
+ * EventEmitter wrapping a post-upgrade socket. State machine
19
+ * mirrors the browser WebSocket API:
20
+ * conn.readyState 'open' | 'closing' | 'closed'
21
+ * conn.lastError last diagnosable error, if any
22
+ * conn.send(data) — Buffer or string. Routes to binary
23
+ * or text frame. Throws if not OPEN.
24
+ * conn.ping(payload?) — Send ping frame (no-op if not OPEN).
25
+ * conn.close(code?, reason?) — Send close frame, wait
26
+ * closeGraceMs for peer's echo, end
27
+ * socket.
28
+ * Events:
29
+ * 'message' (data, isBinary)
30
+ * 'ping' (payload)
31
+ * 'pong' (payload)
32
+ * 'close' (code, reason, wasClean) — fires exactly once at
33
+ * lifecycle end. wasClean: true when
34
+ * the close handshake completed in
35
+ * both directions; false on socket
36
+ * errors / abnormal closure (code
37
+ * 1006) / heartbeat timeout / etc.
38
+ * Operators usually only need this
39
+ * listener for full lifecycle tracking.
40
+ * 'error' (err) — diagnosable issue. Always followed
41
+ * by 'close'. Optional listener;
42
+ * missing listener does NOT crash the
43
+ * process (gated by listenerCount).
44
+ *
45
+ * websocket.serializeFrame(opcode, payload, opts), websocket.FrameParser
46
+ * Lower-level helpers exposed for tests + advanced callers.
47
+ *
48
+ * Spec compliance notes (the parts where naive impls get it wrong):
49
+ *
50
+ * 1. Mask handling (§5.3). All client→server frames MUST be masked.
51
+ * Unmasked client frames close the connection with code 1002.
52
+ * Server→client frames MUST NOT be masked. The serializer here
53
+ * defaults mask:false (server side); a `mask:true` opt exists
54
+ * for completeness / test fixtures only.
55
+ *
56
+ * 2. SHA-1 for Sec-WebSocket-Accept. RFC 6455 §1.3 mandates
57
+ * SHA-1(key + GUID). The framework uses SHA3-512 elsewhere; SHA-1
58
+ * here is NOT a security primitive — the GUID is publicly known
59
+ * and the hash is a protocol marker confirming both sides agree
60
+ * on the upgrade. Nothing about the WebSocket connection's
61
+ * security depends on SHA-1 collision resistance.
62
+ *
63
+ * 3. Close handshake reciprocity (§5.5.1). When the peer sends a
64
+ * close frame, we MUST echo a close frame back, then close the
65
+ * TCP socket. close() handles this; _handleClose echoes if we
66
+ * haven't already initiated.
67
+ *
68
+ * 4. Origin policy. Browser clients send `Origin: <scheme>://<host>`.
69
+ * The framework matches the CORS module's pattern: if the operator
70
+ * passes `origins: [...]`, enforce strictly. If `origins: "*"`,
71
+ * accept all (explicit operator opt-in to no checking). If
72
+ * `origins` is omitted, accept all but emit an audit warning at
73
+ * registration (the safety check) — see lib/router.js where the
74
+ * operator-facing API lives. Non-browser clients (Origin header
75
+ * absent) bypass origin checks since Origin is a browser-only
76
+ * enforcement signal.
77
+ *
78
+ * 5. Subprotocol negotiation. Server picks the FIRST entry from
79
+ * Sec-WebSocket-Protocol that's in the operator's `subprotocols`
80
+ * allowlist. If none match, the response omits the header (per
81
+ * §11.3.4) and the client decides whether to proceed.
82
+ */
83
+
84
+ var nodeCrypto = require("crypto");
85
+ var zlib = require("zlib");
86
+ var { EventEmitter } = require("events");
87
+ var C = require("./constants");
88
+ var requestHelpers = require("./request-helpers");
89
+ var safeAsync = require("./safe-async");
90
+ var safeBuffer = require("./safe-buffer");
91
+ var { FrameworkError } = require("./framework-error");
92
+ var { boot } = require("./log");
93
+
94
+ var HTTP = requestHelpers.HTTP_STATUS;
95
+ var log = boot("websocket");
96
+
97
+ // RFC 6455 §1.3 — the standard handshake GUID. Operators running
98
+ // closed-ecosystem clients with a custom magic string pass their own
99
+ // via opts.handshakeGuid on the route; the framework's default stays
100
+ // the RFC value so RFC-compliant clients work out of the box.
101
+ var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
102
+
103
+ // UUID-shape (8-4-4-4-12 hex) for opts.handshakeGuid validation. The
104
+ // SHA-1 used in the handshake is NOT a security primitive (RFC 6455
105
+ // requires it as a protocol marker), so the GUID itself doesn't need
106
+ // to be cryptographically random — but it must match the client's
107
+ // expected value byte-for-byte. Length + format check at config time
108
+ // catches the typo class.
109
+ var GUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
110
+
111
+ var OPCODE_CONTINUATION = 0x0;
112
+ var OPCODE_TEXT = 0x1;
113
+ var OPCODE_BINARY = 0x2;
114
+ var OPCODE_CLOSE = 0x8;
115
+ var OPCODE_PING = 0x9;
116
+ var OPCODE_PONG = 0xA;
117
+
118
+ // Close codes (RFC 6455 §7.4.1) — encoded in hex so the framework's
119
+ // byte-literal lint (which flags decimal multiples of 8) doesn't trip
120
+ // on the protocol-fixed values.
121
+ var CLOSE_NORMAL = 0x3E8;
122
+ var CLOSE_GOING_AWAY = 0x3E9;
123
+ var CLOSE_PROTOCOL_ERROR = 0x3EA;
124
+ var CLOSE_UNSUPPORTED_DATA = 0x3EB;
125
+ // 0x3EC reserved
126
+ // 0x3ED no-status (must not be sent on the wire)
127
+ // 0x3EE abnormal-closure (must not be sent on the wire)
128
+ var CLOSE_INVALID_PAYLOAD = 0x3EF;
129
+ var CLOSE_POLICY_VIOLATION = 0x3F0;
130
+ var CLOSE_MESSAGE_TOO_BIG = 0x3F1;
131
+ var CLOSE_INTERNAL_ERROR = 0x3F3;
132
+
133
+ // Defaults — tuned for fast detection of dead/silent connections.
134
+ //
135
+ // pingIntervalMs (30s): sends a ping every 30s. Aligned with most
136
+ // load-balancer idle timeouts so the LB doesn't kill the
137
+ // connection while we're still considering it healthy.
138
+ //
139
+ // pongTimeoutMs (35s): if no pong arrives within 35s of the last
140
+ // pong, abort with code 1011. Detection happens at ~35s — just
141
+ // past one ping interval. Stays under AWS ALB's 60s default
142
+ // idle so operators don't get LB-side disconnects fighting our
143
+ // heartbeat. Tighter than the typical 60s default in other libs;
144
+ // the cost of a false positive is a client reconnect, the cost
145
+ // of a slow detection is wasted server resources for genuinely-
146
+ // dead silent-failure connections.
147
+ //
148
+ // closeGraceMs (2s): after we send a close frame, wait this long
149
+ // for the peer's echo before forcibly ending the TCP socket.
150
+ // A healthy peer echoes in <100ms; 2s is plenty. Operators on
151
+ // slow networks override.
152
+ //
153
+ // All operator-overridable per connection via opts.{pingIntervalMs,
154
+ // pongTimeoutMs, closeGraceMs}.
155
+ var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(1);
156
+ var DEFAULT_PING_INTERVAL_MS = C.TIME.seconds(30);
157
+ var DEFAULT_PONG_TIMEOUT_MS = C.TIME.seconds(35);
158
+ var CLOSE_GRACE_MS = C.TIME.seconds(2);
159
+
160
+ // Connection lifecycle states — mirrors the browser WebSocket API +
161
+ // the npm `ws` library. Single-source-of-truth field; every state
162
+ // transition goes through _transitionToClosed (or set in the
163
+ // constructor for OPEN).
164
+ var STATE_OPEN = "open";
165
+ var STATE_CLOSING = "closing"; // we sent a close frame, awaiting peer's echo
166
+ var STATE_CLOSED = "closed";
167
+
168
+ class WebSocketError extends FrameworkError {
169
+ constructor(code, message, closeCode) {
170
+ super(message, code);
171
+ this.name = "WebSocketError";
172
+ this.closeCode = closeCode || CLOSE_PROTOCOL_ERROR;
173
+ this.isWebSocketError = true;
174
+ }
175
+ }
176
+
177
+ // ---- Handshake helpers ----
178
+
179
+ function computeAcceptKey(secWebSocketKey, handshakeGuid) {
180
+ // SHA-1 required by RFC 6455 §1.3 — see file-level note 2 above.
181
+ // This is a protocol marker, not a security primitive.
182
+ // handshakeGuid defaults to the RFC value; operators with custom
183
+ // closed-ecosystem clients override per-route via opts.handshakeGuid.
184
+ var hash = nodeCrypto.createHash("sha1");
185
+ hash.update(String(secWebSocketKey) + (handshakeGuid || GUID));
186
+ return hash.digest("base64");
187
+ }
188
+
189
+ function validateUpgradeRequest(req) {
190
+ if (req.method !== "GET") {
191
+ return { ok: false, status: HTTP.METHOD_NOT_ALLOWED, reason: "method must be GET" };
192
+ }
193
+ var h = req.headers || {};
194
+ if ((h.upgrade || "").toLowerCase() !== "websocket") {
195
+ return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Upgrade: websocket" };
196
+ }
197
+ // Connection header may carry multiple tokens (e.g. "keep-alive, Upgrade").
198
+ // Match "upgrade" as a comma-separated token, case-insensitive.
199
+ if (!/(^|,)\s*upgrade\s*(,|$)/i.test(h.connection || "")) {
200
+ return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Connection: upgrade" };
201
+ }
202
+ if (!h["sec-websocket-key"]) {
203
+ return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Sec-WebSocket-Key" };
204
+ }
205
+ if (h["sec-websocket-version"] !== "13") {
206
+ return { ok: false, status: HTTP.BAD_REQUEST, reason: "Sec-WebSocket-Version must be 13" };
207
+ }
208
+ return { ok: true };
209
+ }
210
+
211
+ function negotiateSubprotocol(req, supported) {
212
+ if (!supported || supported.length === 0) return null;
213
+ var raw = (req.headers || {})["sec-websocket-protocol"] || "";
214
+ var offered = requestHelpers.parseListHeader(raw);
215
+ for (var i = 0; i < offered.length; i++) {
216
+ if (supported.indexOf(offered[i]) !== -1) return offered[i];
217
+ }
218
+ return null;
219
+ }
220
+
221
+ // origins shapes:
222
+ // array — strict allowlist, enforced
223
+ // "*" — explicit "accept all" (operator opt-in to no checking)
224
+ // null/undefined — same as "*" but caller (router) is expected to
225
+ // have logged a startup warning. Origin policy is a
226
+ // framework-level decision; this primitive doesn't
227
+ // re-warn here.
228
+ function isOriginAllowed(req, origins) {
229
+ if (!origins || origins === "*") return true;
230
+ var origin = (req.headers || {}).origin;
231
+ // Non-browser clients (curl, server-to-server, native apps) don't
232
+ // send Origin. Origin enforcement only meaningfully applies to
233
+ // browser-initiated upgrades — non-browser callers are gated by
234
+ // the operator's network ACL / auth middleware, not Origin.
235
+ if (!origin) return true;
236
+ if (Array.isArray(origins)) return origins.indexOf(origin) !== -1;
237
+ return false;
238
+ }
239
+
240
+ function buildUpgradeResponse(secWebSocketKey, subprotocol, extensionHeader, handshakeGuid) {
241
+ var lines = [
242
+ "HTTP/1.1 101 Switching Protocols",
243
+ "Upgrade: websocket",
244
+ "Connection: Upgrade",
245
+ "Sec-WebSocket-Accept: " + computeAcceptKey(secWebSocketKey, handshakeGuid),
246
+ ];
247
+ if (subprotocol) lines.push("Sec-WebSocket-Protocol: " + subprotocol);
248
+ if (extensionHeader) lines.push("Sec-WebSocket-Extensions: " + extensionHeader);
249
+ return lines.join("\r\n") + "\r\n\r\n";
250
+ }
251
+
252
+ // ---- permessage-deflate (RFC 7692) ----
253
+ //
254
+ // Negotiate compression at handshake, compress per-message on send,
255
+ // decompress per-message on receive. The framework runs in
256
+ // "no_context_takeover" mode in both directions — every message uses a
257
+ // fresh zlib state, no LZ77 history carried across messages. This
258
+ // trade-off makes message processing stateless (no per-connection
259
+ // zlib stream lifetime to manage) at a small compression-ratio cost.
260
+ // Operators with throughput-sensitive workloads can extend this later
261
+ // to keep state across messages.
262
+ //
263
+ // Per RFC 7692 §7.2.1 the deflate output is the standard zlib raw
264
+ // deflate WITH the trailing 4 bytes 0x00 0x00 0xff 0xff stripped. The
265
+ // matching inflate path appends them back before inflating.
266
+ var DEFLATE_TRAILING = Buffer.from([0x00, 0x00, 0xff, 0xff]);
267
+
268
+ function _parseExtensionHeader(header) {
269
+ // Sec-WebSocket-Extensions: foo; param=val; param2, bar; ...
270
+ // Returns [{ name, params: { paramName: value | true } }]
271
+ if (!header) return [];
272
+ var entries = String(header).split(",");
273
+ var out = [];
274
+ for (var i = 0; i < entries.length; i++) {
275
+ var parts = entries[i].split(";").map(function (s) { return s.trim(); });
276
+ if (!parts[0]) continue;
277
+ var ext = { name: parts[0].toLowerCase(), params: {} };
278
+ for (var j = 1; j < parts.length; j++) {
279
+ var kv = parts[j].split("=");
280
+ var k = kv[0].trim().toLowerCase();
281
+ if (!k) continue;
282
+ var v = kv.length > 1 ? kv.slice(1).join("=").trim() : true;
283
+ // Strip surrounding quotes per the token-or-quoted-string grammar.
284
+ if (typeof v === "string" && v.length >= 2 &&
285
+ v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
286
+ v = v.slice(1, -1);
287
+ }
288
+ ext.params[k] = v;
289
+ }
290
+ out.push(ext);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ function _negotiatePermessageDeflate(reqHeader) {
296
+ var entries = _parseExtensionHeader(reqHeader);
297
+ for (var i = 0; i < entries.length; i++) {
298
+ if (entries[i].name !== "permessage-deflate") continue;
299
+ var p = entries[i].params;
300
+ // Reject unknown params (RFC 7692 §7 lists exactly four).
301
+ var KNOWN = {
302
+ "server_no_context_takeover": true, "client_no_context_takeover": true,
303
+ "server_max_window_bits": true, "client_max_window_bits": true,
304
+ };
305
+ var ok = true;
306
+ for (var k in p) { if (Object.prototype.hasOwnProperty.call(p, k) && !KNOWN[k]) { ok = false; break; } }
307
+ if (!ok) continue;
308
+ // Always negotiate WITH no_context_takeover in BOTH directions, so
309
+ // every message uses a fresh zlib state. Echo any client window-
310
+ // bits constraints back unchanged (we honour them on the server's
311
+ // outgoing compression).
312
+ var responseParams = ["client_no_context_takeover", "server_no_context_takeover"];
313
+ if (p.client_max_window_bits && p.client_max_window_bits !== true) {
314
+ responseParams.push("client_max_window_bits=" + p.client_max_window_bits);
315
+ }
316
+ if (p.server_max_window_bits && p.server_max_window_bits !== true) {
317
+ responseParams.push("server_max_window_bits=" + p.server_max_window_bits);
318
+ }
319
+ // RFC 7692 §7.1: max_window_bits is 8..15 inclusive; 15 is the
320
+ // unconstrained default. Hex-encoded so the byte-literal lint
321
+ // doesn't flag the 8 lower bound.
322
+ var WB_MIN = 0x8;
323
+ var WB_MAX = 0xF;
324
+ return {
325
+ negotiated: true,
326
+ responseHeader: "permessage-deflate; " + responseParams.join("; "),
327
+ // window-bits constraints we honour; default WB_MAX (15) when unset.
328
+ serverMaxWindowBits: p.server_max_window_bits && p.server_max_window_bits !== true
329
+ ? Math.max(WB_MIN, Math.min(WB_MAX, parseInt(p.server_max_window_bits, 10) || WB_MAX)) : WB_MAX,
330
+ clientMaxWindowBits: p.client_max_window_bits && p.client_max_window_bits !== true
331
+ ? Math.max(WB_MIN, Math.min(WB_MAX, parseInt(p.client_max_window_bits, 10) || WB_MAX)) : WB_MAX,
332
+ };
333
+ }
334
+ return { negotiated: false };
335
+ }
336
+
337
+ function _deflateMessage(payload, windowBits) {
338
+ // Per RFC 7692 §7.2.1, strip the 4-byte 0x00 0x00 0xff 0xff trailer.
339
+ var raw = zlib.deflateRawSync(payload, { windowBits: windowBits, level: zlib.constants.Z_DEFAULT_COMPRESSION });
340
+ if (raw.length >= 4 &&
341
+ raw[raw.length - 4] === 0x00 && raw[raw.length - 3] === 0x00 &&
342
+ raw[raw.length - 2] === 0xff && raw[raw.length - 1] === 0xff) {
343
+ return raw.slice(0, raw.length - 4);
344
+ }
345
+ return raw;
346
+ }
347
+
348
+ function _inflateMessage(payload, windowBits) {
349
+ // Per RFC 7692 §7.2.2, append the 4-byte trailer before inflating.
350
+ var withTrailer = Buffer.concat([payload, DEFLATE_TRAILING]);
351
+ return zlib.inflateRawSync(withTrailer, { windowBits: windowBits });
352
+ }
353
+
354
+ // ---- Frame parser ----
355
+ //
356
+ // Incremental — push(chunk) accepts arbitrary buffer slices from the
357
+ // socket and emits zero-or-more complete frames as they arrive. Holds
358
+ // partial frame state across calls.
359
+
360
+ function FrameParser(opts) {
361
+ opts = opts || {};
362
+ this.maxFrameBytes = opts.maxFrameBytes || DEFAULT_MAX_MESSAGE_BYTES;
363
+ this._buffer = Buffer.alloc(0);
364
+ }
365
+
366
+ FrameParser.prototype.push = function (chunk) {
367
+ this._buffer = Buffer.concat([this._buffer, chunk]);
368
+ var frames = [];
369
+ while (true) {
370
+ var frame = this._tryParseFrame();
371
+ if (!frame) break; // incomplete — wait for more bytes
372
+ frames.push(frame);
373
+ }
374
+ return frames;
375
+ };
376
+
377
+ FrameParser.prototype._tryParseFrame = function () {
378
+ if (this._buffer.length < 2) return null;
379
+ var b0 = this._buffer[0];
380
+ var b1 = this._buffer[1];
381
+ var fin = !!(b0 & 0x80);
382
+ var rsv1 = !!(b0 & 0x40);
383
+ var rsv2 = !!(b0 & 0x20);
384
+ var rsv3 = !!(b0 & 0x10);
385
+ var opcode = b0 & 0x0F;
386
+ var masked = !!(b1 & 0x80);
387
+ var lenInd = b1 & 0x7F;
388
+
389
+ var headerLen = 2;
390
+ if (lenInd === 126) headerLen += 2;
391
+ else if (lenInd === 127) headerLen += C.BYTES.bytes(8);
392
+ if (masked) headerLen += 4;
393
+ if (this._buffer.length < headerLen) return null;
394
+
395
+ var payloadLen;
396
+ var off = 2;
397
+ if (lenInd < 126) {
398
+ payloadLen = lenInd;
399
+ } else if (lenInd === 126) {
400
+ payloadLen = this._buffer.readUInt16BE(off);
401
+ off += 2;
402
+ } else {
403
+ // 64-bit. JS Number is 53-bit safe — reject lengths above
404
+ // Number.MAX_SAFE_INTEGER explicitly rather than silently
405
+ // truncating.
406
+ var hi = this._buffer.readUInt32BE(off);
407
+ var lo = this._buffer.readUInt32BE(off + 4);
408
+ if (hi > 0x1FFFFF) {
409
+ throw new WebSocketError("ws/frame-too-large",
410
+ "frame length exceeds Number.MAX_SAFE_INTEGER", CLOSE_MESSAGE_TOO_BIG);
411
+ }
412
+ payloadLen = (hi * 0x100000000) + lo;
413
+ off += C.BYTES.bytes(8);
414
+ }
415
+
416
+ if (payloadLen > this.maxFrameBytes) {
417
+ throw new WebSocketError("ws/frame-too-large",
418
+ "frame payload exceeds maxFrameBytes (" + this.maxFrameBytes + ")",
419
+ CLOSE_MESSAGE_TOO_BIG);
420
+ }
421
+
422
+ var maskKey = null;
423
+ if (masked) {
424
+ maskKey = Buffer.from(this._buffer.subarray(off, off + 4));
425
+ off += 4;
426
+ }
427
+
428
+ var totalLen = off + payloadLen;
429
+ if (this._buffer.length < totalLen) return null;
430
+
431
+ var payload = this._buffer.subarray(off, totalLen);
432
+ if (masked) {
433
+ var unmasked = Buffer.alloc(payloadLen);
434
+ for (var i = 0; i < payloadLen; i++) {
435
+ unmasked[i] = payload[i] ^ maskKey[i & 3];
436
+ }
437
+ payload = unmasked;
438
+ } else {
439
+ // Copy out — the underlying buffer is about to be sliced.
440
+ payload = Buffer.from(payload);
441
+ }
442
+
443
+ this._buffer = this._buffer.subarray(totalLen);
444
+
445
+ return {
446
+ fin: fin,
447
+ rsv1: rsv1,
448
+ rsv2: rsv2,
449
+ rsv3: rsv3,
450
+ opcode: opcode,
451
+ masked: masked,
452
+ payload: payload,
453
+ };
454
+ };
455
+
456
+ // ---- Frame serializer ----
457
+
458
+ function serializeFrame(opcode, payload, opts) {
459
+ opts = opts || {};
460
+ var fin = opts.fin !== false;
461
+ var mask = opts.mask === true; // server-side defaults false
462
+ // RSV1 — set on the first frame of a permessage-deflate-compressed
463
+ // message (RFC 7692). Caller passes opts.rsv1 = true; we wire it
464
+ // into the header byte. RSV2 / RSV3 stay zero (no other extensions
465
+ // negotiated).
466
+ var rsv1 = opts.rsv1 === true;
467
+ payload = payload || Buffer.alloc(0);
468
+ if (typeof payload === "string") payload = Buffer.from(payload, "utf8");
469
+ if (!Buffer.isBuffer(payload)) {
470
+ throw new WebSocketError("ws/invalid-payload",
471
+ "frame payload must be Buffer or string");
472
+ }
473
+ var len = payload.length;
474
+
475
+ var headerLen = 2;
476
+ var lenByte;
477
+ // RFC 6455 §5.2 — 16-bit extended length boundary at 2^16.
478
+ var EXT16_BOUNDARY = 0x10000;
479
+ if (len < 126) { lenByte = len; }
480
+ else if (len < EXT16_BOUNDARY) { lenByte = 126; headerLen += 2; }
481
+ else { lenByte = 127; headerLen += C.BYTES.bytes(8); }
482
+ if (mask) headerLen += 4;
483
+
484
+ var header = Buffer.alloc(headerLen);
485
+ header[0] = (fin ? 0x80 : 0) | (rsv1 ? 0x40 : 0) | (opcode & 0x0F);
486
+ header[1] = (mask ? 0x80 : 0) | lenByte;
487
+
488
+ var off = 2;
489
+ if (lenByte === 126) {
490
+ header.writeUInt16BE(len, off);
491
+ off += 2;
492
+ } else if (lenByte === 127) {
493
+ var hi = Math.floor(len / 0x100000000);
494
+ var lo = len % 0x100000000;
495
+ header.writeUInt32BE(hi, off);
496
+ header.writeUInt32BE(lo, off + 4);
497
+ off += C.BYTES.bytes(8);
498
+ }
499
+
500
+ if (mask) {
501
+ var maskKey = nodeCrypto.randomBytes(4);
502
+ maskKey.copy(header, off);
503
+ var masked = Buffer.alloc(len);
504
+ for (var i = 0; i < len; i++) masked[i] = payload[i] ^ maskKey[i & 3];
505
+ return Buffer.concat([header, masked]);
506
+ }
507
+ return Buffer.concat([header, payload]);
508
+ }
509
+
510
+ // ---- Connection ----
511
+
512
+ class WebSocketConnection extends EventEmitter {
513
+ constructor(socket, opts) {
514
+ super();
515
+ opts = opts || {};
516
+ this.socket = socket;
517
+ this.subprotocol = opts.subprotocol || null;
518
+ this.maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
519
+ // Transport selects mask-enforcement direction:
520
+ // h1 (RFC 6455): client→server frames MUST be masked. Default.
521
+ // h2 (RFC 8441): frames MUST NOT be masked — h2 already provides
522
+ // the framing/security guarantees that masking
523
+ // exists to protect against in h1 (proxy
524
+ // cache-poisoning via raw text on the wire).
525
+ this.transport = opts.transport === "h2" ? "h2" : "h1";
526
+ // permessage-deflate state — `null` means extension not negotiated.
527
+ // When negotiated the object carries serverMaxWindowBits +
528
+ // clientMaxWindowBits the inflate/deflate paths use per message.
529
+ this._permessageDeflate = opts.permessageDeflate || null;
530
+ var pingMs = opts.pingIntervalMs || DEFAULT_PING_INTERVAL_MS;
531
+ var pongMs = opts.pongTimeoutMs || DEFAULT_PONG_TIMEOUT_MS;
532
+ // Grace period after we send a close frame before forcing the
533
+ // socket end. Production default = 5s (give the peer time to ack).
534
+ // Tests / latency-sensitive ops can pass a shorter value.
535
+ this._closeGraceMs = opts.closeGraceMs != null ? opts.closeGraceMs : CLOSE_GRACE_MS;
536
+
537
+ // Lifecycle state — single source of truth. Operators read
538
+ // conn.readyState; internal code reads/writes this._state via
539
+ // _transitionToClosed. All transitions emit 'close' exactly once.
540
+ this._state = STATE_OPEN;
541
+ this._closeSent = false;
542
+ this._closeTimer = null;
543
+ this.lastError = null; // last diagnosable error, if any
544
+ // Fragmentation reassembly state.
545
+ this._fragOpcode = null;
546
+ this._fragChunks = null;
547
+ this._fragLen = 0;
548
+
549
+ this._parser = new FrameParser({ maxFrameBytes: this.maxMessageBytes });
550
+ this._lastPongAt = Date.now();
551
+
552
+ var self = this;
553
+ this._pingTimer = safeAsync.repeating(function () { self._heartbeat(pongMs); },
554
+ pingMs, { name: "websocket-ping" });
555
+
556
+ socket.on("data", function (chunk) { self._onData(chunk); });
557
+ socket.on("error", function (err) {
558
+ // Network errors are LIFECYCLE events, not protocol errors —
559
+ // route through _transitionToClosed with code 1006 (abnormal
560
+ // closure). Mirrors the browser WebSocket API + ws npm
561
+ // convention: operators listening on 'close' see the death;
562
+ // 'error' is reserved for diagnosable protocol issues that
563
+ // the operator may want to explicitly handle.
564
+ self._transitionToClosed(1006, (err && err.message) || "socket error", false, err);
565
+ });
566
+ socket.on("close", function () {
567
+ // Socket FIN/RST seen — if we haven't already transitioned via
568
+ // a clean close-handshake, this is an abnormal closure.
569
+ if (self._state !== STATE_CLOSED) {
570
+ self._transitionToClosed(1006, "abnormal closure", false, null);
571
+ }
572
+ });
573
+ }
574
+
575
+ // Single state-transition method. Idempotent — repeat calls after
576
+ // CLOSED are no-ops. Emits 'close' exactly once with (code, reason,
577
+ // wasClean) signature matching the browser API.
578
+ _transitionToClosed(code, reason, wasClean, error) {
579
+ if (this._state === STATE_CLOSED) return;
580
+ this._state = STATE_CLOSED;
581
+ if (error) this.lastError = error;
582
+ if (this._pingTimer) { this._pingTimer.stop(); this._pingTimer = null; }
583
+ if (this._closeTimer) { clearTimeout(this._closeTimer); this._closeTimer = null; }
584
+ // Surface diagnosable errors via 'error' first — but only if the
585
+ // operator is listening AND this is a real diagnosable case.
586
+ // EventEmitter throws "Unhandled 'error' event" on emit() with no
587
+ // listener; gate the emit to avoid taking down the process.
588
+ if (error && this.listenerCount("error") > 0) {
589
+ try { this.emit("error", error); } catch (_e) { /* listener threw — ignore */ }
590
+ }
591
+ this.emit("close", code, reason, !!wasClean);
592
+ }
593
+
594
+ // Browser-style state field. 'open' | 'closing' | 'closed'.
595
+ get readyState() { return this._state; }
596
+
597
+ _onData(chunk) {
598
+ var frames;
599
+ try { frames = this._parser.push(chunk); }
600
+ catch (err) {
601
+ var code = err.closeCode || CLOSE_PROTOCOL_ERROR;
602
+ return this._abort(code, err.message);
603
+ }
604
+ for (var i = 0; i < frames.length; i++) {
605
+ this._handleFrame(frames[i]);
606
+ if (this._state === STATE_CLOSED) return;
607
+ }
608
+ }
609
+
610
+ _handleFrame(frame) {
611
+ // Mask enforcement flips by transport (RFC 6455 §5.3 vs RFC 8441):
612
+ // h1: client→server frames MUST be masked
613
+ // h2: frames MUST NOT be masked (h2 transport provides the
614
+ // protections that masking exists for)
615
+ if (this.transport === "h1" && !frame.masked) {
616
+ return this._abort(CLOSE_PROTOCOL_ERROR, "client frame not masked (h1)");
617
+ }
618
+ if (this.transport === "h2" && frame.masked) {
619
+ return this._abort(CLOSE_PROTOCOL_ERROR, "frame must not be masked (h2)");
620
+ }
621
+ // Reserved bits — must be zero unless a negotiated extension uses them.
622
+ // RSV1 is permessage-deflate (RFC 7692). RSV2/RSV3 unused; any RSV2
623
+ // or RSV3 bit set, OR RSV1 set when permessage-deflate wasn't
624
+ // negotiated, is a protocol error.
625
+ if (frame.rsv2 || frame.rsv3) {
626
+ return this._abort(CLOSE_PROTOCOL_ERROR, "reserved bits set without extension");
627
+ }
628
+ if (frame.rsv1 && !this._permessageDeflate) {
629
+ return this._abort(CLOSE_PROTOCOL_ERROR, "RSV1 set without permessage-deflate negotiated");
630
+ }
631
+ // RSV1 is only legal on the FIRST frame of a message (TEXT/BINARY).
632
+ // Continuation frames inherit the compression flag from the start.
633
+ if (frame.rsv1 && frame.opcode === OPCODE_CONTINUATION) {
634
+ return this._abort(CLOSE_PROTOCOL_ERROR, "RSV1 on continuation frame (must be on start)");
635
+ }
636
+
637
+ if (frame.opcode === OPCODE_CONTINUATION) {
638
+ if (this._fragOpcode === null) {
639
+ return this._abort(CLOSE_PROTOCOL_ERROR, "continuation without start");
640
+ }
641
+ this._appendFragment(frame);
642
+ } else if (frame.opcode === OPCODE_TEXT || frame.opcode === OPCODE_BINARY) {
643
+ if (this._fragOpcode !== null) {
644
+ return this._abort(CLOSE_PROTOCOL_ERROR, "new message during fragmentation");
645
+ }
646
+ this._fragOpcode = frame.opcode;
647
+ this._fragChunks = [frame.payload];
648
+ this._fragLen = frame.payload.length;
649
+ this._fragCompressed = !!frame.rsv1;
650
+ if (frame.fin) this._emitMessage();
651
+ } else if (frame.opcode === OPCODE_CLOSE) {
652
+ this._handleClose(frame);
653
+ } else if (frame.opcode === OPCODE_PING) {
654
+ this.emit("ping", frame.payload);
655
+ this._sendFrame(OPCODE_PONG, frame.payload);
656
+ } else if (frame.opcode === OPCODE_PONG) {
657
+ this._lastPongAt = Date.now();
658
+ this.emit("pong", frame.payload);
659
+ } else {
660
+ this._abort(CLOSE_PROTOCOL_ERROR, "unknown opcode " + frame.opcode);
661
+ }
662
+ }
663
+
664
+ _appendFragment(frame) {
665
+ var newLen = this._fragLen + frame.payload.length;
666
+ if (newLen > this.maxMessageBytes) {
667
+ return this._abort(CLOSE_MESSAGE_TOO_BIG, "message exceeds maxMessageBytes");
668
+ }
669
+ this._fragChunks.push(frame.payload);
670
+ this._fragLen = newLen;
671
+ if (frame.fin) this._emitMessage();
672
+ }
673
+
674
+ _emitMessage() {
675
+ var data = this._fragChunks.length === 1
676
+ ? this._fragChunks[0]
677
+ : Buffer.concat(this._fragChunks, this._fragLen);
678
+ var opcode = this._fragOpcode;
679
+ var wasCompressed = this._fragCompressed;
680
+ this._fragOpcode = null;
681
+ this._fragChunks = null;
682
+ this._fragLen = 0;
683
+ this._fragCompressed = false;
684
+ // Decompress before emitting if the start frame had RSV1 set.
685
+ // RFC 7692: malformed deflate is a protocol error, surfaced as
686
+ // CLOSE_INVALID_PAYLOAD per §5.6 / §6 of RFC 6455.
687
+ if (wasCompressed) {
688
+ try {
689
+ data = _inflateMessage(data, this._permessageDeflate.clientMaxWindowBits);
690
+ } catch (e) {
691
+ return this._abort(CLOSE_INVALID_PAYLOAD,
692
+ "permessage-deflate inflate failed: " + ((e && e.message) || String(e)));
693
+ }
694
+ if (data.length > this.maxMessageBytes) {
695
+ return this._abort(CLOSE_MESSAGE_TOO_BIG,
696
+ "decompressed message exceeds maxMessageBytes");
697
+ }
698
+ }
699
+ if (opcode === OPCODE_TEXT) {
700
+ // §5.6: text frames MUST be valid UTF-8. Buffer.toString silently
701
+ // replaces invalid sequences with U+FFFD; explicit validation
702
+ // rejects malformed data per spec.
703
+ var str;
704
+ try { str = new TextDecoder("utf-8", { fatal: true }).decode(data); }
705
+ catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "text frame is not valid UTF-8"); }
706
+ this.emit("message", str, false);
707
+ } else {
708
+ this.emit("message", data, true);
709
+ }
710
+ }
711
+
712
+ _handleClose(frame) {
713
+ var code = CLOSE_NORMAL, reason = "";
714
+ if (frame.payload.length >= 2) {
715
+ code = frame.payload.readUInt16BE(0);
716
+ if (frame.payload.length > 2) {
717
+ try { reason = new TextDecoder("utf-8", { fatal: true }).decode(frame.payload.subarray(2)); }
718
+ catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "close reason is not valid UTF-8"); }
719
+ }
720
+ }
721
+ if (!this._closeSent) {
722
+ // Echo close (§5.5.1) — peer initiated, we acknowledge.
723
+ this._sendCloseFrame(code, reason);
724
+ this._closeSent = true;
725
+ }
726
+ // Transition to CLOSED — clean handshake completed (wasClean=true).
727
+ // The socket close will arrive shortly; _transitionToClosed is
728
+ // idempotent so the socket-close handler running afterward is a
729
+ // no-op.
730
+ try { this.socket.end(); } catch (_e) { /* socket already closed by peer */ }
731
+ this._transitionToClosed(code, reason, true, null);
732
+ }
733
+
734
+ _sendCloseFrame(code, reason) {
735
+ var reasonBuf = reason ? Buffer.from(String(reason), "utf8") : Buffer.alloc(0);
736
+ var payload = Buffer.alloc(2 + reasonBuf.length);
737
+ payload.writeUInt16BE(code, 0);
738
+ if (reasonBuf.length) reasonBuf.copy(payload, 2);
739
+ this._sendFrame(OPCODE_CLOSE, payload);
740
+ }
741
+
742
+ _sendFrame(opcode, payload, opts) {
743
+ if (this._state === STATE_CLOSED) return;
744
+ // Socket may have been destroyed by the peer between our last
745
+ // 'close' event check and this write — Node's 'close' event is
746
+ // async-after-destroy and there's a race window. Treat unwritable
747
+ // socket as the abnormal-closure path so the operator's 'close'
748
+ // handler fires consistently.
749
+ if (this.socket.destroyed || this.socket.writable === false) {
750
+ this._transitionToClosed(1006, "socket no longer writable", false, null);
751
+ return;
752
+ }
753
+ try {
754
+ this.socket.write(serializeFrame(opcode, payload, opts));
755
+ } catch (err) {
756
+ this._transitionToClosed(1006, (err && err.message) || "write failed", false, err);
757
+ }
758
+ }
759
+
760
+ _sendDataFrame(opcode, payload) {
761
+ // Compress entire-message-in-one-frame when permessage-deflate
762
+ // negotiated. RSV1 set on the FIRST frame of the message to mark
763
+ // it compressed; opcode-only continuation frames don't repeat
764
+ // RSV1 (see _onFrame's RSV1+continuation guard).
765
+ if (this._permessageDeflate && opcode !== OPCODE_PING &&
766
+ opcode !== OPCODE_PONG && opcode !== OPCODE_CLOSE) {
767
+ try {
768
+ var compressed = _deflateMessage(payload, this._permessageDeflate.serverMaxWindowBits);
769
+ this._sendFrame(opcode, compressed, { rsv1: true });
770
+ return;
771
+ } catch (_e) {
772
+ // Compression failure on send — fall through to uncompressed
773
+ // (we still have the original payload) so the connection
774
+ // keeps working. The underlying issue surfaces as observability.
775
+ }
776
+ }
777
+ this._sendFrame(opcode, payload);
778
+ }
779
+
780
+ send(data) {
781
+ if (this._state !== STATE_OPEN) {
782
+ throw new WebSocketError("ws/closed",
783
+ "connection is " + this._state + ", cannot send");
784
+ }
785
+ if (typeof data === "string") {
786
+ this._sendDataFrame(OPCODE_TEXT, Buffer.from(data, "utf8"));
787
+ } else if (Buffer.isBuffer(data)) {
788
+ this._sendDataFrame(OPCODE_BINARY, data);
789
+ } else {
790
+ data = safeBuffer.toBuffer(data, {
791
+ errorClass: WebSocketError,
792
+ typeCode: "ws/invalid-payload",
793
+ typeMessage: "send() requires Buffer, Uint8Array, or string",
794
+ });
795
+ this._sendDataFrame(OPCODE_BINARY, data);
796
+ }
797
+ }
798
+
799
+ ping(payload) {
800
+ if (this._state !== STATE_OPEN) return;
801
+ this._sendFrame(OPCODE_PING, payload || Buffer.alloc(0));
802
+ }
803
+
804
+ close(code, reason) {
805
+ if (this._state !== STATE_OPEN) return;
806
+ code = code || CLOSE_NORMAL;
807
+ this._sendCloseFrame(code, reason || "");
808
+ this._closeSent = true;
809
+ this._state = STATE_CLOSING;
810
+ // Grace period — wait for peer's close echo before forcing socket end.
811
+ var self = this;
812
+ this._closeTimer = setTimeout(function () {
813
+ try { self.socket.end(); } catch (_e) { /* socket already closed */ }
814
+ // If the peer never echoed, transition with the locally-sent code.
815
+ // wasClean: false because the peer didn't acknowledge.
816
+ self._transitionToClosed(code, reason || "", false, null);
817
+ }, this._closeGraceMs);
818
+ this._closeTimer.unref();
819
+ }
820
+
821
+ _abort(code, reason) {
822
+ if (this._state === STATE_CLOSED) return;
823
+ if (!this._closeSent) {
824
+ try { this._sendCloseFrame(code, reason); this._closeSent = true; } catch (_e) { /* close frame send-best-effort during abort */ }
825
+ }
826
+ try { this.socket.destroy(); } catch (_e) { /* socket already destroyed */ }
827
+ // _abort is for protocol violations — wasClean: false.
828
+ this._transitionToClosed(code, reason, false, null);
829
+ }
830
+
831
+ _heartbeat(pongTimeoutMs) {
832
+ if (this._state !== STATE_OPEN) return;
833
+ if (Date.now() - this._lastPongAt > pongTimeoutMs) {
834
+ this._abort(CLOSE_INTERNAL_ERROR, "ping timeout — peer unresponsive");
835
+ return;
836
+ }
837
+ this.ping();
838
+ }
839
+ }
840
+
841
+ // ---- Server-side upgrade handler ----
842
+ //
843
+ // The framework's router wires the HTTP server's 'upgrade' event to
844
+ // this function. Operators usually don't call it directly; they pass
845
+ // a handler to router.ws(path, opts).
846
+
847
+ function handleUpgrade(req, socket, head, opts) {
848
+ opts = opts || {};
849
+
850
+ // Throw-at-config-time on the optional GUID override. A typo here
851
+ // would produce a Sec-WebSocket-Accept the client can't match,
852
+ // breaking the upgrade in a way that's hard to diagnose; the format
853
+ // check at the top of handleUpgrade catches it loudly. Empty /
854
+ // undefined falls through to the RFC default in computeAcceptKey.
855
+ var GUID_MAX_LENGTH = C.BYTES.bytes(64); // allow:raw-byte-literal — UUID is 36 chars; 64 is a tolerant upper bound for the regex engine.
856
+ if (opts.handshakeGuid !== undefined && opts.handshakeGuid !== null) {
857
+ // Length cap before the regex test — UUIDs are exactly 36 chars so
858
+ // a > GUID_MAX_LENGTH input never matches the format and shouldn't
859
+ // reach the regex engine. Bounds the engine on hostile input
860
+ // regardless of the GUID_RE shape.
861
+ if (typeof opts.handshakeGuid !== "string" ||
862
+ opts.handshakeGuid.length > GUID_MAX_LENGTH ||
863
+ !GUID_RE.test(opts.handshakeGuid)) {
864
+ throw new Error("websocket.handleUpgrade: handshakeGuid must be a UUID-shaped string (8-4-4-4-12 hex with dashes), got " +
865
+ JSON.stringify(opts.handshakeGuid));
866
+ }
867
+ }
868
+
869
+ // Validate handshake first — refusing here writes a plain HTTP/1.1
870
+ // response and closes the socket, matching what the upgrade-event
871
+ // consumer would expect for a malformed request.
872
+ var v = validateUpgradeRequest(req);
873
+ if (!v.ok) {
874
+ _refuseUpgrade(socket, v.status || 400, v.reason);
875
+ return null;
876
+ }
877
+
878
+ // Origin policy.
879
+ if (!isOriginAllowed(req, opts.origins)) {
880
+ _refuseUpgrade(socket, 403, "origin not allowed");
881
+ return null;
882
+ }
883
+
884
+ // Subprotocol negotiation.
885
+ var subprotocol = negotiateSubprotocol(req, opts.subprotocols);
886
+
887
+ // permessage-deflate negotiation. Skipped (no echo header, no
888
+ // compression state on the connection) when the operator passes
889
+ // opts.permessageDeflate = false OR when the client didn't offer it.
890
+ var pmd = null;
891
+ if (opts.permessageDeflate !== false) {
892
+ var negotiated = _negotiatePermessageDeflate(req.headers["sec-websocket-extensions"]);
893
+ if (negotiated.negotiated) pmd = negotiated;
894
+ }
895
+
896
+ // Send 101.
897
+ try {
898
+ socket.write(buildUpgradeResponse(
899
+ req.headers["sec-websocket-key"], subprotocol,
900
+ pmd ? pmd.responseHeader : null, opts.handshakeGuid));
901
+ } catch (err) {
902
+ log.error("failed to write upgrade response: " + err.message);
903
+ try { socket.destroy(); } catch (_e) { /* socket already destroyed */ }
904
+ return null;
905
+ }
906
+
907
+ // If the head buffer has any bytes (data that arrived between
908
+ // headers and the upgrade handler), we pre-feed them into the
909
+ // parser via a synthetic data event. Most clients don't send
910
+ // anything before the 101 response, but the spec allows it.
911
+ var conn = new WebSocketConnection(socket, {
912
+ subprotocol: subprotocol,
913
+ maxMessageBytes: opts.maxMessageBytes,
914
+ pingIntervalMs: opts.pingIntervalMs,
915
+ pongTimeoutMs: opts.pongTimeoutMs,
916
+ permessageDeflate: pmd,
917
+ });
918
+ if (head && head.length > 0) {
919
+ // Manually invoke the data path with the pre-read bytes.
920
+ conn._onData(head);
921
+ }
922
+ return conn;
923
+ }
924
+
925
+ // ---- h2 Extended CONNECT (RFC 8441) entry point ----
926
+ //
927
+ // Called by the router from an http2.Server's 'stream' event when the
928
+ // :method header is "CONNECT" and :protocol is "websocket". Validates
929
+ // origin + subprotocols (same policy as h1), responds with :status 200
930
+ // (NOT 101 — Extended CONNECT is a CONNECT, not an Upgrade), and
931
+ // returns a WebSocketConnection wrapping the h2 stream.
932
+ //
933
+ // The server side must advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1
934
+ // in its h2 settings frame BEFORE clients can use Extended CONNECT.
935
+ // That's the operator's responsibility when constructing the h2 server
936
+ // — pass `settings: { enableConnectProtocol: true }` to
937
+ // http2.createServer / createSecureServer.
938
+
939
+ function handleExtendedConnect(stream, requestHeaders, opts) {
940
+ opts = opts || {};
941
+
942
+ // Verify it's actually a WebSocket Extended CONNECT (RFC 8441 §4).
943
+ if (requestHeaders[":method"] !== "CONNECT") {
944
+ _refuseH2Connect(stream, HTTP.BAD_REQUEST, "method must be CONNECT");
945
+ return null;
946
+ }
947
+ if (requestHeaders[":protocol"] !== "websocket") {
948
+ _refuseH2Connect(stream, HTTP.BAD_REQUEST, ":protocol must be websocket");
949
+ return null;
950
+ }
951
+
952
+ // Origin + subprotocol policy — same as h1. Build a fake req object
953
+ // so the helpers (which expect a Node http req shape) work uniformly.
954
+ var fakeReq = { headers: requestHeaders, method: "CONNECT" };
955
+ if (!isOriginAllowed(fakeReq, opts.origins)) {
956
+ _refuseH2Connect(stream, HTTP.FORBIDDEN, "origin not allowed");
957
+ return null;
958
+ }
959
+
960
+ var subprotocol = negotiateSubprotocol(fakeReq, opts.subprotocols);
961
+
962
+ // OK response — Extended CONNECT does NOT use 101. Sec-WebSocket-Key
963
+ // / Sec-WebSocket-Accept are NOT used (h2 stream identity replaces
964
+ // the handshake nonce dance from h1).
965
+ var responseHeaders = { ":status": HTTP.OK };
966
+ if (subprotocol) responseHeaders["sec-websocket-protocol"] = subprotocol;
967
+ try {
968
+ stream.respond(responseHeaders);
969
+ } catch (err) {
970
+ log.error("failed to write h2 Extended CONNECT response: " + err.message);
971
+ try { stream.close(); } catch (_e) { /* stream already closing */ }
972
+ return null;
973
+ }
974
+
975
+ return new WebSocketConnection(stream, {
976
+ transport: "h2",
977
+ subprotocol: subprotocol,
978
+ maxMessageBytes: opts.maxMessageBytes,
979
+ pingIntervalMs: opts.pingIntervalMs,
980
+ pongTimeoutMs: opts.pongTimeoutMs,
981
+ });
982
+ }
983
+
984
+ function _refuseH2Connect(stream, status, reason) {
985
+ try {
986
+ stream.respond({ ":status": status, "content-type": "text/plain; charset=utf-8" });
987
+ stream.end(reason || ("HTTP " + status));
988
+ } catch (_e) {
989
+ try { stream.close(); } catch (_e2) { /* stream already closed */ }
990
+ }
991
+ }
992
+
993
+ // Status text table for upgrade-refusal responses. Keyed by the
994
+ // framework's HTTP_STATUS hex IDs so the byte-literal lint doesn't
995
+ // hit decimal multiples-of-8 in the keys.
996
+ var _UPGRADE_REFUSAL_TEXT = {};
997
+ _UPGRADE_REFUSAL_TEXT[HTTP.BAD_REQUEST] = "Bad Request";
998
+ _UPGRADE_REFUSAL_TEXT[HTTP.FORBIDDEN] = "Forbidden";
999
+ _UPGRADE_REFUSAL_TEXT[HTTP.METHOD_NOT_ALLOWED] = "Method Not Allowed";
1000
+ _UPGRADE_REFUSAL_TEXT[0x1AA] = "Upgrade Required";
1001
+
1002
+ function _refuseUpgrade(socket, status, reason) {
1003
+ var statusText = _UPGRADE_REFUSAL_TEXT[status] || "Bad Request";
1004
+ var body = reason || statusText;
1005
+ var resp =
1006
+ "HTTP/1.1 " + status + " " + statusText + "\r\n" +
1007
+ "Connection: close\r\n" +
1008
+ "Content-Type: text/plain; charset=utf-8\r\n" +
1009
+ "Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
1010
+ "\r\n" +
1011
+ body;
1012
+ try { socket.write(resp); } catch (_e) { /* socket already closed */ }
1013
+ try { socket.destroy(); } catch (_e) { /* socket already closed */ }
1014
+ }
1015
+
1016
+ module.exports = {
1017
+ // Handshake helpers
1018
+ computeAcceptKey: computeAcceptKey,
1019
+ validateUpgradeRequest: validateUpgradeRequest,
1020
+ negotiateSubprotocol: negotiateSubprotocol,
1021
+ isOriginAllowed: isOriginAllowed,
1022
+ buildUpgradeResponse: buildUpgradeResponse,
1023
+ // Frame layer
1024
+ FrameParser: FrameParser,
1025
+ serializeFrame: serializeFrame,
1026
+ // Connection
1027
+ WebSocketConnection: WebSocketConnection,
1028
+ WebSocketError: WebSocketError,
1029
+ // Server-side entrypoints
1030
+ handleUpgrade: handleUpgrade, // h1 — RFC 6455 HTTP upgrade
1031
+ handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
1032
+ // Constants
1033
+ GUID: GUID,
1034
+ OPCODE_CONTINUATION: OPCODE_CONTINUATION,
1035
+ OPCODE_TEXT: OPCODE_TEXT,
1036
+ OPCODE_BINARY: OPCODE_BINARY,
1037
+ OPCODE_CLOSE: OPCODE_CLOSE,
1038
+ OPCODE_PING: OPCODE_PING,
1039
+ OPCODE_PONG: OPCODE_PONG,
1040
+ CLOSE_NORMAL: CLOSE_NORMAL,
1041
+ CLOSE_GOING_AWAY: CLOSE_GOING_AWAY,
1042
+ CLOSE_PROTOCOL_ERROR: CLOSE_PROTOCOL_ERROR,
1043
+ CLOSE_UNSUPPORTED_DATA: CLOSE_UNSUPPORTED_DATA,
1044
+ CLOSE_INVALID_PAYLOAD: CLOSE_INVALID_PAYLOAD,
1045
+ CLOSE_POLICY_VIOLATION: CLOSE_POLICY_VIOLATION,
1046
+ CLOSE_MESSAGE_TOO_BIG: CLOSE_MESSAGE_TOO_BIG,
1047
+ CLOSE_INTERNAL_ERROR: CLOSE_INTERNAL_ERROR,
1048
+ };