@blamejs/core 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
package/lib/websocket.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
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 { EventEmitter } = require("events");
|
|
86
|
+
var C = require("./constants");
|
|
87
|
+
var safeBuffer = require("./safe-buffer");
|
|
88
|
+
var { FrameworkError } = require("./framework-error");
|
|
89
|
+
var { boot } = require("./log");
|
|
90
|
+
|
|
91
|
+
var log = boot("websocket");
|
|
92
|
+
|
|
93
|
+
// RFC 6455 §1.3
|
|
94
|
+
var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
95
|
+
|
|
96
|
+
var OPCODE_CONTINUATION = 0x0;
|
|
97
|
+
var OPCODE_TEXT = 0x1;
|
|
98
|
+
var OPCODE_BINARY = 0x2;
|
|
99
|
+
var OPCODE_CLOSE = 0x8;
|
|
100
|
+
var OPCODE_PING = 0x9;
|
|
101
|
+
var OPCODE_PONG = 0xA;
|
|
102
|
+
|
|
103
|
+
// Close codes (RFC 6455 §7.4.1)
|
|
104
|
+
var CLOSE_NORMAL = 1000;
|
|
105
|
+
var CLOSE_GOING_AWAY = 1001;
|
|
106
|
+
var CLOSE_PROTOCOL_ERROR = 1002;
|
|
107
|
+
var CLOSE_UNSUPPORTED_DATA = 1003;
|
|
108
|
+
// 1004 reserved
|
|
109
|
+
// 1005 no-status (must not be sent on the wire)
|
|
110
|
+
// 1006 abnormal-closure (must not be sent on the wire)
|
|
111
|
+
var CLOSE_INVALID_PAYLOAD = 1007;
|
|
112
|
+
var CLOSE_POLICY_VIOLATION = 1008;
|
|
113
|
+
var CLOSE_MESSAGE_TOO_BIG = 1009;
|
|
114
|
+
var CLOSE_INTERNAL_ERROR = 1011;
|
|
115
|
+
|
|
116
|
+
// Defaults — tuned for fast detection of dead/silent connections.
|
|
117
|
+
//
|
|
118
|
+
// pingIntervalMs (30s): sends a ping every 30s. Aligned with most
|
|
119
|
+
// load-balancer idle timeouts so the LB doesn't kill the
|
|
120
|
+
// connection while we're still considering it healthy.
|
|
121
|
+
//
|
|
122
|
+
// pongTimeoutMs (35s): if no pong arrives within 35s of the last
|
|
123
|
+
// pong, abort with code 1011. Detection happens at ~35s — just
|
|
124
|
+
// past one ping interval. Stays under AWS ALB's 60s default
|
|
125
|
+
// idle so operators don't get LB-side disconnects fighting our
|
|
126
|
+
// heartbeat. Tighter than the typical 60s default in other libs;
|
|
127
|
+
// the cost of a false positive is a client reconnect, the cost
|
|
128
|
+
// of a slow detection is wasted server resources for genuinely-
|
|
129
|
+
// dead silent-failure connections.
|
|
130
|
+
//
|
|
131
|
+
// closeGraceMs (2s): after we send a close frame, wait this long
|
|
132
|
+
// for the peer's echo before forcibly ending the TCP socket.
|
|
133
|
+
// A healthy peer echoes in <100ms; 2s is plenty. Operators on
|
|
134
|
+
// slow networks override.
|
|
135
|
+
//
|
|
136
|
+
// All operator-overridable per connection via opts.{pingIntervalMs,
|
|
137
|
+
// pongTimeoutMs, closeGraceMs}.
|
|
138
|
+
var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(1);
|
|
139
|
+
var DEFAULT_PING_INTERVAL_MS = C.TIME.seconds(30);
|
|
140
|
+
var DEFAULT_PONG_TIMEOUT_MS = C.TIME.seconds(35);
|
|
141
|
+
var CLOSE_GRACE_MS = C.TIME.seconds(2);
|
|
142
|
+
|
|
143
|
+
// Connection lifecycle states — mirrors the browser WebSocket API +
|
|
144
|
+
// the npm `ws` library. Single-source-of-truth field; every state
|
|
145
|
+
// transition goes through _transitionToClosed (or set in the
|
|
146
|
+
// constructor for OPEN).
|
|
147
|
+
var STATE_OPEN = "open";
|
|
148
|
+
var STATE_CLOSING = "closing"; // we sent a close frame, awaiting peer's echo
|
|
149
|
+
var STATE_CLOSED = "closed";
|
|
150
|
+
|
|
151
|
+
class WebSocketError extends FrameworkError {
|
|
152
|
+
constructor(code, message, closeCode) {
|
|
153
|
+
super(message, code);
|
|
154
|
+
this.name = "WebSocketError";
|
|
155
|
+
this.closeCode = closeCode || CLOSE_PROTOCOL_ERROR;
|
|
156
|
+
this.isWebSocketError = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- Handshake helpers ----
|
|
161
|
+
|
|
162
|
+
function computeAcceptKey(secWebSocketKey) {
|
|
163
|
+
// SHA-1 required by RFC 6455 §1.3 — see file-level note 2 above.
|
|
164
|
+
// This is a protocol marker, not a security primitive.
|
|
165
|
+
var hash = nodeCrypto.createHash("sha1");
|
|
166
|
+
hash.update(String(secWebSocketKey) + GUID);
|
|
167
|
+
return hash.digest("base64");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateUpgradeRequest(req) {
|
|
171
|
+
if (req.method !== "GET") {
|
|
172
|
+
return { ok: false, status: 405, reason: "method must be GET" };
|
|
173
|
+
}
|
|
174
|
+
var h = req.headers || {};
|
|
175
|
+
if ((h.upgrade || "").toLowerCase() !== "websocket") {
|
|
176
|
+
return { ok: false, status: 400, reason: "missing Upgrade: websocket" };
|
|
177
|
+
}
|
|
178
|
+
// Connection header may carry multiple tokens (e.g. "keep-alive, Upgrade").
|
|
179
|
+
// Match "upgrade" as a comma-separated token, case-insensitive.
|
|
180
|
+
if (!/(^|,)\s*upgrade\s*(,|$)/i.test(h.connection || "")) {
|
|
181
|
+
return { ok: false, status: 400, reason: "missing Connection: upgrade" };
|
|
182
|
+
}
|
|
183
|
+
if (!h["sec-websocket-key"]) {
|
|
184
|
+
return { ok: false, status: 400, reason: "missing Sec-WebSocket-Key" };
|
|
185
|
+
}
|
|
186
|
+
if (h["sec-websocket-version"] !== "13") {
|
|
187
|
+
return { ok: false, status: 400, reason: "Sec-WebSocket-Version must be 13" };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function negotiateSubprotocol(req, supported) {
|
|
193
|
+
if (!supported || supported.length === 0) return null;
|
|
194
|
+
var raw = (req.headers || {})["sec-websocket-protocol"] || "";
|
|
195
|
+
var offered = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
196
|
+
for (var i = 0; i < offered.length; i++) {
|
|
197
|
+
if (supported.indexOf(offered[i]) !== -1) return offered[i];
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// origins shapes:
|
|
203
|
+
// array — strict allowlist, enforced
|
|
204
|
+
// "*" — explicit "accept all" (operator opt-in to no checking)
|
|
205
|
+
// null/undefined — same as "*" but caller (router) is expected to
|
|
206
|
+
// have logged a startup warning. Origin policy is a
|
|
207
|
+
// framework-level decision; this primitive doesn't
|
|
208
|
+
// re-warn here.
|
|
209
|
+
function isOriginAllowed(req, origins) {
|
|
210
|
+
if (!origins || origins === "*") return true;
|
|
211
|
+
var origin = (req.headers || {}).origin;
|
|
212
|
+
// Non-browser clients (curl, server-to-server, native apps) don't
|
|
213
|
+
// send Origin. Origin enforcement only meaningfully applies to
|
|
214
|
+
// browser-initiated upgrades — non-browser callers are gated by
|
|
215
|
+
// the operator's network ACL / auth middleware, not Origin.
|
|
216
|
+
if (!origin) return true;
|
|
217
|
+
if (Array.isArray(origins)) return origins.indexOf(origin) !== -1;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildUpgradeResponse(secWebSocketKey, subprotocol) {
|
|
222
|
+
var lines = [
|
|
223
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
224
|
+
"Upgrade: websocket",
|
|
225
|
+
"Connection: Upgrade",
|
|
226
|
+
"Sec-WebSocket-Accept: " + computeAcceptKey(secWebSocketKey),
|
|
227
|
+
];
|
|
228
|
+
if (subprotocol) lines.push("Sec-WebSocket-Protocol: " + subprotocol);
|
|
229
|
+
return lines.join("\r\n") + "\r\n\r\n";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Frame parser ----
|
|
233
|
+
//
|
|
234
|
+
// Incremental — push(chunk) accepts arbitrary buffer slices from the
|
|
235
|
+
// socket and emits zero-or-more complete frames as they arrive. Holds
|
|
236
|
+
// partial frame state across calls.
|
|
237
|
+
|
|
238
|
+
function FrameParser(opts) {
|
|
239
|
+
opts = opts || {};
|
|
240
|
+
this.maxFrameBytes = opts.maxFrameBytes || DEFAULT_MAX_MESSAGE_BYTES;
|
|
241
|
+
this._buffer = Buffer.alloc(0);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
FrameParser.prototype.push = function (chunk) {
|
|
245
|
+
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
246
|
+
var frames = [];
|
|
247
|
+
while (true) {
|
|
248
|
+
var frame = this._tryParseFrame();
|
|
249
|
+
if (!frame) break; // incomplete — wait for more bytes
|
|
250
|
+
frames.push(frame);
|
|
251
|
+
}
|
|
252
|
+
return frames;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
FrameParser.prototype._tryParseFrame = function () {
|
|
256
|
+
if (this._buffer.length < 2) return null;
|
|
257
|
+
var b0 = this._buffer[0];
|
|
258
|
+
var b1 = this._buffer[1];
|
|
259
|
+
var fin = !!(b0 & 0x80);
|
|
260
|
+
var rsv1 = !!(b0 & 0x40);
|
|
261
|
+
var rsv2 = !!(b0 & 0x20);
|
|
262
|
+
var rsv3 = !!(b0 & 0x10);
|
|
263
|
+
var opcode = b0 & 0x0F;
|
|
264
|
+
var masked = !!(b1 & 0x80);
|
|
265
|
+
var lenInd = b1 & 0x7F;
|
|
266
|
+
|
|
267
|
+
var headerLen = 2;
|
|
268
|
+
if (lenInd === 126) headerLen += 2;
|
|
269
|
+
else if (lenInd === 127) headerLen += 8;
|
|
270
|
+
if (masked) headerLen += 4;
|
|
271
|
+
if (this._buffer.length < headerLen) return null;
|
|
272
|
+
|
|
273
|
+
var payloadLen;
|
|
274
|
+
var off = 2;
|
|
275
|
+
if (lenInd < 126) {
|
|
276
|
+
payloadLen = lenInd;
|
|
277
|
+
} else if (lenInd === 126) {
|
|
278
|
+
payloadLen = this._buffer.readUInt16BE(off);
|
|
279
|
+
off += 2;
|
|
280
|
+
} else {
|
|
281
|
+
// 64-bit. JS Number is 53-bit safe — reject lengths above
|
|
282
|
+
// Number.MAX_SAFE_INTEGER explicitly rather than silently
|
|
283
|
+
// truncating.
|
|
284
|
+
var hi = this._buffer.readUInt32BE(off);
|
|
285
|
+
var lo = this._buffer.readUInt32BE(off + 4);
|
|
286
|
+
if (hi > 0x1FFFFF) {
|
|
287
|
+
throw new WebSocketError("ws/frame-too-large",
|
|
288
|
+
"frame length exceeds Number.MAX_SAFE_INTEGER", CLOSE_MESSAGE_TOO_BIG);
|
|
289
|
+
}
|
|
290
|
+
payloadLen = (hi * 0x100000000) + lo;
|
|
291
|
+
off += 8;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (payloadLen > this.maxFrameBytes) {
|
|
295
|
+
throw new WebSocketError("ws/frame-too-large",
|
|
296
|
+
"frame payload exceeds maxFrameBytes (" + this.maxFrameBytes + ")",
|
|
297
|
+
CLOSE_MESSAGE_TOO_BIG);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
var maskKey = null;
|
|
301
|
+
if (masked) {
|
|
302
|
+
maskKey = Buffer.from(this._buffer.subarray(off, off + 4));
|
|
303
|
+
off += 4;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
var totalLen = off + payloadLen;
|
|
307
|
+
if (this._buffer.length < totalLen) return null;
|
|
308
|
+
|
|
309
|
+
var payload = this._buffer.subarray(off, totalLen);
|
|
310
|
+
if (masked) {
|
|
311
|
+
var unmasked = Buffer.alloc(payloadLen);
|
|
312
|
+
for (var i = 0; i < payloadLen; i++) {
|
|
313
|
+
unmasked[i] = payload[i] ^ maskKey[i & 3];
|
|
314
|
+
}
|
|
315
|
+
payload = unmasked;
|
|
316
|
+
} else {
|
|
317
|
+
// Copy out — the underlying buffer is about to be sliced.
|
|
318
|
+
payload = Buffer.from(payload);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this._buffer = this._buffer.subarray(totalLen);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
fin: fin,
|
|
325
|
+
rsv1: rsv1,
|
|
326
|
+
rsv2: rsv2,
|
|
327
|
+
rsv3: rsv3,
|
|
328
|
+
opcode: opcode,
|
|
329
|
+
masked: masked,
|
|
330
|
+
payload: payload,
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// ---- Frame serializer ----
|
|
335
|
+
|
|
336
|
+
function serializeFrame(opcode, payload, opts) {
|
|
337
|
+
opts = opts || {};
|
|
338
|
+
var fin = opts.fin !== false;
|
|
339
|
+
var mask = opts.mask === true; // server-side defaults false
|
|
340
|
+
payload = payload || Buffer.alloc(0);
|
|
341
|
+
if (typeof payload === "string") payload = Buffer.from(payload, "utf8");
|
|
342
|
+
if (!Buffer.isBuffer(payload)) {
|
|
343
|
+
throw new WebSocketError("ws/invalid-payload",
|
|
344
|
+
"frame payload must be Buffer or string");
|
|
345
|
+
}
|
|
346
|
+
var len = payload.length;
|
|
347
|
+
|
|
348
|
+
var headerLen = 2;
|
|
349
|
+
var lenByte;
|
|
350
|
+
if (len < 126) { lenByte = len; }
|
|
351
|
+
else if (len < 65536) { lenByte = 126; headerLen += 2; }
|
|
352
|
+
else { lenByte = 127; headerLen += 8; }
|
|
353
|
+
if (mask) headerLen += 4;
|
|
354
|
+
|
|
355
|
+
var header = Buffer.alloc(headerLen);
|
|
356
|
+
header[0] = (fin ? 0x80 : 0) | (opcode & 0x0F);
|
|
357
|
+
header[1] = (mask ? 0x80 : 0) | lenByte;
|
|
358
|
+
|
|
359
|
+
var off = 2;
|
|
360
|
+
if (lenByte === 126) {
|
|
361
|
+
header.writeUInt16BE(len, off);
|
|
362
|
+
off += 2;
|
|
363
|
+
} else if (lenByte === 127) {
|
|
364
|
+
var hi = Math.floor(len / 0x100000000);
|
|
365
|
+
var lo = len % 0x100000000;
|
|
366
|
+
header.writeUInt32BE(hi, off);
|
|
367
|
+
header.writeUInt32BE(lo, off + 4);
|
|
368
|
+
off += 8;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (mask) {
|
|
372
|
+
var maskKey = nodeCrypto.randomBytes(4);
|
|
373
|
+
maskKey.copy(header, off);
|
|
374
|
+
var masked = Buffer.alloc(len);
|
|
375
|
+
for (var i = 0; i < len; i++) masked[i] = payload[i] ^ maskKey[i & 3];
|
|
376
|
+
return Buffer.concat([header, masked]);
|
|
377
|
+
}
|
|
378
|
+
return Buffer.concat([header, payload]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---- Connection ----
|
|
382
|
+
|
|
383
|
+
class WebSocketConnection extends EventEmitter {
|
|
384
|
+
constructor(socket, opts) {
|
|
385
|
+
super();
|
|
386
|
+
opts = opts || {};
|
|
387
|
+
this.socket = socket;
|
|
388
|
+
this.subprotocol = opts.subprotocol || null;
|
|
389
|
+
this.maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
|
|
390
|
+
// Transport selects mask-enforcement direction:
|
|
391
|
+
// h1 (RFC 6455): client→server frames MUST be masked. Default.
|
|
392
|
+
// h2 (RFC 8441): frames MUST NOT be masked — h2 already provides
|
|
393
|
+
// the framing/security guarantees that masking
|
|
394
|
+
// exists to protect against in h1 (proxy
|
|
395
|
+
// cache-poisoning via raw text on the wire).
|
|
396
|
+
this.transport = opts.transport === "h2" ? "h2" : "h1";
|
|
397
|
+
var pingMs = opts.pingIntervalMs || DEFAULT_PING_INTERVAL_MS;
|
|
398
|
+
var pongMs = opts.pongTimeoutMs || DEFAULT_PONG_TIMEOUT_MS;
|
|
399
|
+
// Grace period after we send a close frame before forcing the
|
|
400
|
+
// socket end. Production default = 5s (give the peer time to ack).
|
|
401
|
+
// Tests / latency-sensitive ops can pass a shorter value.
|
|
402
|
+
this._closeGraceMs = opts.closeGraceMs != null ? opts.closeGraceMs : CLOSE_GRACE_MS;
|
|
403
|
+
|
|
404
|
+
// Lifecycle state — single source of truth. Operators read
|
|
405
|
+
// conn.readyState; internal code reads/writes this._state via
|
|
406
|
+
// _transitionToClosed. All transitions emit 'close' exactly once.
|
|
407
|
+
this._state = STATE_OPEN;
|
|
408
|
+
this._closeSent = false;
|
|
409
|
+
this._closeTimer = null;
|
|
410
|
+
this.lastError = null; // last diagnosable error, if any
|
|
411
|
+
// Fragmentation reassembly state.
|
|
412
|
+
this._fragOpcode = null;
|
|
413
|
+
this._fragChunks = null;
|
|
414
|
+
this._fragLen = 0;
|
|
415
|
+
|
|
416
|
+
this._parser = new FrameParser({ maxFrameBytes: this.maxMessageBytes });
|
|
417
|
+
this._lastPongAt = Date.now();
|
|
418
|
+
|
|
419
|
+
var self = this;
|
|
420
|
+
this._pingTimer = setInterval(function () { self._heartbeat(pongMs); }, pingMs);
|
|
421
|
+
this._pingTimer.unref();
|
|
422
|
+
|
|
423
|
+
socket.on("data", function (chunk) { self._onData(chunk); });
|
|
424
|
+
socket.on("error", function (err) {
|
|
425
|
+
// Network errors are LIFECYCLE events, not protocol errors —
|
|
426
|
+
// route through _transitionToClosed with code 1006 (abnormal
|
|
427
|
+
// closure). Mirrors the browser WebSocket API + ws npm
|
|
428
|
+
// convention: operators listening on 'close' see the death;
|
|
429
|
+
// 'error' is reserved for diagnosable protocol issues that
|
|
430
|
+
// the operator may want to explicitly handle.
|
|
431
|
+
self._transitionToClosed(1006, (err && err.message) || "socket error", false, err);
|
|
432
|
+
});
|
|
433
|
+
socket.on("close", function () {
|
|
434
|
+
// Socket FIN/RST seen — if we haven't already transitioned via
|
|
435
|
+
// a clean close-handshake, this is an abnormal closure.
|
|
436
|
+
if (self._state !== STATE_CLOSED) {
|
|
437
|
+
self._transitionToClosed(1006, "abnormal closure", false, null);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Single state-transition method. Idempotent — repeat calls after
|
|
443
|
+
// CLOSED are no-ops. Emits 'close' exactly once with (code, reason,
|
|
444
|
+
// wasClean) signature matching the browser API.
|
|
445
|
+
_transitionToClosed(code, reason, wasClean, error) {
|
|
446
|
+
if (this._state === STATE_CLOSED) return;
|
|
447
|
+
this._state = STATE_CLOSED;
|
|
448
|
+
if (error) this.lastError = error;
|
|
449
|
+
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
|
|
450
|
+
if (this._closeTimer) { clearTimeout(this._closeTimer); this._closeTimer = null; }
|
|
451
|
+
// Surface diagnosable errors via 'error' first — but only if the
|
|
452
|
+
// operator is listening AND this is a real diagnosable case.
|
|
453
|
+
// EventEmitter throws "Unhandled 'error' event" on emit() with no
|
|
454
|
+
// listener; gate the emit to avoid taking down the process.
|
|
455
|
+
if (error && this.listenerCount("error") > 0) {
|
|
456
|
+
try { this.emit("error", error); } catch (_e) { /* listener threw — ignore */ }
|
|
457
|
+
}
|
|
458
|
+
this.emit("close", code, reason, !!wasClean);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Browser-style state field. 'open' | 'closing' | 'closed'.
|
|
462
|
+
get readyState() { return this._state; }
|
|
463
|
+
|
|
464
|
+
_onData(chunk) {
|
|
465
|
+
var frames;
|
|
466
|
+
try { frames = this._parser.push(chunk); }
|
|
467
|
+
catch (err) {
|
|
468
|
+
var code = err.closeCode || CLOSE_PROTOCOL_ERROR;
|
|
469
|
+
return this._abort(code, err.message);
|
|
470
|
+
}
|
|
471
|
+
for (var i = 0; i < frames.length; i++) {
|
|
472
|
+
this._handleFrame(frames[i]);
|
|
473
|
+
if (this._state === STATE_CLOSED) return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
_handleFrame(frame) {
|
|
478
|
+
// Mask enforcement flips by transport (RFC 6455 §5.3 vs RFC 8441):
|
|
479
|
+
// h1: client→server frames MUST be masked
|
|
480
|
+
// h2: frames MUST NOT be masked (h2 transport provides the
|
|
481
|
+
// protections that masking exists for)
|
|
482
|
+
if (this.transport === "h1" && !frame.masked) {
|
|
483
|
+
return this._abort(CLOSE_PROTOCOL_ERROR, "client frame not masked (h1)");
|
|
484
|
+
}
|
|
485
|
+
if (this.transport === "h2" && frame.masked) {
|
|
486
|
+
return this._abort(CLOSE_PROTOCOL_ERROR, "frame must not be masked (h2)");
|
|
487
|
+
}
|
|
488
|
+
// Reserved bits — must be zero unless a negotiated extension uses them.
|
|
489
|
+
// We don't negotiate any extensions today (compression deferred), so
|
|
490
|
+
// any RSV bit set is a protocol error.
|
|
491
|
+
if (frame.rsv1 || frame.rsv2 || frame.rsv3) {
|
|
492
|
+
return this._abort(CLOSE_PROTOCOL_ERROR, "reserved bits set without extension");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (frame.opcode === OPCODE_CONTINUATION) {
|
|
496
|
+
if (this._fragOpcode === null) {
|
|
497
|
+
return this._abort(CLOSE_PROTOCOL_ERROR, "continuation without start");
|
|
498
|
+
}
|
|
499
|
+
this._appendFragment(frame);
|
|
500
|
+
} else if (frame.opcode === OPCODE_TEXT || frame.opcode === OPCODE_BINARY) {
|
|
501
|
+
if (this._fragOpcode !== null) {
|
|
502
|
+
return this._abort(CLOSE_PROTOCOL_ERROR, "new message during fragmentation");
|
|
503
|
+
}
|
|
504
|
+
this._fragOpcode = frame.opcode;
|
|
505
|
+
this._fragChunks = [frame.payload];
|
|
506
|
+
this._fragLen = frame.payload.length;
|
|
507
|
+
if (frame.fin) this._emitMessage();
|
|
508
|
+
} else if (frame.opcode === OPCODE_CLOSE) {
|
|
509
|
+
this._handleClose(frame);
|
|
510
|
+
} else if (frame.opcode === OPCODE_PING) {
|
|
511
|
+
this.emit("ping", frame.payload);
|
|
512
|
+
this._sendFrame(OPCODE_PONG, frame.payload);
|
|
513
|
+
} else if (frame.opcode === OPCODE_PONG) {
|
|
514
|
+
this._lastPongAt = Date.now();
|
|
515
|
+
this.emit("pong", frame.payload);
|
|
516
|
+
} else {
|
|
517
|
+
this._abort(CLOSE_PROTOCOL_ERROR, "unknown opcode " + frame.opcode);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_appendFragment(frame) {
|
|
522
|
+
var newLen = this._fragLen + frame.payload.length;
|
|
523
|
+
if (newLen > this.maxMessageBytes) {
|
|
524
|
+
return this._abort(CLOSE_MESSAGE_TOO_BIG, "message exceeds maxMessageBytes");
|
|
525
|
+
}
|
|
526
|
+
this._fragChunks.push(frame.payload);
|
|
527
|
+
this._fragLen = newLen;
|
|
528
|
+
if (frame.fin) this._emitMessage();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
_emitMessage() {
|
|
532
|
+
var data = this._fragChunks.length === 1
|
|
533
|
+
? this._fragChunks[0]
|
|
534
|
+
: Buffer.concat(this._fragChunks, this._fragLen);
|
|
535
|
+
var opcode = this._fragOpcode;
|
|
536
|
+
this._fragOpcode = null;
|
|
537
|
+
this._fragChunks = null;
|
|
538
|
+
this._fragLen = 0;
|
|
539
|
+
if (opcode === OPCODE_TEXT) {
|
|
540
|
+
// §5.6: text frames MUST be valid UTF-8. Buffer.toString silently
|
|
541
|
+
// replaces invalid sequences with U+FFFD; explicit validation
|
|
542
|
+
// rejects malformed data per spec.
|
|
543
|
+
var str;
|
|
544
|
+
try { str = new TextDecoder("utf-8", { fatal: true }).decode(data); }
|
|
545
|
+
catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "text frame is not valid UTF-8"); }
|
|
546
|
+
this.emit("message", str, false);
|
|
547
|
+
} else {
|
|
548
|
+
this.emit("message", data, true);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
_handleClose(frame) {
|
|
553
|
+
var code = CLOSE_NORMAL, reason = "";
|
|
554
|
+
if (frame.payload.length >= 2) {
|
|
555
|
+
code = frame.payload.readUInt16BE(0);
|
|
556
|
+
if (frame.payload.length > 2) {
|
|
557
|
+
try { reason = new TextDecoder("utf-8", { fatal: true }).decode(frame.payload.subarray(2)); }
|
|
558
|
+
catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "close reason is not valid UTF-8"); }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (!this._closeSent) {
|
|
562
|
+
// Echo close (§5.5.1) — peer initiated, we acknowledge.
|
|
563
|
+
this._sendCloseFrame(code, reason);
|
|
564
|
+
this._closeSent = true;
|
|
565
|
+
}
|
|
566
|
+
// Transition to CLOSED — clean handshake completed (wasClean=true).
|
|
567
|
+
// The socket close will arrive shortly; _transitionToClosed is
|
|
568
|
+
// idempotent so the socket-close handler running afterward is a
|
|
569
|
+
// no-op.
|
|
570
|
+
try { this.socket.end(); } catch (_e) {}
|
|
571
|
+
this._transitionToClosed(code, reason, true, null);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
_sendCloseFrame(code, reason) {
|
|
575
|
+
var reasonBuf = reason ? Buffer.from(String(reason), "utf8") : Buffer.alloc(0);
|
|
576
|
+
var payload = Buffer.alloc(2 + reasonBuf.length);
|
|
577
|
+
payload.writeUInt16BE(code, 0);
|
|
578
|
+
if (reasonBuf.length) reasonBuf.copy(payload, 2);
|
|
579
|
+
this._sendFrame(OPCODE_CLOSE, payload);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
_sendFrame(opcode, payload) {
|
|
583
|
+
if (this._state === STATE_CLOSED) return;
|
|
584
|
+
// Socket may have been destroyed by the peer between our last
|
|
585
|
+
// 'close' event check and this write — Node's 'close' event is
|
|
586
|
+
// async-after-destroy and there's a race window. Treat unwritable
|
|
587
|
+
// socket as the abnormal-closure path so the operator's 'close'
|
|
588
|
+
// handler fires consistently.
|
|
589
|
+
if (this.socket.destroyed || this.socket.writable === false) {
|
|
590
|
+
this._transitionToClosed(1006, "socket no longer writable", false, null);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
this.socket.write(serializeFrame(opcode, payload));
|
|
595
|
+
} catch (err) {
|
|
596
|
+
this._transitionToClosed(1006, (err && err.message) || "write failed", false, err);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
send(data) {
|
|
601
|
+
if (this._state !== STATE_OPEN) {
|
|
602
|
+
throw new WebSocketError("ws/closed",
|
|
603
|
+
"connection is " + this._state + ", cannot send");
|
|
604
|
+
}
|
|
605
|
+
if (typeof data === "string") {
|
|
606
|
+
this._sendFrame(OPCODE_TEXT, Buffer.from(data, "utf8"));
|
|
607
|
+
} else if (Buffer.isBuffer(data)) {
|
|
608
|
+
this._sendFrame(OPCODE_BINARY, data);
|
|
609
|
+
} else {
|
|
610
|
+
data = safeBuffer.toBuffer(data, {
|
|
611
|
+
errorClass: WebSocketError,
|
|
612
|
+
typeCode: "ws/invalid-payload",
|
|
613
|
+
typeMessage: "send() requires Buffer, Uint8Array, or string",
|
|
614
|
+
});
|
|
615
|
+
this._sendFrame(OPCODE_BINARY, data);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
ping(payload) {
|
|
620
|
+
if (this._state !== STATE_OPEN) return;
|
|
621
|
+
this._sendFrame(OPCODE_PING, payload || Buffer.alloc(0));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
close(code, reason) {
|
|
625
|
+
if (this._state !== STATE_OPEN) return;
|
|
626
|
+
code = code || CLOSE_NORMAL;
|
|
627
|
+
this._sendCloseFrame(code, reason || "");
|
|
628
|
+
this._closeSent = true;
|
|
629
|
+
this._state = STATE_CLOSING;
|
|
630
|
+
// Grace period — wait for peer's close echo before forcing socket end.
|
|
631
|
+
var self = this;
|
|
632
|
+
this._closeTimer = setTimeout(function () {
|
|
633
|
+
try { self.socket.end(); } catch (_e) {}
|
|
634
|
+
// If the peer never echoed, transition with the locally-sent code.
|
|
635
|
+
// wasClean: false because the peer didn't acknowledge.
|
|
636
|
+
self._transitionToClosed(code, reason || "", false, null);
|
|
637
|
+
}, this._closeGraceMs);
|
|
638
|
+
this._closeTimer.unref();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
_abort(code, reason) {
|
|
642
|
+
if (this._state === STATE_CLOSED) return;
|
|
643
|
+
if (!this._closeSent) {
|
|
644
|
+
try { this._sendCloseFrame(code, reason); this._closeSent = true; } catch (_e) {}
|
|
645
|
+
}
|
|
646
|
+
try { this.socket.destroy(); } catch (_e) {}
|
|
647
|
+
// _abort is for protocol violations — wasClean: false.
|
|
648
|
+
this._transitionToClosed(code, reason, false, null);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
_heartbeat(pongTimeoutMs) {
|
|
652
|
+
if (this._state !== STATE_OPEN) return;
|
|
653
|
+
if (Date.now() - this._lastPongAt > pongTimeoutMs) {
|
|
654
|
+
this._abort(CLOSE_INTERNAL_ERROR, "ping timeout — peer unresponsive");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
this.ping();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---- Server-side upgrade handler ----
|
|
662
|
+
//
|
|
663
|
+
// The framework's router wires the HTTP server's 'upgrade' event to
|
|
664
|
+
// this function. Operators usually don't call it directly; they pass
|
|
665
|
+
// a handler to router.ws(path, opts).
|
|
666
|
+
|
|
667
|
+
function handleUpgrade(req, socket, head, opts) {
|
|
668
|
+
opts = opts || {};
|
|
669
|
+
|
|
670
|
+
// Validate handshake first — refusing here writes a plain HTTP/1.1
|
|
671
|
+
// response and closes the socket, matching what the upgrade-event
|
|
672
|
+
// consumer would expect for a malformed request.
|
|
673
|
+
var v = validateUpgradeRequest(req);
|
|
674
|
+
if (!v.ok) {
|
|
675
|
+
_refuseUpgrade(socket, v.status || 400, v.reason);
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Origin policy.
|
|
680
|
+
if (!isOriginAllowed(req, opts.origins)) {
|
|
681
|
+
_refuseUpgrade(socket, 403, "origin not allowed");
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Subprotocol negotiation.
|
|
686
|
+
var subprotocol = negotiateSubprotocol(req, opts.subprotocols);
|
|
687
|
+
|
|
688
|
+
// Send 101.
|
|
689
|
+
try {
|
|
690
|
+
socket.write(buildUpgradeResponse(req.headers["sec-websocket-key"], subprotocol));
|
|
691
|
+
} catch (err) {
|
|
692
|
+
log.error("failed to write upgrade response: " + err.message);
|
|
693
|
+
try { socket.destroy(); } catch (_e) {}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// If the head buffer has any bytes (data that arrived between
|
|
698
|
+
// headers and the upgrade handler), we pre-feed them into the
|
|
699
|
+
// parser via a synthetic data event. Most clients don't send
|
|
700
|
+
// anything before the 101 response, but the spec allows it.
|
|
701
|
+
var conn = new WebSocketConnection(socket, {
|
|
702
|
+
subprotocol: subprotocol,
|
|
703
|
+
maxMessageBytes: opts.maxMessageBytes,
|
|
704
|
+
pingIntervalMs: opts.pingIntervalMs,
|
|
705
|
+
pongTimeoutMs: opts.pongTimeoutMs,
|
|
706
|
+
});
|
|
707
|
+
if (head && head.length > 0) {
|
|
708
|
+
// Manually invoke the data path with the pre-read bytes.
|
|
709
|
+
conn._onData(head);
|
|
710
|
+
}
|
|
711
|
+
return conn;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---- h2 Extended CONNECT (RFC 8441) entry point ----
|
|
715
|
+
//
|
|
716
|
+
// Called by the router from an http2.Server's 'stream' event when the
|
|
717
|
+
// :method header is "CONNECT" and :protocol is "websocket". Validates
|
|
718
|
+
// origin + subprotocols (same policy as h1), responds with :status 200
|
|
719
|
+
// (NOT 101 — Extended CONNECT is a CONNECT, not an Upgrade), and
|
|
720
|
+
// returns a WebSocketConnection wrapping the h2 stream.
|
|
721
|
+
//
|
|
722
|
+
// The server side must advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1
|
|
723
|
+
// in its h2 settings frame BEFORE clients can use Extended CONNECT.
|
|
724
|
+
// That's the operator's responsibility when constructing the h2 server
|
|
725
|
+
// — pass `settings: { enableConnectProtocol: true }` to
|
|
726
|
+
// http2.createServer / createSecureServer.
|
|
727
|
+
|
|
728
|
+
function handleExtendedConnect(stream, requestHeaders, opts) {
|
|
729
|
+
opts = opts || {};
|
|
730
|
+
|
|
731
|
+
// Verify it's actually a WebSocket Extended CONNECT (RFC 8441 §4).
|
|
732
|
+
if (requestHeaders[":method"] !== "CONNECT") {
|
|
733
|
+
_refuseH2Connect(stream, 400, "method must be CONNECT");
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
if (requestHeaders[":protocol"] !== "websocket") {
|
|
737
|
+
_refuseH2Connect(stream, 400, ":protocol must be websocket");
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Origin + subprotocol policy — same as h1. Build a fake req object
|
|
742
|
+
// so the helpers (which expect a Node http req shape) work uniformly.
|
|
743
|
+
var fakeReq = { headers: requestHeaders, method: "CONNECT" };
|
|
744
|
+
if (!isOriginAllowed(fakeReq, opts.origins)) {
|
|
745
|
+
_refuseH2Connect(stream, 403, "origin not allowed");
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
var subprotocol = negotiateSubprotocol(fakeReq, opts.subprotocols);
|
|
750
|
+
|
|
751
|
+
// 200 response — Extended CONNECT does NOT use 101. Sec-WebSocket-Key
|
|
752
|
+
// / Sec-WebSocket-Accept are NOT used (h2 stream identity replaces
|
|
753
|
+
// the handshake nonce dance from h1).
|
|
754
|
+
var responseHeaders = { ":status": 200 };
|
|
755
|
+
if (subprotocol) responseHeaders["sec-websocket-protocol"] = subprotocol;
|
|
756
|
+
try {
|
|
757
|
+
stream.respond(responseHeaders);
|
|
758
|
+
} catch (err) {
|
|
759
|
+
log.error("failed to write h2 Extended CONNECT response: " + err.message);
|
|
760
|
+
try { stream.close(); } catch (_e) {}
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return new WebSocketConnection(stream, {
|
|
765
|
+
transport: "h2",
|
|
766
|
+
subprotocol: subprotocol,
|
|
767
|
+
maxMessageBytes: opts.maxMessageBytes,
|
|
768
|
+
pingIntervalMs: opts.pingIntervalMs,
|
|
769
|
+
pongTimeoutMs: opts.pongTimeoutMs,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function _refuseH2Connect(stream, status, reason) {
|
|
774
|
+
try {
|
|
775
|
+
stream.respond({ ":status": status, "content-type": "text/plain; charset=utf-8" });
|
|
776
|
+
stream.end(reason || ("HTTP " + status));
|
|
777
|
+
} catch (_e) {
|
|
778
|
+
try { stream.close(); } catch (_e2) {}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function _refuseUpgrade(socket, status, reason) {
|
|
783
|
+
var statusText = {
|
|
784
|
+
400: "Bad Request",
|
|
785
|
+
403: "Forbidden",
|
|
786
|
+
405: "Method Not Allowed",
|
|
787
|
+
426: "Upgrade Required",
|
|
788
|
+
}[status] || "Bad Request";
|
|
789
|
+
var body = reason || statusText;
|
|
790
|
+
var resp =
|
|
791
|
+
"HTTP/1.1 " + status + " " + statusText + "\r\n" +
|
|
792
|
+
"Connection: close\r\n" +
|
|
793
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
794
|
+
"Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
|
|
795
|
+
"\r\n" +
|
|
796
|
+
body;
|
|
797
|
+
try { socket.write(resp); } catch (_e) {}
|
|
798
|
+
try { socket.destroy(); } catch (_e) {}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
module.exports = {
|
|
802
|
+
// Handshake helpers
|
|
803
|
+
computeAcceptKey: computeAcceptKey,
|
|
804
|
+
validateUpgradeRequest: validateUpgradeRequest,
|
|
805
|
+
negotiateSubprotocol: negotiateSubprotocol,
|
|
806
|
+
isOriginAllowed: isOriginAllowed,
|
|
807
|
+
buildUpgradeResponse: buildUpgradeResponse,
|
|
808
|
+
// Frame layer
|
|
809
|
+
FrameParser: FrameParser,
|
|
810
|
+
serializeFrame: serializeFrame,
|
|
811
|
+
// Connection
|
|
812
|
+
WebSocketConnection: WebSocketConnection,
|
|
813
|
+
WebSocketError: WebSocketError,
|
|
814
|
+
// Server-side entrypoints
|
|
815
|
+
handleUpgrade: handleUpgrade, // h1 — RFC 6455 HTTP upgrade
|
|
816
|
+
handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
|
|
817
|
+
// Constants
|
|
818
|
+
GUID: GUID,
|
|
819
|
+
OPCODE_CONTINUATION: OPCODE_CONTINUATION,
|
|
820
|
+
OPCODE_TEXT: OPCODE_TEXT,
|
|
821
|
+
OPCODE_BINARY: OPCODE_BINARY,
|
|
822
|
+
OPCODE_CLOSE: OPCODE_CLOSE,
|
|
823
|
+
OPCODE_PING: OPCODE_PING,
|
|
824
|
+
OPCODE_PONG: OPCODE_PONG,
|
|
825
|
+
CLOSE_NORMAL: CLOSE_NORMAL,
|
|
826
|
+
CLOSE_GOING_AWAY: CLOSE_GOING_AWAY,
|
|
827
|
+
CLOSE_PROTOCOL_ERROR: CLOSE_PROTOCOL_ERROR,
|
|
828
|
+
CLOSE_UNSUPPORTED_DATA: CLOSE_UNSUPPORTED_DATA,
|
|
829
|
+
CLOSE_INVALID_PAYLOAD: CLOSE_INVALID_PAYLOAD,
|
|
830
|
+
CLOSE_POLICY_VIOLATION: CLOSE_POLICY_VIOLATION,
|
|
831
|
+
CLOSE_MESSAGE_TOO_BIG: CLOSE_MESSAGE_TOO_BIG,
|
|
832
|
+
CLOSE_INTERNAL_ERROR: CLOSE_INTERNAL_ERROR,
|
|
833
|
+
};
|