@blamejs/core 0.7.107 → 0.8.4
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 +41 -1
- package/NOTICE +17 -1
- package/README.md +4 -3
- package/index.js +15 -0
- package/lib/asyncapi-bindings.js +160 -0
- package/lib/asyncapi-traits.js +143 -0
- package/lib/asyncapi.js +531 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +68 -2
- package/lib/auth/acr-vocabulary.js +265 -0
- package/lib/auth/auth-time-tracker.js +111 -0
- package/lib/auth/elevation-grant.js +306 -0
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/auth/step-up-policy.js +335 -0
- package/lib/auth/step-up.js +445 -0
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +190 -0
- package/lib/compliance-ai-act-prohibited.js +205 -0
- package/lib/compliance-ai-act-risk.js +189 -0
- package/lib/compliance-ai-act-transparency.js +200 -0
- package/lib/compliance-ai-act.js +558 -0
- package/lib/compliance.js +12 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +114 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag-cache.js +136 -0
- package/lib/flag-evaluation-context.js +135 -0
- package/lib/flag-providers.js +279 -0
- package/lib/flag-targeting.js +210 -0
- package/lib/flag.js +284 -0
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +391 -0
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail-arc-sign.js +372 -0
- package/lib/mail-auth.js +2 -0
- package/lib/mail.js +40 -0
- package/lib/middleware/ai-act-disclosure.js +166 -0
- package/lib/middleware/asyncapi-serve.js +136 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/flag-context.js +76 -0
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/index.js +15 -0
- package/lib/middleware/openapi-serve.js +143 -0
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/require-step-up.js +186 -0
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/openapi-paths-builder.js +248 -0
- package/lib/openapi-schema-walk.js +192 -0
- package/lib/openapi-security.js +169 -0
- package/lib/openapi-yaml.js +154 -0
- package/lib/openapi.js +443 -0
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pqc-software.js +195 -0
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/vault/index.js +3 -0
- package/lib/vault-aad.js +259 -0
- package/lib/vendor/MANIFEST.json +29 -0
- package/lib/vendor/noble-post-quantum.cjs +18 -0
- package/lib/ws-client.js +978 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/ws-client.js
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.wsClient — outbound WebSocket client (RFC 6455).
|
|
4
|
+
*
|
|
5
|
+
* Companion to b.websocket (server-side). Operators dial out to peer
|
|
6
|
+
* WebSocket endpoints from Node — webhooks, pubsub bridges, integration
|
|
7
|
+
* with external realtime services — without reaching for `ws` from
|
|
8
|
+
* npm.
|
|
9
|
+
*
|
|
10
|
+
* var client = b.wsClient.connect("wss://stream.example.com/v1", {
|
|
11
|
+
* subprotocols: ["json-stream-v1"],
|
|
12
|
+
* headers: { "Authorization": "Bearer " + token },
|
|
13
|
+
* reconnect: { maxAttempts: 10, baseMs: 500, maxMs: 30000 },
|
|
14
|
+
* pingMs: 30000,
|
|
15
|
+
* pongMs: 60000,
|
|
16
|
+
* maxMessageBytes: b.constants.BYTES.mib(8),
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* client.on("open", function () { client.send({ subscribe: ["orders"] }); });
|
|
20
|
+
* client.on("message", function (data, isBinary) { ... });
|
|
21
|
+
* client.on("close", function (code, reason) { ... });
|
|
22
|
+
* client.on("error", function (err) { ... });
|
|
23
|
+
*
|
|
24
|
+
* client.send("text frame");
|
|
25
|
+
* client.send(Buffer.from("binary frame"));
|
|
26
|
+
* client.close(1000, "bye");
|
|
27
|
+
*
|
|
28
|
+
* Frame layer is the same RFC 6455 implementation b.websocket already
|
|
29
|
+
* ships — we reuse `FrameParser` and `serializeFrame` from
|
|
30
|
+
* lib/websocket.js. The client adds:
|
|
31
|
+
*
|
|
32
|
+
* - Outbound HTTP/1.1 Upgrade with Sec-WebSocket-Key generation.
|
|
33
|
+
* - Sec-WebSocket-Accept verification (rejects on hash mismatch).
|
|
34
|
+
* - Subprotocol + permessage-deflate negotiation.
|
|
35
|
+
* - Client-side frame masking (RFC 6455 §5.3 — required for outbound).
|
|
36
|
+
* - TLS via b.network.tls.pqc (X25519MLKEM768 hybrid handshake).
|
|
37
|
+
* - Heartbeat: ping every `pingMs`, drop the connection if pong not
|
|
38
|
+
* received within `pongMs`.
|
|
39
|
+
* - Auto-reconnect with exponential backoff + jitter.
|
|
40
|
+
*
|
|
41
|
+
* Per the validation-tier policy: connect() throws on bad opts at
|
|
42
|
+
* config time; runtime errors flow through 'error' events.
|
|
43
|
+
*
|
|
44
|
+
* Per the security-defaults stance: TLS verification ON by default
|
|
45
|
+
* (operator opts in to mTLS via tlsOpts). HSTS-style, no soft-fail.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
var net = require("net");
|
|
49
|
+
var url = require("url");
|
|
50
|
+
var nodeCrypto = require("crypto");
|
|
51
|
+
var EventEmitter = require("events");
|
|
52
|
+
|
|
53
|
+
var lazyRequire = require("./lazy-require");
|
|
54
|
+
var validateOpts = require("./validate-opts");
|
|
55
|
+
var safeAsync = require("./safe-async");
|
|
56
|
+
var safeBuffer = require("./safe-buffer");
|
|
57
|
+
var fwCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
58
|
+
var websocket = lazyRequire(function () { return require("./websocket"); });
|
|
59
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
|
+
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
61
|
+
var safeJson = lazyRequire(function () { return require("./safe-json"); });
|
|
62
|
+
var ssrfGuard = require("./ssrf-guard");
|
|
63
|
+
var C = require("./constants");
|
|
64
|
+
var { defineClass } = require("./framework-error");
|
|
65
|
+
|
|
66
|
+
var WsClientError = defineClass("WsClientError", { alwaysPermanent: true });
|
|
67
|
+
|
|
68
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455 §1.3
|
|
69
|
+
|
|
70
|
+
var DEFAULT_PING_MS = C.TIME.seconds(30);
|
|
71
|
+
var DEFAULT_PONG_MS = C.TIME.seconds(60);
|
|
72
|
+
var DEFAULT_MAX_BYTES = C.BYTES.mib(8);
|
|
73
|
+
var DEFAULT_MAX_FRAME = C.BYTES.mib(8);
|
|
74
|
+
var DEFAULT_HANDSHAKE_TIMEOUT_MS = C.TIME.seconds(15);
|
|
75
|
+
var DEFAULT_RECONNECT_BASE_MS = C.TIME.seconds(1) / 2;
|
|
76
|
+
var DEFAULT_RECONNECT_MAX_MS = C.TIME.seconds(30);
|
|
77
|
+
var DEFAULT_RECONNECT_MAX_ATTEMPTS = 10;
|
|
78
|
+
|
|
79
|
+
var OPCODE_CONT = 0x00; // allow:raw-byte-literal — RFC 6455 opcode
|
|
80
|
+
var OPCODE_TEXT = 0x01; // allow:raw-byte-literal — RFC 6455 opcode
|
|
81
|
+
var OPCODE_BINARY = 0x02; // allow:raw-byte-literal — RFC 6455 opcode
|
|
82
|
+
var OPCODE_CLOSE = 0x08; // allow:raw-byte-literal — RFC 6455 opcode
|
|
83
|
+
var OPCODE_PING = 0x09; // allow:raw-byte-literal — RFC 6455 opcode
|
|
84
|
+
var OPCODE_PONG = 0x0A; // allow:raw-byte-literal — RFC 6455 opcode
|
|
85
|
+
|
|
86
|
+
var CLOSE_NORMAL = 1000; // allow:raw-byte-literal — RFC 6455 close code
|
|
87
|
+
var CLOSE_GOING_AWAY = 1001; // allow:raw-byte-literal — RFC 6455 close code
|
|
88
|
+
var CLOSE_ABNORMAL = 1006; // allow:raw-byte-literal — RFC 6455 close code (synthetic — never on wire)
|
|
89
|
+
|
|
90
|
+
// Permanent vs transient error classifier — used by reconnect logic
|
|
91
|
+
// so client doesn't hammer the server on credentials / handshake
|
|
92
|
+
// errors that won't resolve by retrying.
|
|
93
|
+
var PERMANENT_CODES = {
|
|
94
|
+
"ws-client/bad-url": true,
|
|
95
|
+
"ws-client/bad-status": true, // HTTP 4xx during handshake
|
|
96
|
+
"ws-client/accept-mismatch": true,
|
|
97
|
+
"ws-client/bad-upgrade": true,
|
|
98
|
+
"ws-client/bad-status-line": true,
|
|
99
|
+
"ws-client/bad-subprotocol": true,
|
|
100
|
+
"ws-client/bad-header": true,
|
|
101
|
+
"ws-client/handshake-too-large": true,
|
|
102
|
+
"ws-client/control-too-big": true,
|
|
103
|
+
"ws-client/control-fragmented": true,
|
|
104
|
+
"ws-client/protocol-error": true,
|
|
105
|
+
"ws-client/invalid-utf8": true,
|
|
106
|
+
"ws-client/rsv1-on-continuation": true,
|
|
107
|
+
"ws-client/message-too-big": true,
|
|
108
|
+
"ws-client/deflate-error": true,
|
|
109
|
+
"ws-client/payload-too-big": true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function _isPermanentError(err) {
|
|
113
|
+
if (!err) return false;
|
|
114
|
+
if (err.permanent === true) return true; // defineClass-marked
|
|
115
|
+
if (err.code && PERMANENT_CODES[err.code]) return true;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Synchronous bounded inflate — runs zlib.inflateRawSync with
|
|
120
|
+
// `maxOutputLength` set to maxBytes + 1, so Node aborts the inflate
|
|
121
|
+
// when the output would exceed the cap. Defends against a malicious
|
|
122
|
+
// server sending a tiny compressed frame that expands to GBs.
|
|
123
|
+
function _inflateRawCappedSync(zlib, compressed, maxBytes, windowBits) {
|
|
124
|
+
try {
|
|
125
|
+
return zlib.inflateRawSync(compressed, {
|
|
126
|
+
maxOutputLength: maxBytes + 1,
|
|
127
|
+
windowBits: windowBits,
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e && (e.code === "ERR_BUFFER_TOO_LARGE" ||
|
|
131
|
+
/maxOutputLength|maxOutput/.test(String(e.message)))) {
|
|
132
|
+
throw new WsClientError("ws-client/decompression-bomb",
|
|
133
|
+
"decompression-bomb defense: inflated output > " + maxBytes + " bytes");
|
|
134
|
+
}
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _generateKey() {
|
|
140
|
+
return fwCrypto().generateBytes(C.BYTES.bytes(16)).toString("base64");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _expectedAccept(secKey, handshakeGuid) {
|
|
144
|
+
return nodeCrypto.createHash("sha1").update(secKey + (handshakeGuid || WS_GUID)).digest("base64");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _parseUrl(target) {
|
|
148
|
+
var parsed;
|
|
149
|
+
try { parsed = new url.URL(target); }
|
|
150
|
+
catch (e) {
|
|
151
|
+
throw new WsClientError("ws-client/bad-url",
|
|
152
|
+
"wsClient.connect: url is malformed - " + e.message);
|
|
153
|
+
}
|
|
154
|
+
var proto = parsed.protocol;
|
|
155
|
+
if (proto !== "ws:" && proto !== "wss:") {
|
|
156
|
+
throw new WsClientError("ws-client/bad-url",
|
|
157
|
+
"wsClient.connect: url must start with ws:// or wss:// - got " + JSON.stringify(proto));
|
|
158
|
+
}
|
|
159
|
+
return parsed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function connect(target, opts) {
|
|
163
|
+
opts = opts || {};
|
|
164
|
+
validateOpts(opts, [
|
|
165
|
+
"subprotocols", "headers", "tlsOpts", "pingMs", "pongMs",
|
|
166
|
+
"maxMessageBytes", "maxFrameBytes",
|
|
167
|
+
"handshakeTimeoutMs", "reconnect",
|
|
168
|
+
"permessageDeflate", "audit", "origin",
|
|
169
|
+
"handshakeGuid", "allowInternal",
|
|
170
|
+
"parse", "parser",
|
|
171
|
+
"urlFor", "tlsOptsFor",
|
|
172
|
+
], "wsClient.connect");
|
|
173
|
+
|
|
174
|
+
// Per-dial state-injection callbacks. Both fire on every connect
|
|
175
|
+
// attempt (initial + each reconnect) so operators can rotate
|
|
176
|
+
// bearer tokens, DNS targets, or TLS material between hops without
|
|
177
|
+
// tearing the client down.
|
|
178
|
+
// urlFor(attempt) -> string — overrides target URL
|
|
179
|
+
// tlsOptsFor(attempt) -> object — overrides TLS opts
|
|
180
|
+
// attempt is a 0-based hop index; 0 is the initial dial.
|
|
181
|
+
if (opts.urlFor != null && typeof opts.urlFor !== "function") {
|
|
182
|
+
throw new WsClientError("ws-client/bad-url-for",
|
|
183
|
+
"wsClient.connect: urlFor must be a function");
|
|
184
|
+
}
|
|
185
|
+
if (opts.tlsOptsFor != null && typeof opts.tlsOptsFor !== "function") {
|
|
186
|
+
throw new WsClientError("ws-client/bad-tls-opts-for",
|
|
187
|
+
"wsClient.connect: tlsOptsFor must be a function");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Operators with a non-RFC-6455 GUID (private protocols on top of
|
|
191
|
+
// the WebSocket framing layer, framework-specific handshake variants)
|
|
192
|
+
// pass a custom handshakeGuid. The server-side b.websocket already
|
|
193
|
+
// supports it; this is the symmetric client-side knob. Defaults to
|
|
194
|
+
// the RFC 6455 §1.3 GUID `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`.
|
|
195
|
+
var handshakeGuid = WS_GUID;
|
|
196
|
+
if (opts.handshakeGuid != null) {
|
|
197
|
+
validateOpts.requireNonEmptyString(opts.handshakeGuid,
|
|
198
|
+
"wsClient.connect: handshakeGuid", WsClientError, "ws-client/bad-handshake-guid");
|
|
199
|
+
handshakeGuid = opts.handshakeGuid;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var parsed = _parseUrl(target);
|
|
203
|
+
|
|
204
|
+
var subprotocols = Array.isArray(opts.subprotocols) ? opts.subprotocols.slice() : [];
|
|
205
|
+
for (var sp = 0; sp < subprotocols.length; sp += 1) {
|
|
206
|
+
if (typeof subprotocols[sp] !== "string" || subprotocols[sp].length === 0) {
|
|
207
|
+
throw new WsClientError("ws-client/bad-subprotocol",
|
|
208
|
+
"wsClient.connect: subprotocols[" + sp + "] must be a non-empty string");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
var pingMs = (typeof opts.pingMs === "number" && opts.pingMs > 0) // allow:numeric-opt-Infinity
|
|
212
|
+
? opts.pingMs : DEFAULT_PING_MS;
|
|
213
|
+
var pongMs = (typeof opts.pongMs === "number" && opts.pongMs > 0) // allow:numeric-opt-Infinity
|
|
214
|
+
? opts.pongMs : DEFAULT_PONG_MS;
|
|
215
|
+
var maxMessageBytes = (typeof opts.maxMessageBytes === "number" && opts.maxMessageBytes > 0) // allow:numeric-opt-Infinity
|
|
216
|
+
? opts.maxMessageBytes : DEFAULT_MAX_BYTES;
|
|
217
|
+
var maxFrameBytes = (typeof opts.maxFrameBytes === "number" && opts.maxFrameBytes > 0) // allow:numeric-opt-Infinity
|
|
218
|
+
? opts.maxFrameBytes : DEFAULT_MAX_FRAME;
|
|
219
|
+
var handshakeTimeoutMs = (typeof opts.handshakeTimeoutMs === "number" && opts.handshakeTimeoutMs > 0) // allow:numeric-opt-Infinity
|
|
220
|
+
? opts.handshakeTimeoutMs : DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
221
|
+
|
|
222
|
+
var reconnectOpts = _normaliseReconnect(opts.reconnect);
|
|
223
|
+
var permessageDeflate = opts.permessageDeflate !== false;
|
|
224
|
+
var auditOn = opts.audit !== false;
|
|
225
|
+
|
|
226
|
+
var client = new WsClient({
|
|
227
|
+
target: target,
|
|
228
|
+
parsedUrl: parsed,
|
|
229
|
+
subprotocols: subprotocols,
|
|
230
|
+
headers: opts.headers || {},
|
|
231
|
+
tlsOpts: opts.tlsOpts || null,
|
|
232
|
+
origin: opts.origin || null,
|
|
233
|
+
pingMs: pingMs,
|
|
234
|
+
pongMs: pongMs,
|
|
235
|
+
maxMessageBytes: maxMessageBytes,
|
|
236
|
+
maxFrameBytes: maxFrameBytes,
|
|
237
|
+
handshakeTimeoutMs: handshakeTimeoutMs,
|
|
238
|
+
reconnectOpts: reconnectOpts,
|
|
239
|
+
permessageDeflate: permessageDeflate,
|
|
240
|
+
auditOn: auditOn,
|
|
241
|
+
handshakeGuid: handshakeGuid,
|
|
242
|
+
allowInternal: opts.allowInternal,
|
|
243
|
+
parse: opts.parse || null,
|
|
244
|
+
parser: typeof opts.parser === "function" ? opts.parser : null,
|
|
245
|
+
urlFor: typeof opts.urlFor === "function" ? opts.urlFor : null,
|
|
246
|
+
tlsOptsFor: typeof opts.tlsOptsFor === "function" ? opts.tlsOptsFor : null,
|
|
247
|
+
});
|
|
248
|
+
// SSRF gate — refuse private / loopback / link-local / cloud-metadata /
|
|
249
|
+
// reserved IP destinations by default. Symmetric to b.httpClient. The
|
|
250
|
+
// returned `ips` are pinned through tls.connect / net.connect so the
|
|
251
|
+
// actual TCP connect targets the validated address (closes the DNS-
|
|
252
|
+
// rebinding TOCTOU window). Cloud-metadata IPs are unconditional
|
|
253
|
+
// hard-deny — `allowInternal: true` does not bypass them.
|
|
254
|
+
var hostnameForUrl = parsed.protocol === "wss:" ? "https:" : "http:";
|
|
255
|
+
var probeUrl = new url.URL(hostnameForUrl + "//" + parsed.host + parsed.pathname + parsed.search);
|
|
256
|
+
ssrfGuard.checkUrl(probeUrl, {
|
|
257
|
+
allowInternal: opts.allowInternal,
|
|
258
|
+
errorClass: WsClientError,
|
|
259
|
+
}).then(function (result) {
|
|
260
|
+
client._ssrfPinnedIps = result && result.ips;
|
|
261
|
+
client._dial();
|
|
262
|
+
}).catch(function (e) {
|
|
263
|
+
setImmediate(function () { client._handleSocketError(e); });
|
|
264
|
+
});
|
|
265
|
+
return client;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _normaliseReconnect(input) {
|
|
269
|
+
if (input === false || input == null) {
|
|
270
|
+
return { enabled: false, maxAttempts: 0,
|
|
271
|
+
baseMs: DEFAULT_RECONNECT_BASE_MS,
|
|
272
|
+
maxMs: DEFAULT_RECONNECT_MAX_MS };
|
|
273
|
+
}
|
|
274
|
+
if (typeof input !== "object") {
|
|
275
|
+
throw new WsClientError("ws-client/bad-reconnect",
|
|
276
|
+
"wsClient.connect: reconnect must be false / null / object");
|
|
277
|
+
}
|
|
278
|
+
validateOpts(input, ["maxAttempts", "baseMs", "maxMs", "enabled"], "wsClient.connect.reconnect");
|
|
279
|
+
return {
|
|
280
|
+
enabled: input.enabled !== false,
|
|
281
|
+
maxAttempts: (typeof input.maxAttempts === "number" && input.maxAttempts >= 0) // allow:numeric-opt-Infinity
|
|
282
|
+
? input.maxAttempts : DEFAULT_RECONNECT_MAX_ATTEMPTS,
|
|
283
|
+
baseMs: (typeof input.baseMs === "number" && input.baseMs > 0) // allow:numeric-opt-Infinity
|
|
284
|
+
? input.baseMs : DEFAULT_RECONNECT_BASE_MS,
|
|
285
|
+
maxMs: (typeof input.maxMs === "number" && input.maxMs > 0) // allow:numeric-opt-Infinity
|
|
286
|
+
? input.maxMs : DEFAULT_RECONNECT_MAX_MS,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
class WsClient extends EventEmitter {
|
|
291
|
+
constructor(opts) {
|
|
292
|
+
super();
|
|
293
|
+
this._opts = opts;
|
|
294
|
+
this._socket = null;
|
|
295
|
+
this._parser = null;
|
|
296
|
+
this._readyState = "connecting";
|
|
297
|
+
this._reconnectAttempt = 0;
|
|
298
|
+
this._reconnectTimer = null;
|
|
299
|
+
this._handshakeTimer = null;
|
|
300
|
+
this._pingTimer = null;
|
|
301
|
+
this._pongDeadline = 0;
|
|
302
|
+
this._fragmentChunks = [];
|
|
303
|
+
this._fragmentOpcode = null;
|
|
304
|
+
this._fragmentRsv1 = false;
|
|
305
|
+
this._closed = false;
|
|
306
|
+
this._negotiatedSubprotocol = null;
|
|
307
|
+
this._negotiatedDeflate = false;
|
|
308
|
+
this._negotiatedWindowBits = 15; // RFC 7692 default
|
|
309
|
+
this._bytesSent = 0;
|
|
310
|
+
this._bytesReceived = 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
get readyState() { return this._readyState; }
|
|
314
|
+
get subprotocol() { return this._negotiatedSubprotocol; }
|
|
315
|
+
get url() { return this._opts.target; }
|
|
316
|
+
|
|
317
|
+
_dial() {
|
|
318
|
+
var self = this;
|
|
319
|
+
var opts = this._opts;
|
|
320
|
+
this._readyState = "connecting";
|
|
321
|
+
|
|
322
|
+
// Per-dial overrides — urlFor swaps the target URL, tlsOptsFor
|
|
323
|
+
// overrides TLS material. Both fire every dial including reconnects
|
|
324
|
+
// so callers can rotate state. urlFor's result is re-validated
|
|
325
|
+
// through ssrfGuard so a hostile upstream can't direct the client
|
|
326
|
+
// at a private address mid-reconnect.
|
|
327
|
+
var attempt = this._reconnectAttempt || 0;
|
|
328
|
+
var dialTarget = opts.target;
|
|
329
|
+
var dialParsed = opts.parsedUrl;
|
|
330
|
+
if (typeof opts.urlFor === "function") {
|
|
331
|
+
try {
|
|
332
|
+
var nextTarget = opts.urlFor(attempt);
|
|
333
|
+
if (typeof nextTarget === "string" && nextTarget.length > 0 && nextTarget !== dialTarget) {
|
|
334
|
+
dialParsed = _parseUrl(nextTarget);
|
|
335
|
+
dialTarget = nextTarget;
|
|
336
|
+
var probeProto = dialParsed.protocol === "wss:" ? "https:" : "http:";
|
|
337
|
+
var probeUrl = new url.URL(probeProto + "//" + dialParsed.host + dialParsed.pathname + dialParsed.search);
|
|
338
|
+
var probe = ssrfGuard.checkUrl(probeUrl, {
|
|
339
|
+
allowInternal: opts.allowInternal,
|
|
340
|
+
errorClass: WsClientError,
|
|
341
|
+
});
|
|
342
|
+
this._ssrfPinnedIps = probe && probe.ips ? probe.ips : null;
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return self._handleSocketError(e);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
var dialTlsOpts = opts.tlsOpts;
|
|
350
|
+
if (typeof opts.tlsOptsFor === "function") {
|
|
351
|
+
try {
|
|
352
|
+
var override = opts.tlsOptsFor(attempt);
|
|
353
|
+
if (override && typeof override === "object") {
|
|
354
|
+
dialTlsOpts = Object.assign({}, opts.tlsOpts || {}, override);
|
|
355
|
+
}
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return self._handleSocketError(e);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
var parsed = dialParsed;
|
|
362
|
+
var port = parsed.port ? parseInt(parsed.port, 10) :
|
|
363
|
+
(parsed.protocol === "wss:" ? 443 : 80); // allow:raw-byte-literal — TLS / HTTP default port
|
|
364
|
+
var host = parsed.hostname;
|
|
365
|
+
|
|
366
|
+
function _onError(err) { self._handleSocketError(err); }
|
|
367
|
+
|
|
368
|
+
// Pin the connect to the SSRF-validated IPs returned by
|
|
369
|
+
// ssrfGuard.checkUrl — closes the DNS-rebinding TOCTOU window where
|
|
370
|
+
// the gate resolves a public IP and the kernel re-resolves to a
|
|
371
|
+
// private one between the check and the connect.
|
|
372
|
+
var pinnedIps = self._ssrfPinnedIps || null;
|
|
373
|
+
var lookup = null;
|
|
374
|
+
if (pinnedIps && pinnedIps.length > 0) {
|
|
375
|
+
lookup = function (h, lookupOpts, cb) {
|
|
376
|
+
// Node's lookup callback signatures:
|
|
377
|
+
// (err, address, family) for legacy { all: false } (default)
|
|
378
|
+
// (err, addresses) for { all: true }
|
|
379
|
+
if (typeof lookupOpts === "function") { cb = lookupOpts; lookupOpts = {}; }
|
|
380
|
+
var first = pinnedIps[0];
|
|
381
|
+
if (lookupOpts && lookupOpts.all) {
|
|
382
|
+
return cb(null, pinnedIps.map(function (ip) {
|
|
383
|
+
return { address: ip.address, family: ip.family };
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
return cb(null, first.address, first.family);
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
var socket;
|
|
391
|
+
if (parsed.protocol === "wss:") {
|
|
392
|
+
var tls = require("tls"); // allow:inline-require — node:tls only on TLS path
|
|
393
|
+
var tlsOpts = Object.assign({
|
|
394
|
+
host: host,
|
|
395
|
+
port: port,
|
|
396
|
+
servername: host,
|
|
397
|
+
rejectUnauthorized: true,
|
|
398
|
+
minVersion: "TLSv1.3",
|
|
399
|
+
}, dialTlsOpts || {});
|
|
400
|
+
if (lookup) tlsOpts.lookup = lookup;
|
|
401
|
+
try {
|
|
402
|
+
var pqcShares = networkTls().pqc.getKeyShares();
|
|
403
|
+
if (Array.isArray(pqcShares) && pqcShares.length > 0 && !tlsOpts.curves) {
|
|
404
|
+
tlsOpts.curves = pqcShares.join(":");
|
|
405
|
+
}
|
|
406
|
+
} catch (_e) { /* drop-silent — tls module pre-init or non-Node */ }
|
|
407
|
+
socket = tls.connect(tlsOpts);
|
|
408
|
+
} else {
|
|
409
|
+
var netOpts = { host: host, port: port };
|
|
410
|
+
if (lookup) netOpts.lookup = lookup;
|
|
411
|
+
socket = net.connect(netOpts);
|
|
412
|
+
}
|
|
413
|
+
this._socket = socket;
|
|
414
|
+
socket.on("error", _onError);
|
|
415
|
+
|
|
416
|
+
var connectEvent = parsed.protocol === "wss:" ? "secureConnect" : "connect";
|
|
417
|
+
socket.once(connectEvent, function () {
|
|
418
|
+
try { self._sendHandshake(); }
|
|
419
|
+
catch (e) { self._handleSocketError(e); }
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
this._handshakeTimer = setTimeout(function () {
|
|
423
|
+
self._handleSocketError(new WsClientError("ws-client/handshake-timeout",
|
|
424
|
+
"Handshake exceeded " + opts.handshakeTimeoutMs + "ms"));
|
|
425
|
+
}, opts.handshakeTimeoutMs);
|
|
426
|
+
if (typeof this._handshakeTimer.unref === "function") this._handshakeTimer.unref();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_sendHandshake() {
|
|
430
|
+
var opts = this._opts;
|
|
431
|
+
var self = this;
|
|
432
|
+
var parsed = opts.parsedUrl;
|
|
433
|
+
var key = _generateKey();
|
|
434
|
+
this._secKey = key;
|
|
435
|
+
|
|
436
|
+
var hostHeader = parsed.host;
|
|
437
|
+
var pathStr = parsed.pathname + (parsed.search || "");
|
|
438
|
+
if (!pathStr) pathStr = "/";
|
|
439
|
+
|
|
440
|
+
var lines = [
|
|
441
|
+
"GET " + pathStr + " HTTP/1.1",
|
|
442
|
+
"Host: " + hostHeader,
|
|
443
|
+
"Upgrade: websocket",
|
|
444
|
+
"Connection: Upgrade",
|
|
445
|
+
"Sec-WebSocket-Key: " + key,
|
|
446
|
+
"Sec-WebSocket-Version: 13", // allow:raw-byte-literal — RFC 6455 §1.9
|
|
447
|
+
];
|
|
448
|
+
if (opts.origin) {
|
|
449
|
+
if (safeBuffer.hasCrlf(opts.origin)) {
|
|
450
|
+
throw new WsClientError("ws-client/bad-header",
|
|
451
|
+
"Origin header value contains CR/LF (injection refused)");
|
|
452
|
+
}
|
|
453
|
+
lines.push("Origin: " + opts.origin);
|
|
454
|
+
}
|
|
455
|
+
if (opts.subprotocols.length > 0) {
|
|
456
|
+
lines.push("Sec-WebSocket-Protocol: " + opts.subprotocols.join(", "));
|
|
457
|
+
}
|
|
458
|
+
if (opts.permessageDeflate) {
|
|
459
|
+
lines.push("Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits");
|
|
460
|
+
}
|
|
461
|
+
var customHeaders = opts.headers || {};
|
|
462
|
+
var forbidden = ["host", "upgrade", "connection", "sec-websocket-key",
|
|
463
|
+
"sec-websocket-version", "sec-websocket-protocol",
|
|
464
|
+
"sec-websocket-extensions", "sec-websocket-accept"];
|
|
465
|
+
for (var hkey in customHeaders) {
|
|
466
|
+
if (!Object.prototype.hasOwnProperty.call(customHeaders, hkey)) continue;
|
|
467
|
+
if (forbidden.indexOf(hkey.toLowerCase()) !== -1) continue;
|
|
468
|
+
var v = customHeaders[hkey];
|
|
469
|
+
if (typeof v !== "string") continue;
|
|
470
|
+
if (safeBuffer.hasCrlf(v)) {
|
|
471
|
+
throw new WsClientError("ws-client/bad-header",
|
|
472
|
+
"header " + JSON.stringify(hkey) + ": value contains CR/LF (injection refused)");
|
|
473
|
+
}
|
|
474
|
+
lines.push(hkey + ": " + v);
|
|
475
|
+
}
|
|
476
|
+
lines.push("");
|
|
477
|
+
lines.push("");
|
|
478
|
+
var request = lines.join("\r\n");
|
|
479
|
+
this._handshakeBuf = Buffer.alloc(0);
|
|
480
|
+
this._socket.write(request);
|
|
481
|
+
this._socket.on("data", function (chunk) {
|
|
482
|
+
if (self._readyState === "connecting") {
|
|
483
|
+
self._consumeHandshake(chunk);
|
|
484
|
+
} else {
|
|
485
|
+
self._consumeFrames(chunk);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
_consumeHandshake(chunk) {
|
|
491
|
+
// allow:handrolled-buffer-collect — handshake header capped at 64 KiB below; once handshake parses we switch to FrameParser
|
|
492
|
+
this._handshakeBuf = Buffer.concat([this._handshakeBuf, chunk]);
|
|
493
|
+
var headerEnd = this._handshakeBuf.indexOf("\r\n\r\n");
|
|
494
|
+
if (headerEnd === -1) {
|
|
495
|
+
if (this._handshakeBuf.length > C.BYTES.kib(64)) {
|
|
496
|
+
this._handleSocketError(new WsClientError("ws-client/handshake-too-large",
|
|
497
|
+
"handshake response exceeded 64 KiB before CRLFCRLF"));
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
var headerSection = this._handshakeBuf.subarray(0, headerEnd).toString("utf8");
|
|
502
|
+
var rest = this._handshakeBuf.subarray(headerEnd + 4);
|
|
503
|
+
|
|
504
|
+
var lines = headerSection.split("\r\n");
|
|
505
|
+
var statusLine = lines[0] || "";
|
|
506
|
+
var match = statusLine.match(/^HTTP\/1\.\d (\d{3})/);
|
|
507
|
+
if (!match) {
|
|
508
|
+
this._handleSocketError(new WsClientError("ws-client/bad-status-line",
|
|
509
|
+
"handshake response status line malformed: " + JSON.stringify(statusLine)));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
var status = parseInt(match[1], 10);
|
|
513
|
+
if (status !== 101) { // allow:raw-byte-literal — HTTP 101
|
|
514
|
+
// Body bytes after the header section are the server's
|
|
515
|
+
// explanation. Surface them on the error so callers can branch
|
|
516
|
+
// on the status code and inspect the body without re-parsing
|
|
517
|
+
// the message string.
|
|
518
|
+
var bodyText = "";
|
|
519
|
+
try { bodyText = rest.toString("utf8"); } catch (_e) { /* drop-silent */ }
|
|
520
|
+
var statusErr = new WsClientError("ws-client/bad-status",
|
|
521
|
+
"handshake response status was " + status + " (expected 101 Switching Protocols)");
|
|
522
|
+
statusErr.status = status;
|
|
523
|
+
statusErr.body = bodyText;
|
|
524
|
+
this._handleSocketError(statusErr);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
var headers = Object.create(null);
|
|
529
|
+
for (var i = 1; i < lines.length; i += 1) {
|
|
530
|
+
var idx = lines[i].indexOf(":");
|
|
531
|
+
if (idx === -1) continue;
|
|
532
|
+
var hkey = lines[i].slice(0, idx).trim().toLowerCase();
|
|
533
|
+
var hval = lines[i].slice(idx + 1).trim();
|
|
534
|
+
headers[hkey] = hval;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if ((headers["upgrade"] || "").toLowerCase() !== "websocket" ||
|
|
538
|
+
(headers["connection"] || "").toLowerCase().indexOf("upgrade") === -1) {
|
|
539
|
+
this._handleSocketError(new WsClientError("ws-client/bad-upgrade",
|
|
540
|
+
"handshake response missing Upgrade: websocket / Connection: Upgrade"));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
var accept = headers["sec-websocket-accept"] || "";
|
|
545
|
+
var expected = _expectedAccept(this._secKey, this._opts.handshakeGuid);
|
|
546
|
+
if (accept !== expected) {
|
|
547
|
+
this._handleSocketError(new WsClientError("ws-client/accept-mismatch",
|
|
548
|
+
"handshake response Sec-WebSocket-Accept mismatch: peer responded with a key " +
|
|
549
|
+
"that does not match the SHA-1(key+RFC-6455-GUID) hash"));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
var negotiatedSubprotocol = headers["sec-websocket-protocol"] || null;
|
|
554
|
+
if (negotiatedSubprotocol && this._opts.subprotocols.indexOf(negotiatedSubprotocol) === -1) {
|
|
555
|
+
this._handleSocketError(new WsClientError("ws-client/bad-subprotocol",
|
|
556
|
+
"server selected subprotocol " + JSON.stringify(negotiatedSubprotocol) +
|
|
557
|
+
" not in client offer"));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this._negotiatedSubprotocol = negotiatedSubprotocol;
|
|
561
|
+
|
|
562
|
+
this._negotiatedDeflate = false;
|
|
563
|
+
this._negotiatedWindowBits = 15; // allow:raw-byte-literal — RFC 7692 default windowBits
|
|
564
|
+
if (this._opts.permessageDeflate &&
|
|
565
|
+
(headers["sec-websocket-extensions"] || "").indexOf("permessage-deflate") !== -1) {
|
|
566
|
+
this._negotiatedDeflate = true;
|
|
567
|
+
// Parse server_max_window_bits from the response — server may
|
|
568
|
+
// narrow the window vs the default 15. We honour anything in
|
|
569
|
+
// [8, 15]; outside that range we treat the response as malformed
|
|
570
|
+
// and refuse the extension (RFC 7692 §7.1.2.1).
|
|
571
|
+
var extLine = headers["sec-websocket-extensions"];
|
|
572
|
+
var smwbMatch = extLine.match(/server_max_window_bits\s*=\s*"?(\d+)"?/); // allow:regex-no-length-cap — bounded by header line + RFC 7692 §7.1
|
|
573
|
+
if (smwbMatch) {
|
|
574
|
+
var smwb = parseInt(smwbMatch[1], 10);
|
|
575
|
+
if (smwb < 8 || smwb > 15) { // allow:raw-byte-literal — RFC 7692 windowBits range
|
|
576
|
+
this._handleSocketError(new WsClientError("ws-client/deflate-error",
|
|
577
|
+
"server_max_window_bits=" + smwb + " is outside RFC 7692 range [8, 15]"));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this._negotiatedWindowBits = smwb;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (this._handshakeTimer) {
|
|
585
|
+
clearTimeout(this._handshakeTimer);
|
|
586
|
+
this._handshakeTimer = null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
var fp = websocket().FrameParser;
|
|
590
|
+
this._parser = new fp({ maxFrameBytes: this._opts.maxFrameBytes });
|
|
591
|
+
this._readyState = "open";
|
|
592
|
+
this._reconnectAttempt = 0;
|
|
593
|
+
this._fragmentChunks = [];
|
|
594
|
+
this._fragmentOpcode = null;
|
|
595
|
+
|
|
596
|
+
this._startHeartbeat();
|
|
597
|
+
if (this._opts.auditOn) {
|
|
598
|
+
try {
|
|
599
|
+
audit().safeEmit({
|
|
600
|
+
action: "wsclient.connected",
|
|
601
|
+
outcome: "success",
|
|
602
|
+
actor: null,
|
|
603
|
+
metadata: {
|
|
604
|
+
host: this._opts.parsedUrl.host,
|
|
605
|
+
subprotocol: negotiatedSubprotocol,
|
|
606
|
+
deflate: this._negotiatedDeflate,
|
|
607
|
+
serverWindowBits: this._negotiatedWindowBits,
|
|
608
|
+
tls: this._opts.parsedUrl.protocol === "wss:",
|
|
609
|
+
attempt: this._reconnectAttempt,
|
|
610
|
+
peerCertFingerprint: this._captureCertFingerprint(),
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
} catch (_e) { /* drop-silent */ }
|
|
614
|
+
}
|
|
615
|
+
this.emit("open");
|
|
616
|
+
|
|
617
|
+
if (rest.length > 0) this._consumeFrames(rest);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
_consumeFrames(chunk) {
|
|
621
|
+
if (!this._parser) return;
|
|
622
|
+
try {
|
|
623
|
+
var frames = this._parser.push(chunk) || [];
|
|
624
|
+
for (var fi = 0; fi < frames.length; fi += 1) {
|
|
625
|
+
this._handleFrame(frames[fi]);
|
|
626
|
+
}
|
|
627
|
+
} catch (e) {
|
|
628
|
+
this._handleSocketError(e);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
_handleFrame(frame) {
|
|
633
|
+
// RFC 6455 §5.5: control frames MUST be <= 125 bytes AND non-fragmented.
|
|
634
|
+
var isControl = frame.opcode === OPCODE_PING ||
|
|
635
|
+
frame.opcode === OPCODE_PONG ||
|
|
636
|
+
frame.opcode === OPCODE_CLOSE;
|
|
637
|
+
if (isControl) {
|
|
638
|
+
if (frame.payload.length > 125) { // allow:raw-byte-literal — RFC 6455 §5.5 control-frame cap
|
|
639
|
+
this._handleSocketError(new WsClientError("ws-client/control-too-big",
|
|
640
|
+
"control-frame payload exceeds 125 bytes (RFC 6455 §5.5)"));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (frame.fin === false) {
|
|
644
|
+
this._handleSocketError(new WsClientError("ws-client/control-fragmented",
|
|
645
|
+
"control frame must have FIN=1 (RFC 6455 §5.5)"));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Continuation frames MUST NOT carry rsv1 (RFC 7692 §6.1) — only
|
|
650
|
+
// the first frame of a compressed message sets rsv1.
|
|
651
|
+
if (frame.opcode === OPCODE_CONT && frame.rsv1) {
|
|
652
|
+
this._handleSocketError(new WsClientError("ws-client/rsv1-on-continuation",
|
|
653
|
+
"RSV1 set on continuation frame (RFC 7692 §6.1)"));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (frame.opcode === OPCODE_PING) {
|
|
657
|
+
this._sendFrame(OPCODE_PONG, frame.payload, { fin: true });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (frame.opcode === OPCODE_PONG) {
|
|
661
|
+
this._pongDeadline = Date.now() + this._opts.pongMs;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (frame.opcode === OPCODE_CLOSE) {
|
|
665
|
+
var code = CLOSE_NORMAL, reason = "";
|
|
666
|
+
if (frame.payload.length >= 2) {
|
|
667
|
+
code = frame.payload.readUInt16BE(0);
|
|
668
|
+
var reasonBytes = frame.payload.subarray(2); // allow:raw-byte-literal — RFC 6455 close-frame layout
|
|
669
|
+
try {
|
|
670
|
+
reason = new TextDecoder("utf-8", { fatal: true }).decode(reasonBytes);
|
|
671
|
+
} catch (_e) {
|
|
672
|
+
this._handleSocketError(new WsClientError("ws-client/invalid-utf8",
|
|
673
|
+
"close-frame reason is not valid UTF-8 (RFC 6455 §5.6 + §7.4.1)"));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
this._readyState = "closing";
|
|
678
|
+
this._sendFrame(OPCODE_CLOSE, frame.payload, { fin: true });
|
|
679
|
+
this._teardown(code, reason, false);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (frame.opcode === OPCODE_TEXT || frame.opcode === OPCODE_BINARY) {
|
|
683
|
+
if (this._fragmentOpcode != null) {
|
|
684
|
+
this._handleSocketError(new WsClientError("ws-client/protocol-error",
|
|
685
|
+
"received non-continuation opcode mid-fragmented-message"));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this._fragmentOpcode = frame.opcode;
|
|
689
|
+
this._fragmentRsv1 = frame.rsv1 === true;
|
|
690
|
+
this._fragmentChunks = [frame.payload];
|
|
691
|
+
} else if (frame.opcode === OPCODE_CONT) {
|
|
692
|
+
if (this._fragmentOpcode == null) {
|
|
693
|
+
this._handleSocketError(new WsClientError("ws-client/protocol-error",
|
|
694
|
+
"received continuation opcode with no prior text/binary frame"));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
this._fragmentChunks.push(frame.payload);
|
|
698
|
+
}
|
|
699
|
+
if (frame.fin) {
|
|
700
|
+
var fullPayload = Buffer.concat(this._fragmentChunks); // allow:handrolled-buffer-collect — bounded by maxMessageBytes below
|
|
701
|
+
if (fullPayload.length > this._opts.maxMessageBytes) {
|
|
702
|
+
this._handleSocketError(new WsClientError("ws-client/message-too-big",
|
|
703
|
+
"incoming message exceeds maxMessageBytes (" + this._opts.maxMessageBytes + ")"));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
var isBinary = this._fragmentOpcode === OPCODE_BINARY;
|
|
707
|
+
var firstFrameRsv1 = this._fragmentRsv1 === true;
|
|
708
|
+
this._fragmentChunks = [];
|
|
709
|
+
this._fragmentOpcode = null;
|
|
710
|
+
this._fragmentRsv1 = false;
|
|
711
|
+
if (this._negotiatedDeflate && firstFrameRsv1) {
|
|
712
|
+
try {
|
|
713
|
+
var zlib = require("zlib"); // allow:inline-require — zlib only on deflate-negotiated path
|
|
714
|
+
var compressed = Buffer.concat([fullPayload, Buffer.from([0x00, 0x00, 0xff, 0xff])]); // allow:raw-byte-literal — RFC 7692 §7.2.2 deflate trailer
|
|
715
|
+
// Decompression-bomb defense: zlib.inflateRawSync's
|
|
716
|
+
// `maxOutputLength` aborts the inflate the moment the
|
|
717
|
+
// output would exceed maxMessageBytes — never decode GBs
|
|
718
|
+
// from a 100-byte compressed frame.
|
|
719
|
+
var inflated = _inflateRawCappedSync(zlib, compressed, this._opts.maxMessageBytes,
|
|
720
|
+
this._negotiatedWindowBits);
|
|
721
|
+
fullPayload = inflated;
|
|
722
|
+
} catch (e) {
|
|
723
|
+
this._handleSocketError(new WsClientError("ws-client/deflate-error",
|
|
724
|
+
"permessage-deflate inflate failed or exceeded maxMessageBytes: " + e.message));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
this._bytesReceived += fullPayload.length;
|
|
729
|
+
var data;
|
|
730
|
+
if (isBinary) {
|
|
731
|
+
data = fullPayload;
|
|
732
|
+
} else {
|
|
733
|
+
try {
|
|
734
|
+
data = new TextDecoder("utf-8", { fatal: true }).decode(fullPayload);
|
|
735
|
+
} catch (_e) {
|
|
736
|
+
this._handleSocketError(new WsClientError("ws-client/invalid-utf8",
|
|
737
|
+
"text frame is not valid UTF-8 (RFC 6455 §5.6)"));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Auto-parse text frames as JSON when the operator opted in via
|
|
742
|
+
// `parse: "json"` or supplied `parser: fn`. JSON-only protocols
|
|
743
|
+
// (most modern WS APIs) get a typed message argument without a
|
|
744
|
+
// wrapper layer; parse failures surface as 'error' events
|
|
745
|
+
// rather than crashing the message handler.
|
|
746
|
+
var parsed = data;
|
|
747
|
+
var parsedOk = true;
|
|
748
|
+
if (!isBinary && this._opts.parse === "json") {
|
|
749
|
+
try { parsed = safeJson().parse(data, { maxBytes: this._opts.maxMessageBytes }); }
|
|
750
|
+
catch (e) {
|
|
751
|
+
parsedOk = false;
|
|
752
|
+
this.emit("error", new WsClientError("ws-client/json-parse",
|
|
753
|
+
"text frame is not valid JSON: " + ((e && e.message) || String(e))));
|
|
754
|
+
}
|
|
755
|
+
} else if (!isBinary && typeof this._opts.parser === "function") {
|
|
756
|
+
try { parsed = this._opts.parser(data); }
|
|
757
|
+
catch (e) {
|
|
758
|
+
parsedOk = false;
|
|
759
|
+
this.emit("error", new WsClientError("ws-client/parser-failed",
|
|
760
|
+
"operator parser threw: " + ((e && e.message) || String(e))));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (parsedOk) this.emit("message", parsed, isBinary);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_sendFrame(opcode, payload, opts) {
|
|
768
|
+
if (!this._socket || this._socket.destroyed) return;
|
|
769
|
+
var serialize = websocket().serializeFrame;
|
|
770
|
+
var frame = serialize(opcode, payload, Object.assign({ mask: true }, opts || {}));
|
|
771
|
+
this._bytesSent += frame.length;
|
|
772
|
+
this._socket.write(frame);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
_captureCertFingerprint() {
|
|
776
|
+
try {
|
|
777
|
+
if (this._socket && typeof this._socket.getPeerCertificate === "function") {
|
|
778
|
+
var cert = this._socket.getPeerCertificate(false);
|
|
779
|
+
if (cert && cert.fingerprint256) return cert.fingerprint256;
|
|
780
|
+
}
|
|
781
|
+
} catch (_e) { /* drop-silent */ }
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
send(data, opts) {
|
|
786
|
+
if (this._readyState !== "open") {
|
|
787
|
+
throw new WsClientError("ws-client/not-open",
|
|
788
|
+
"send: socket is not open (readyState=" + this._readyState + ")");
|
|
789
|
+
}
|
|
790
|
+
opts = opts || {};
|
|
791
|
+
var isBinary = Buffer.isBuffer(data);
|
|
792
|
+
var payload;
|
|
793
|
+
if (isBinary) {
|
|
794
|
+
payload = data;
|
|
795
|
+
} else if (typeof data === "string") {
|
|
796
|
+
payload = Buffer.from(data, "utf8");
|
|
797
|
+
} else {
|
|
798
|
+
payload = Buffer.from(JSON.stringify(data), "utf8");
|
|
799
|
+
}
|
|
800
|
+
if (payload.length > this._opts.maxMessageBytes) {
|
|
801
|
+
throw new WsClientError("ws-client/payload-too-big",
|
|
802
|
+
"send: payload exceeds maxMessageBytes (" + this._opts.maxMessageBytes + ")");
|
|
803
|
+
}
|
|
804
|
+
this._sendFrame(isBinary ? OPCODE_BINARY : OPCODE_TEXT, payload, { fin: true });
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
ping(payload) {
|
|
808
|
+
if (this._readyState !== "open") return;
|
|
809
|
+
this._sendFrame(OPCODE_PING, payload || Buffer.alloc(0), { fin: true });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
close(code, reason) {
|
|
813
|
+
// Operator-initiated close cancels any pending reconnect — once
|
|
814
|
+
// close() is called, the operator wants this client retired.
|
|
815
|
+
this._cancelReconnect();
|
|
816
|
+
if (this._readyState === "closed" || this._readyState === "closing") {
|
|
817
|
+
// Even when already closed, ensure we mark as fully retired so
|
|
818
|
+
// a previously-scheduled reconnect can't fire after close().
|
|
819
|
+
this._closed = true;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
code = (typeof code === "number") ? code : CLOSE_NORMAL;
|
|
823
|
+
reason = (typeof reason === "string") ? reason : "";
|
|
824
|
+
// RFC 6455 §5.5: control frames must be <= 125 bytes total. The
|
|
825
|
+
// close payload is 2 status bytes + UTF-8 reason; that gives us
|
|
826
|
+
// 123 bytes for the reason. Truncate at the BYTE level rather
|
|
827
|
+
// than character level since a 123-byte UTF-8 sequence might end
|
|
828
|
+
// mid-codepoint — to be RFC-safe we truncate at code-point
|
|
829
|
+
// boundaries.
|
|
830
|
+
var rb = Buffer.from(reason, "utf8");
|
|
831
|
+
if (rb.length > 123) { // allow:raw-byte-literal — RFC 6455 §5.5 (125 - 2)
|
|
832
|
+
// Truncate at last complete codepoint within 123 bytes. Use a
|
|
833
|
+
// fatal TextDecoder to validate; back off one byte at a time
|
|
834
|
+
// until the slice decodes cleanly. Bounded by [123 - 3, 123]
|
|
835
|
+
// since a single UTF-8 codepoint is at most 4 bytes.
|
|
836
|
+
var fatal = new TextDecoder("utf-8", { fatal: true });
|
|
837
|
+
var truncated = rb.subarray(0, 123); // allow:raw-byte-literal — RFC 6455 §5.5
|
|
838
|
+
for (var bi = 0; bi < 4; bi += 1) { // allow:raw-byte-literal — max UTF-8 codepoint width
|
|
839
|
+
try { fatal.decode(truncated); break; }
|
|
840
|
+
catch (_e) { truncated = truncated.subarray(0, truncated.length - 1); }
|
|
841
|
+
}
|
|
842
|
+
rb = truncated;
|
|
843
|
+
}
|
|
844
|
+
var payload = Buffer.alloc(2 + rb.length); // allow:raw-byte-literal — RFC 6455 close-frame layout
|
|
845
|
+
payload.writeUInt16BE(code, 0);
|
|
846
|
+
rb.copy(payload, 2); // allow:raw-byte-literal — RFC 6455 close-frame layout
|
|
847
|
+
this._readyState = "closing";
|
|
848
|
+
this._sendFrame(OPCODE_CLOSE, payload, { fin: true });
|
|
849
|
+
var self = this;
|
|
850
|
+
setTimeout(function () { self._teardown(code, reason, false); }, 1000).unref(); // allow:raw-byte-literal — graceful close grace window
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
_teardown(code, reason, willReconnect) {
|
|
854
|
+
if (this._closed && !willReconnect) return;
|
|
855
|
+
this._closed = !willReconnect;
|
|
856
|
+
if (this._socket && !this._socket.destroyed) {
|
|
857
|
+
try { this._socket.destroy(); } catch (_e) { /* drop-silent */ }
|
|
858
|
+
}
|
|
859
|
+
if (this._pingTimer) { try { this._pingTimer.stop(); } catch (_e) { /* drop-silent */ } this._pingTimer = null; }
|
|
860
|
+
if (this._handshakeTimer) { clearTimeout(this._handshakeTimer); this._handshakeTimer = null; }
|
|
861
|
+
this._readyState = "closed";
|
|
862
|
+
this._parser = null;
|
|
863
|
+
this._fragmentChunks = [];
|
|
864
|
+
this._fragmentOpcode = null;
|
|
865
|
+
if (this._opts.auditOn) {
|
|
866
|
+
try {
|
|
867
|
+
audit().safeEmit({
|
|
868
|
+
action: "wsclient.closed",
|
|
869
|
+
outcome: "success",
|
|
870
|
+
actor: null,
|
|
871
|
+
metadata: { code: code, reason: reason, host: this._opts.parsedUrl.host },
|
|
872
|
+
});
|
|
873
|
+
} catch (_e) { /* drop-silent */ }
|
|
874
|
+
}
|
|
875
|
+
this.emit("close", code, reason);
|
|
876
|
+
if (willReconnect) this._scheduleReconnect();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
_handleSocketError(err) {
|
|
880
|
+
// Swallow post-close socket errors. After a consumer-initiated
|
|
881
|
+
// close() the framework waits for the server to send back its
|
|
882
|
+
// close frame; if the server tears down its end with an
|
|
883
|
+
// ECONNRESET / EPIPE / "premature close" instead of a graceful
|
|
884
|
+
// close frame, the underlying socket emits an error that bubbles
|
|
885
|
+
// up here. From the consumer's perspective the close already
|
|
886
|
+
// happened — surfacing a "socket error" event after `close` was
|
|
887
|
+
// called is noise that hides real bugs.
|
|
888
|
+
if (this._closed && err && (
|
|
889
|
+
err.code === "ECONNRESET" || err.code === "EPIPE" ||
|
|
890
|
+
err.code === "ECONNABORTED" || err.code === "ERR_STREAM_PREMATURE_CLOSE")) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
var permanent = _isPermanentError(err);
|
|
894
|
+
if (this._opts.auditOn) {
|
|
895
|
+
try {
|
|
896
|
+
audit().safeEmit({
|
|
897
|
+
action: "wsclient.error",
|
|
898
|
+
outcome: "failure",
|
|
899
|
+
actor: null,
|
|
900
|
+
metadata: {
|
|
901
|
+
host: this._opts.parsedUrl.host,
|
|
902
|
+
code: err && err.code || "unknown",
|
|
903
|
+
message: err && err.message || String(err),
|
|
904
|
+
attempt: this._reconnectAttempt,
|
|
905
|
+
bytesSent: this._bytesSent,
|
|
906
|
+
bytesReceived: this._bytesReceived,
|
|
907
|
+
permanent: permanent,
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
} catch (_e) { /* drop-silent */ }
|
|
911
|
+
}
|
|
912
|
+
this.emit("error", err);
|
|
913
|
+
var rOpts = this._opts.reconnectOpts;
|
|
914
|
+
var willReconnect = rOpts.enabled &&
|
|
915
|
+
!permanent &&
|
|
916
|
+
this._reconnectAttempt < rOpts.maxAttempts &&
|
|
917
|
+
!this._closed;
|
|
918
|
+
this._teardown(CLOSE_ABNORMAL, err.message || "error", willReconnect);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
_scheduleReconnect() {
|
|
922
|
+
var rOpts = this._opts.reconnectOpts;
|
|
923
|
+
this._reconnectAttempt += 1;
|
|
924
|
+
var attempt = Math.min(this._reconnectAttempt, 30); // allow:raw-byte-literal — clamp 2^attempt overflow
|
|
925
|
+
var ceiling = Math.min(rOpts.maxMs, rOpts.baseMs * Math.pow(2, attempt - 1));
|
|
926
|
+
var delay = Math.floor(Math.random() * ceiling); // allow:math-random-noncrypto — backoff jitter, not security
|
|
927
|
+
var self = this;
|
|
928
|
+
this._reconnectTimer = setTimeout(function () {
|
|
929
|
+
self._reconnectTimer = null;
|
|
930
|
+
if (self._closed) return; // operator-cancelled in flight
|
|
931
|
+
self._dial();
|
|
932
|
+
}, delay);
|
|
933
|
+
if (typeof this._reconnectTimer.unref === "function") this._reconnectTimer.unref();
|
|
934
|
+
this.emit("reconnecting", { attempt: this._reconnectAttempt, delayMs: delay });
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
_cancelReconnect() {
|
|
938
|
+
if (this._reconnectTimer) {
|
|
939
|
+
try { clearTimeout(this._reconnectTimer); } catch (_e) { /* drop-silent */ }
|
|
940
|
+
this._reconnectTimer = null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Operator-facing API — cancel any pending reconnect AND mark the
|
|
945
|
+
// client closed so future scheduling is also blocked. Pairs with
|
|
946
|
+
// `close()` for "I want this client done, even mid-reconnect".
|
|
947
|
+
cancelReconnect() {
|
|
948
|
+
this._cancelReconnect();
|
|
949
|
+
this._closed = true;
|
|
950
|
+
return this;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
_startHeartbeat() {
|
|
954
|
+
var self = this;
|
|
955
|
+
this._pongDeadline = Date.now() + this._opts.pongMs;
|
|
956
|
+
this._pingTimer = safeAsync.repeating(function () { self._heartbeat(); }, this._opts.pingMs);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
_heartbeat() {
|
|
960
|
+
if (this._readyState !== "open") return;
|
|
961
|
+
if (Date.now() > this._pongDeadline) {
|
|
962
|
+
this._handleSocketError(new WsClientError("ws-client/pong-timeout",
|
|
963
|
+
"no pong received within " + this._opts.pongMs + "ms"));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
this._sendFrame(OPCODE_PING, Buffer.alloc(0), { fin: true });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
module.exports = {
|
|
971
|
+
connect: connect,
|
|
972
|
+
WsClientError: WsClientError,
|
|
973
|
+
OPCODE_TEXT: OPCODE_TEXT,
|
|
974
|
+
OPCODE_BINARY: OPCODE_BINARY,
|
|
975
|
+
CLOSE_NORMAL: CLOSE_NORMAL,
|
|
976
|
+
CLOSE_GOING_AWAY: CLOSE_GOING_AWAY,
|
|
977
|
+
WS_GUID: WS_GUID,
|
|
978
|
+
};
|