@blamejs/core 0.7.107 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/NOTICE +17 -1
  3. package/README.md +4 -3
  4. package/index.js +15 -0
  5. package/lib/asyncapi-bindings.js +160 -0
  6. package/lib/asyncapi-traits.js +143 -0
  7. package/lib/asyncapi.js +531 -0
  8. package/lib/audit.js +6 -0
  9. package/lib/auth/acr-vocabulary.js +265 -0
  10. package/lib/auth/auth-time-tracker.js +111 -0
  11. package/lib/auth/elevation-grant.js +306 -0
  12. package/lib/auth/step-up-policy.js +335 -0
  13. package/lib/auth/step-up.js +445 -0
  14. package/lib/compliance-ai-act-logging.js +186 -0
  15. package/lib/compliance-ai-act-prohibited.js +205 -0
  16. package/lib/compliance-ai-act-risk.js +189 -0
  17. package/lib/compliance-ai-act-transparency.js +200 -0
  18. package/lib/compliance-ai-act.js +558 -0
  19. package/lib/compliance.js +2 -0
  20. package/lib/crypto.js +32 -0
  21. package/lib/flag-cache.js +136 -0
  22. package/lib/flag-evaluation-context.js +135 -0
  23. package/lib/flag-providers.js +279 -0
  24. package/lib/flag-targeting.js +210 -0
  25. package/lib/flag.js +284 -0
  26. package/lib/inbox.js +367 -0
  27. package/lib/mail-arc-sign.js +372 -0
  28. package/lib/mail-auth.js +2 -0
  29. package/lib/middleware/ai-act-disclosure.js +166 -0
  30. package/lib/middleware/asyncapi-serve.js +136 -0
  31. package/lib/middleware/flag-context.js +76 -0
  32. package/lib/middleware/index.js +15 -0
  33. package/lib/middleware/openapi-serve.js +143 -0
  34. package/lib/middleware/require-step-up.js +186 -0
  35. package/lib/openapi-paths-builder.js +248 -0
  36. package/lib/openapi-schema-walk.js +192 -0
  37. package/lib/openapi-security.js +169 -0
  38. package/lib/openapi-yaml.js +154 -0
  39. package/lib/openapi.js +443 -0
  40. package/lib/pqc-software.js +195 -0
  41. package/lib/vault/index.js +3 -0
  42. package/lib/vault-aad.js +259 -0
  43. package/lib/vendor/MANIFEST.json +29 -0
  44. package/lib/vendor/noble-post-quantum.cjs +18 -0
  45. package/lib/ws-client.js +829 -0
  46. package/package.json +1 -1
  47. package/sbom.cyclonedx.json +6 -6
@@ -0,0 +1,829 @@
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 C = require("./constants");
62
+ var { defineClass } = require("./framework-error");
63
+
64
+ var WsClientError = defineClass("WsClientError", { alwaysPermanent: true });
65
+
66
+ var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455 §1.3
67
+
68
+ var DEFAULT_PING_MS = C.TIME.seconds(30);
69
+ var DEFAULT_PONG_MS = C.TIME.seconds(60);
70
+ var DEFAULT_MAX_BYTES = C.BYTES.mib(8);
71
+ var DEFAULT_MAX_FRAME = C.BYTES.mib(8);
72
+ var DEFAULT_HANDSHAKE_TIMEOUT_MS = C.TIME.seconds(15);
73
+ var DEFAULT_RECONNECT_BASE_MS = C.TIME.seconds(1) / 2;
74
+ var DEFAULT_RECONNECT_MAX_MS = C.TIME.seconds(30);
75
+ var DEFAULT_RECONNECT_MAX_ATTEMPTS = 10;
76
+
77
+ var OPCODE_CONT = 0x00; // allow:raw-byte-literal — RFC 6455 opcode
78
+ var OPCODE_TEXT = 0x01; // allow:raw-byte-literal — RFC 6455 opcode
79
+ var OPCODE_BINARY = 0x02; // allow:raw-byte-literal — RFC 6455 opcode
80
+ var OPCODE_CLOSE = 0x08; // allow:raw-byte-literal — RFC 6455 opcode
81
+ var OPCODE_PING = 0x09; // allow:raw-byte-literal — RFC 6455 opcode
82
+ var OPCODE_PONG = 0x0A; // allow:raw-byte-literal — RFC 6455 opcode
83
+
84
+ var CLOSE_NORMAL = 1000; // allow:raw-byte-literal — RFC 6455 close code
85
+ var CLOSE_GOING_AWAY = 1001; // allow:raw-byte-literal — RFC 6455 close code
86
+ var CLOSE_ABNORMAL = 1006; // allow:raw-byte-literal — RFC 6455 close code (synthetic — never on wire)
87
+
88
+ // Permanent vs transient error classifier — used by reconnect logic
89
+ // so client doesn't hammer the server on credentials / handshake
90
+ // errors that won't resolve by retrying.
91
+ var PERMANENT_CODES = {
92
+ "ws-client/bad-url": true,
93
+ "ws-client/bad-status": true, // HTTP 4xx during handshake
94
+ "ws-client/accept-mismatch": true,
95
+ "ws-client/bad-upgrade": true,
96
+ "ws-client/bad-status-line": true,
97
+ "ws-client/bad-subprotocol": true,
98
+ "ws-client/bad-header": true,
99
+ "ws-client/handshake-too-large": true,
100
+ "ws-client/control-too-big": true,
101
+ "ws-client/control-fragmented": true,
102
+ "ws-client/protocol-error": true,
103
+ "ws-client/invalid-utf8": true,
104
+ "ws-client/rsv1-on-continuation": true,
105
+ "ws-client/message-too-big": true,
106
+ "ws-client/deflate-error": true,
107
+ "ws-client/payload-too-big": true,
108
+ };
109
+
110
+ function _isPermanentError(err) {
111
+ if (!err) return false;
112
+ if (err.permanent === true) return true; // defineClass-marked
113
+ if (err.code && PERMANENT_CODES[err.code]) return true;
114
+ return false;
115
+ }
116
+
117
+ // Synchronous bounded inflate — runs zlib.inflateRawSync with
118
+ // `maxOutputLength` set to maxBytes + 1, so Node aborts the inflate
119
+ // when the output would exceed the cap. Defends against a malicious
120
+ // server sending a tiny compressed frame that expands to GBs.
121
+ function _inflateRawCappedSync(zlib, compressed, maxBytes, windowBits) {
122
+ try {
123
+ return zlib.inflateRawSync(compressed, {
124
+ maxOutputLength: maxBytes + 1,
125
+ windowBits: windowBits,
126
+ });
127
+ } catch (e) {
128
+ if (e && (e.code === "ERR_BUFFER_TOO_LARGE" ||
129
+ /maxOutputLength|maxOutput/.test(String(e.message)))) {
130
+ throw new WsClientError("ws-client/decompression-bomb",
131
+ "decompression-bomb defense: inflated output > " + maxBytes + " bytes");
132
+ }
133
+ throw e;
134
+ }
135
+ }
136
+
137
+ function _generateKey() {
138
+ return fwCrypto().generateBytes(C.BYTES.bytes(16)).toString("base64");
139
+ }
140
+
141
+ function _expectedAccept(secKey, handshakeGuid) {
142
+ return nodeCrypto.createHash("sha1").update(secKey + (handshakeGuid || WS_GUID)).digest("base64");
143
+ }
144
+
145
+ function _parseUrl(target) {
146
+ var parsed;
147
+ try { parsed = new url.URL(target); }
148
+ catch (e) {
149
+ throw new WsClientError("ws-client/bad-url",
150
+ "wsClient.connect: url is malformed - " + e.message);
151
+ }
152
+ var proto = parsed.protocol;
153
+ if (proto !== "ws:" && proto !== "wss:") {
154
+ throw new WsClientError("ws-client/bad-url",
155
+ "wsClient.connect: url must start with ws:// or wss:// - got " + JSON.stringify(proto));
156
+ }
157
+ return parsed;
158
+ }
159
+
160
+ function connect(target, opts) {
161
+ opts = opts || {};
162
+ validateOpts(opts, [
163
+ "subprotocols", "headers", "tlsOpts", "pingMs", "pongMs",
164
+ "maxMessageBytes", "maxFrameBytes",
165
+ "handshakeTimeoutMs", "reconnect",
166
+ "permessageDeflate", "audit", "origin",
167
+ "handshakeGuid",
168
+ ], "wsClient.connect");
169
+
170
+ // Operators with a non-RFC-6455 GUID (private protocols on top of
171
+ // the WebSocket framing layer, framework-specific handshake variants)
172
+ // pass a custom handshakeGuid. The server-side b.websocket already
173
+ // supports it; this is the symmetric client-side knob. Defaults to
174
+ // the RFC 6455 §1.3 GUID `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`.
175
+ var handshakeGuid = WS_GUID;
176
+ if (opts.handshakeGuid != null) {
177
+ validateOpts.requireNonEmptyString(opts.handshakeGuid,
178
+ "wsClient.connect: handshakeGuid", WsClientError, "ws-client/bad-handshake-guid");
179
+ handshakeGuid = opts.handshakeGuid;
180
+ }
181
+
182
+ var parsed = _parseUrl(target);
183
+
184
+ var subprotocols = Array.isArray(opts.subprotocols) ? opts.subprotocols.slice() : [];
185
+ for (var sp = 0; sp < subprotocols.length; sp += 1) {
186
+ if (typeof subprotocols[sp] !== "string" || subprotocols[sp].length === 0) {
187
+ throw new WsClientError("ws-client/bad-subprotocol",
188
+ "wsClient.connect: subprotocols[" + sp + "] must be a non-empty string");
189
+ }
190
+ }
191
+ var pingMs = (typeof opts.pingMs === "number" && opts.pingMs > 0) // allow:numeric-opt-Infinity
192
+ ? opts.pingMs : DEFAULT_PING_MS;
193
+ var pongMs = (typeof opts.pongMs === "number" && opts.pongMs > 0) // allow:numeric-opt-Infinity
194
+ ? opts.pongMs : DEFAULT_PONG_MS;
195
+ var maxMessageBytes = (typeof opts.maxMessageBytes === "number" && opts.maxMessageBytes > 0) // allow:numeric-opt-Infinity
196
+ ? opts.maxMessageBytes : DEFAULT_MAX_BYTES;
197
+ var maxFrameBytes = (typeof opts.maxFrameBytes === "number" && opts.maxFrameBytes > 0) // allow:numeric-opt-Infinity
198
+ ? opts.maxFrameBytes : DEFAULT_MAX_FRAME;
199
+ var handshakeTimeoutMs = (typeof opts.handshakeTimeoutMs === "number" && opts.handshakeTimeoutMs > 0) // allow:numeric-opt-Infinity
200
+ ? opts.handshakeTimeoutMs : DEFAULT_HANDSHAKE_TIMEOUT_MS;
201
+
202
+ var reconnectOpts = _normaliseReconnect(opts.reconnect);
203
+ var permessageDeflate = opts.permessageDeflate !== false;
204
+ var auditOn = opts.audit !== false;
205
+
206
+ var client = new WsClient({
207
+ target: target,
208
+ parsedUrl: parsed,
209
+ subprotocols: subprotocols,
210
+ headers: opts.headers || {},
211
+ tlsOpts: opts.tlsOpts || null,
212
+ origin: opts.origin || null,
213
+ pingMs: pingMs,
214
+ pongMs: pongMs,
215
+ maxMessageBytes: maxMessageBytes,
216
+ maxFrameBytes: maxFrameBytes,
217
+ handshakeTimeoutMs: handshakeTimeoutMs,
218
+ reconnectOpts: reconnectOpts,
219
+ permessageDeflate: permessageDeflate,
220
+ auditOn: auditOn,
221
+ handshakeGuid: handshakeGuid,
222
+ });
223
+ client._dial();
224
+ return client;
225
+ }
226
+
227
+ function _normaliseReconnect(input) {
228
+ if (input === false || input == null) {
229
+ return { enabled: false, maxAttempts: 0,
230
+ baseMs: DEFAULT_RECONNECT_BASE_MS,
231
+ maxMs: DEFAULT_RECONNECT_MAX_MS };
232
+ }
233
+ if (typeof input !== "object") {
234
+ throw new WsClientError("ws-client/bad-reconnect",
235
+ "wsClient.connect: reconnect must be false / null / object");
236
+ }
237
+ validateOpts(input, ["maxAttempts", "baseMs", "maxMs", "enabled"], "wsClient.connect.reconnect");
238
+ return {
239
+ enabled: input.enabled !== false,
240
+ maxAttempts: (typeof input.maxAttempts === "number" && input.maxAttempts >= 0) // allow:numeric-opt-Infinity
241
+ ? input.maxAttempts : DEFAULT_RECONNECT_MAX_ATTEMPTS,
242
+ baseMs: (typeof input.baseMs === "number" && input.baseMs > 0) // allow:numeric-opt-Infinity
243
+ ? input.baseMs : DEFAULT_RECONNECT_BASE_MS,
244
+ maxMs: (typeof input.maxMs === "number" && input.maxMs > 0) // allow:numeric-opt-Infinity
245
+ ? input.maxMs : DEFAULT_RECONNECT_MAX_MS,
246
+ };
247
+ }
248
+
249
+ class WsClient extends EventEmitter {
250
+ constructor(opts) {
251
+ super();
252
+ this._opts = opts;
253
+ this._socket = null;
254
+ this._parser = null;
255
+ this._readyState = "connecting";
256
+ this._reconnectAttempt = 0;
257
+ this._reconnectTimer = null;
258
+ this._handshakeTimer = null;
259
+ this._pingTimer = null;
260
+ this._pongDeadline = 0;
261
+ this._fragmentChunks = [];
262
+ this._fragmentOpcode = null;
263
+ this._fragmentRsv1 = false;
264
+ this._closed = false;
265
+ this._negotiatedSubprotocol = null;
266
+ this._negotiatedDeflate = false;
267
+ this._negotiatedWindowBits = 15; // RFC 7692 default
268
+ this._bytesSent = 0;
269
+ this._bytesReceived = 0;
270
+ }
271
+
272
+ get readyState() { return this._readyState; }
273
+ get subprotocol() { return this._negotiatedSubprotocol; }
274
+ get url() { return this._opts.target; }
275
+
276
+ _dial() {
277
+ var self = this;
278
+ var opts = this._opts;
279
+ this._readyState = "connecting";
280
+
281
+ var parsed = opts.parsedUrl;
282
+ var port = parsed.port ? parseInt(parsed.port, 10) :
283
+ (parsed.protocol === "wss:" ? 443 : 80); // allow:raw-byte-literal — TLS / HTTP default port
284
+ var host = parsed.hostname;
285
+
286
+ function _onError(err) { self._handleSocketError(err); }
287
+
288
+ var socket;
289
+ if (parsed.protocol === "wss:") {
290
+ var tls = require("tls"); // allow:inline-require — node:tls only on TLS path
291
+ var tlsOpts = Object.assign({
292
+ host: host,
293
+ port: port,
294
+ servername: host,
295
+ rejectUnauthorized: true,
296
+ minVersion: "TLSv1.3",
297
+ }, opts.tlsOpts || {});
298
+ try {
299
+ var pqcShares = networkTls().pqc.getKeyShares();
300
+ if (Array.isArray(pqcShares) && pqcShares.length > 0 && !tlsOpts.curves) {
301
+ tlsOpts.curves = pqcShares.join(":");
302
+ }
303
+ } catch (_e) { /* drop-silent — tls module pre-init or non-Node */ }
304
+ socket = tls.connect(tlsOpts);
305
+ } else {
306
+ socket = net.connect({ host: host, port: port });
307
+ }
308
+ this._socket = socket;
309
+ socket.on("error", _onError);
310
+
311
+ var connectEvent = parsed.protocol === "wss:" ? "secureConnect" : "connect";
312
+ socket.once(connectEvent, function () {
313
+ try { self._sendHandshake(); }
314
+ catch (e) { self._handleSocketError(e); }
315
+ });
316
+
317
+ this._handshakeTimer = setTimeout(function () {
318
+ self._handleSocketError(new WsClientError("ws-client/handshake-timeout",
319
+ "Handshake exceeded " + opts.handshakeTimeoutMs + "ms"));
320
+ }, opts.handshakeTimeoutMs);
321
+ if (typeof this._handshakeTimer.unref === "function") this._handshakeTimer.unref();
322
+ }
323
+
324
+ _sendHandshake() {
325
+ var opts = this._opts;
326
+ var self = this;
327
+ var parsed = opts.parsedUrl;
328
+ var key = _generateKey();
329
+ this._secKey = key;
330
+
331
+ var hostHeader = parsed.host;
332
+ var pathStr = parsed.pathname + (parsed.search || "");
333
+ if (!pathStr) pathStr = "/";
334
+
335
+ var lines = [
336
+ "GET " + pathStr + " HTTP/1.1",
337
+ "Host: " + hostHeader,
338
+ "Upgrade: websocket",
339
+ "Connection: Upgrade",
340
+ "Sec-WebSocket-Key: " + key,
341
+ "Sec-WebSocket-Version: 13", // allow:raw-byte-literal — RFC 6455 §1.9
342
+ ];
343
+ if (opts.origin) {
344
+ if (safeBuffer.hasCrlf(opts.origin)) {
345
+ throw new WsClientError("ws-client/bad-header",
346
+ "Origin header value contains CR/LF (injection refused)");
347
+ }
348
+ lines.push("Origin: " + opts.origin);
349
+ }
350
+ if (opts.subprotocols.length > 0) {
351
+ lines.push("Sec-WebSocket-Protocol: " + opts.subprotocols.join(", "));
352
+ }
353
+ if (opts.permessageDeflate) {
354
+ lines.push("Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits");
355
+ }
356
+ var customHeaders = opts.headers || {};
357
+ var forbidden = ["host", "upgrade", "connection", "sec-websocket-key",
358
+ "sec-websocket-version", "sec-websocket-protocol",
359
+ "sec-websocket-extensions", "sec-websocket-accept"];
360
+ for (var hkey in customHeaders) {
361
+ if (!Object.prototype.hasOwnProperty.call(customHeaders, hkey)) continue;
362
+ if (forbidden.indexOf(hkey.toLowerCase()) !== -1) continue;
363
+ var v = customHeaders[hkey];
364
+ if (typeof v !== "string") continue;
365
+ if (safeBuffer.hasCrlf(v)) {
366
+ throw new WsClientError("ws-client/bad-header",
367
+ "header " + JSON.stringify(hkey) + ": value contains CR/LF (injection refused)");
368
+ }
369
+ lines.push(hkey + ": " + v);
370
+ }
371
+ lines.push("");
372
+ lines.push("");
373
+ var request = lines.join("\r\n");
374
+ this._handshakeBuf = Buffer.alloc(0);
375
+ this._socket.write(request);
376
+ this._socket.on("data", function (chunk) {
377
+ if (self._readyState === "connecting") {
378
+ self._consumeHandshake(chunk);
379
+ } else {
380
+ self._consumeFrames(chunk);
381
+ }
382
+ });
383
+ }
384
+
385
+ _consumeHandshake(chunk) {
386
+ // allow:handrolled-buffer-collect — handshake header capped at 64 KiB below; once handshake parses we switch to FrameParser
387
+ this._handshakeBuf = Buffer.concat([this._handshakeBuf, chunk]);
388
+ var headerEnd = this._handshakeBuf.indexOf("\r\n\r\n");
389
+ if (headerEnd === -1) {
390
+ if (this._handshakeBuf.length > C.BYTES.kib(64)) {
391
+ this._handleSocketError(new WsClientError("ws-client/handshake-too-large",
392
+ "handshake response exceeded 64 KiB before CRLFCRLF"));
393
+ }
394
+ return;
395
+ }
396
+ var headerSection = this._handshakeBuf.subarray(0, headerEnd).toString("utf8");
397
+ var rest = this._handshakeBuf.subarray(headerEnd + 4);
398
+
399
+ var lines = headerSection.split("\r\n");
400
+ var statusLine = lines[0] || "";
401
+ var match = statusLine.match(/^HTTP\/1\.\d (\d{3})/);
402
+ if (!match) {
403
+ this._handleSocketError(new WsClientError("ws-client/bad-status-line",
404
+ "handshake response status line malformed: " + JSON.stringify(statusLine)));
405
+ return;
406
+ }
407
+ var status = parseInt(match[1], 10);
408
+ if (status !== 101) { // allow:raw-byte-literal — HTTP 101
409
+ this._handleSocketError(new WsClientError("ws-client/bad-status",
410
+ "handshake response status was " + status + " (expected 101 Switching Protocols)"));
411
+ return;
412
+ }
413
+
414
+ var headers = Object.create(null);
415
+ for (var i = 1; i < lines.length; i += 1) {
416
+ var idx = lines[i].indexOf(":");
417
+ if (idx === -1) continue;
418
+ var hkey = lines[i].slice(0, idx).trim().toLowerCase();
419
+ var hval = lines[i].slice(idx + 1).trim();
420
+ headers[hkey] = hval;
421
+ }
422
+
423
+ if ((headers["upgrade"] || "").toLowerCase() !== "websocket" ||
424
+ (headers["connection"] || "").toLowerCase().indexOf("upgrade") === -1) {
425
+ this._handleSocketError(new WsClientError("ws-client/bad-upgrade",
426
+ "handshake response missing Upgrade: websocket / Connection: Upgrade"));
427
+ return;
428
+ }
429
+
430
+ var accept = headers["sec-websocket-accept"] || "";
431
+ var expected = _expectedAccept(this._secKey, this._opts.handshakeGuid);
432
+ if (accept !== expected) {
433
+ this._handleSocketError(new WsClientError("ws-client/accept-mismatch",
434
+ "handshake response Sec-WebSocket-Accept mismatch: peer responded with a key " +
435
+ "that does not match the SHA-1(key+RFC-6455-GUID) hash"));
436
+ return;
437
+ }
438
+
439
+ var negotiatedSubprotocol = headers["sec-websocket-protocol"] || null;
440
+ if (negotiatedSubprotocol && this._opts.subprotocols.indexOf(negotiatedSubprotocol) === -1) {
441
+ this._handleSocketError(new WsClientError("ws-client/bad-subprotocol",
442
+ "server selected subprotocol " + JSON.stringify(negotiatedSubprotocol) +
443
+ " not in client offer"));
444
+ return;
445
+ }
446
+ this._negotiatedSubprotocol = negotiatedSubprotocol;
447
+
448
+ this._negotiatedDeflate = false;
449
+ this._negotiatedWindowBits = 15; // allow:raw-byte-literal — RFC 7692 default windowBits
450
+ if (this._opts.permessageDeflate &&
451
+ (headers["sec-websocket-extensions"] || "").indexOf("permessage-deflate") !== -1) {
452
+ this._negotiatedDeflate = true;
453
+ // Parse server_max_window_bits from the response — server may
454
+ // narrow the window vs the default 15. We honour anything in
455
+ // [8, 15]; outside that range we treat the response as malformed
456
+ // and refuse the extension (RFC 7692 §7.1.2.1).
457
+ var extLine = headers["sec-websocket-extensions"];
458
+ var smwbMatch = extLine.match(/server_max_window_bits\s*=\s*"?(\d+)"?/); // allow:regex-no-length-cap — bounded by header line + RFC 7692 §7.1
459
+ if (smwbMatch) {
460
+ var smwb = parseInt(smwbMatch[1], 10);
461
+ if (smwb < 8 || smwb > 15) { // allow:raw-byte-literal — RFC 7692 windowBits range
462
+ this._handleSocketError(new WsClientError("ws-client/deflate-error",
463
+ "server_max_window_bits=" + smwb + " is outside RFC 7692 range [8, 15]"));
464
+ return;
465
+ }
466
+ this._negotiatedWindowBits = smwb;
467
+ }
468
+ }
469
+
470
+ if (this._handshakeTimer) {
471
+ clearTimeout(this._handshakeTimer);
472
+ this._handshakeTimer = null;
473
+ }
474
+
475
+ var fp = websocket().FrameParser;
476
+ this._parser = new fp({ maxFrameBytes: this._opts.maxFrameBytes });
477
+ this._readyState = "open";
478
+ this._reconnectAttempt = 0;
479
+ this._fragmentChunks = [];
480
+ this._fragmentOpcode = null;
481
+
482
+ this._startHeartbeat();
483
+ if (this._opts.auditOn) {
484
+ try {
485
+ audit().safeEmit({
486
+ action: "wsclient.connected",
487
+ outcome: "success",
488
+ actor: null,
489
+ metadata: {
490
+ host: this._opts.parsedUrl.host,
491
+ subprotocol: negotiatedSubprotocol,
492
+ deflate: this._negotiatedDeflate,
493
+ serverWindowBits: this._negotiatedWindowBits,
494
+ tls: this._opts.parsedUrl.protocol === "wss:",
495
+ attempt: this._reconnectAttempt,
496
+ peerCertFingerprint: this._captureCertFingerprint(),
497
+ },
498
+ });
499
+ } catch (_e) { /* drop-silent */ }
500
+ }
501
+ this.emit("open");
502
+
503
+ if (rest.length > 0) this._consumeFrames(rest);
504
+ }
505
+
506
+ _consumeFrames(chunk) {
507
+ if (!this._parser) return;
508
+ try {
509
+ var frames = this._parser.push(chunk) || [];
510
+ for (var fi = 0; fi < frames.length; fi += 1) {
511
+ this._handleFrame(frames[fi]);
512
+ }
513
+ } catch (e) {
514
+ this._handleSocketError(e);
515
+ }
516
+ }
517
+
518
+ _handleFrame(frame) {
519
+ // RFC 6455 §5.5: control frames MUST be <= 125 bytes AND non-fragmented.
520
+ var isControl = frame.opcode === OPCODE_PING ||
521
+ frame.opcode === OPCODE_PONG ||
522
+ frame.opcode === OPCODE_CLOSE;
523
+ if (isControl) {
524
+ if (frame.payload.length > 125) { // allow:raw-byte-literal — RFC 6455 §5.5 control-frame cap
525
+ this._handleSocketError(new WsClientError("ws-client/control-too-big",
526
+ "control-frame payload exceeds 125 bytes (RFC 6455 §5.5)"));
527
+ return;
528
+ }
529
+ if (frame.fin === false) {
530
+ this._handleSocketError(new WsClientError("ws-client/control-fragmented",
531
+ "control frame must have FIN=1 (RFC 6455 §5.5)"));
532
+ return;
533
+ }
534
+ }
535
+ // Continuation frames MUST NOT carry rsv1 (RFC 7692 §6.1) — only
536
+ // the first frame of a compressed message sets rsv1.
537
+ if (frame.opcode === OPCODE_CONT && frame.rsv1) {
538
+ this._handleSocketError(new WsClientError("ws-client/rsv1-on-continuation",
539
+ "RSV1 set on continuation frame (RFC 7692 §6.1)"));
540
+ return;
541
+ }
542
+ if (frame.opcode === OPCODE_PING) {
543
+ this._sendFrame(OPCODE_PONG, frame.payload, { fin: true });
544
+ return;
545
+ }
546
+ if (frame.opcode === OPCODE_PONG) {
547
+ this._pongDeadline = Date.now() + this._opts.pongMs;
548
+ return;
549
+ }
550
+ if (frame.opcode === OPCODE_CLOSE) {
551
+ var code = CLOSE_NORMAL, reason = "";
552
+ if (frame.payload.length >= 2) {
553
+ code = frame.payload.readUInt16BE(0);
554
+ var reasonBytes = frame.payload.subarray(2); // allow:raw-byte-literal — RFC 6455 close-frame layout
555
+ try {
556
+ reason = new TextDecoder("utf-8", { fatal: true }).decode(reasonBytes);
557
+ } catch (_e) {
558
+ this._handleSocketError(new WsClientError("ws-client/invalid-utf8",
559
+ "close-frame reason is not valid UTF-8 (RFC 6455 §5.6 + §7.4.1)"));
560
+ return;
561
+ }
562
+ }
563
+ this._readyState = "closing";
564
+ this._sendFrame(OPCODE_CLOSE, frame.payload, { fin: true });
565
+ this._teardown(code, reason, false);
566
+ return;
567
+ }
568
+ if (frame.opcode === OPCODE_TEXT || frame.opcode === OPCODE_BINARY) {
569
+ if (this._fragmentOpcode != null) {
570
+ this._handleSocketError(new WsClientError("ws-client/protocol-error",
571
+ "received non-continuation opcode mid-fragmented-message"));
572
+ return;
573
+ }
574
+ this._fragmentOpcode = frame.opcode;
575
+ this._fragmentRsv1 = frame.rsv1 === true;
576
+ this._fragmentChunks = [frame.payload];
577
+ } else if (frame.opcode === OPCODE_CONT) {
578
+ if (this._fragmentOpcode == null) {
579
+ this._handleSocketError(new WsClientError("ws-client/protocol-error",
580
+ "received continuation opcode with no prior text/binary frame"));
581
+ return;
582
+ }
583
+ this._fragmentChunks.push(frame.payload);
584
+ }
585
+ if (frame.fin) {
586
+ var fullPayload = Buffer.concat(this._fragmentChunks); // allow:handrolled-buffer-collect — bounded by maxMessageBytes below
587
+ if (fullPayload.length > this._opts.maxMessageBytes) {
588
+ this._handleSocketError(new WsClientError("ws-client/message-too-big",
589
+ "incoming message exceeds maxMessageBytes (" + this._opts.maxMessageBytes + ")"));
590
+ return;
591
+ }
592
+ var isBinary = this._fragmentOpcode === OPCODE_BINARY;
593
+ var firstFrameRsv1 = this._fragmentRsv1 === true;
594
+ this._fragmentChunks = [];
595
+ this._fragmentOpcode = null;
596
+ this._fragmentRsv1 = false;
597
+ if (this._negotiatedDeflate && firstFrameRsv1) {
598
+ try {
599
+ var zlib = require("zlib"); // allow:inline-require — zlib only on deflate-negotiated path
600
+ var compressed = Buffer.concat([fullPayload, Buffer.from([0x00, 0x00, 0xff, 0xff])]); // allow:raw-byte-literal — RFC 7692 §7.2.2 deflate trailer
601
+ // Decompression-bomb defense: zlib.inflateRawSync's
602
+ // `maxOutputLength` aborts the inflate the moment the
603
+ // output would exceed maxMessageBytes — never decode GBs
604
+ // from a 100-byte compressed frame.
605
+ var inflated = _inflateRawCappedSync(zlib, compressed, this._opts.maxMessageBytes,
606
+ this._negotiatedWindowBits);
607
+ fullPayload = inflated;
608
+ } catch (e) {
609
+ this._handleSocketError(new WsClientError("ws-client/deflate-error",
610
+ "permessage-deflate inflate failed or exceeded maxMessageBytes: " + e.message));
611
+ return;
612
+ }
613
+ }
614
+ this._bytesReceived += fullPayload.length;
615
+ var data;
616
+ if (isBinary) {
617
+ data = fullPayload;
618
+ } else {
619
+ try {
620
+ data = new TextDecoder("utf-8", { fatal: true }).decode(fullPayload);
621
+ } catch (_e) {
622
+ this._handleSocketError(new WsClientError("ws-client/invalid-utf8",
623
+ "text frame is not valid UTF-8 (RFC 6455 §5.6)"));
624
+ return;
625
+ }
626
+ }
627
+ this.emit("message", data, isBinary);
628
+ }
629
+ }
630
+
631
+ _sendFrame(opcode, payload, opts) {
632
+ if (!this._socket || this._socket.destroyed) return;
633
+ var serialize = websocket().serializeFrame;
634
+ var frame = serialize(opcode, payload, Object.assign({ mask: true }, opts || {}));
635
+ this._bytesSent += frame.length;
636
+ this._socket.write(frame);
637
+ }
638
+
639
+ _captureCertFingerprint() {
640
+ try {
641
+ if (this._socket && typeof this._socket.getPeerCertificate === "function") {
642
+ var cert = this._socket.getPeerCertificate(false);
643
+ if (cert && cert.fingerprint256) return cert.fingerprint256;
644
+ }
645
+ } catch (_e) { /* drop-silent */ }
646
+ return null;
647
+ }
648
+
649
+ send(data, opts) {
650
+ if (this._readyState !== "open") {
651
+ throw new WsClientError("ws-client/not-open",
652
+ "send: socket is not open (readyState=" + this._readyState + ")");
653
+ }
654
+ opts = opts || {};
655
+ var isBinary = Buffer.isBuffer(data);
656
+ var payload;
657
+ if (isBinary) {
658
+ payload = data;
659
+ } else if (typeof data === "string") {
660
+ payload = Buffer.from(data, "utf8");
661
+ } else {
662
+ payload = Buffer.from(JSON.stringify(data), "utf8");
663
+ }
664
+ if (payload.length > this._opts.maxMessageBytes) {
665
+ throw new WsClientError("ws-client/payload-too-big",
666
+ "send: payload exceeds maxMessageBytes (" + this._opts.maxMessageBytes + ")");
667
+ }
668
+ this._sendFrame(isBinary ? OPCODE_BINARY : OPCODE_TEXT, payload, { fin: true });
669
+ }
670
+
671
+ ping(payload) {
672
+ if (this._readyState !== "open") return;
673
+ this._sendFrame(OPCODE_PING, payload || Buffer.alloc(0), { fin: true });
674
+ }
675
+
676
+ close(code, reason) {
677
+ // Operator-initiated close cancels any pending reconnect — once
678
+ // close() is called, the operator wants this client retired.
679
+ this._cancelReconnect();
680
+ if (this._readyState === "closed" || this._readyState === "closing") {
681
+ // Even when already closed, ensure we mark as fully retired so
682
+ // a previously-scheduled reconnect can't fire after close().
683
+ this._closed = true;
684
+ return;
685
+ }
686
+ code = (typeof code === "number") ? code : CLOSE_NORMAL;
687
+ reason = (typeof reason === "string") ? reason : "";
688
+ // RFC 6455 §5.5: control frames must be <= 125 bytes total. The
689
+ // close payload is 2 status bytes + UTF-8 reason; that gives us
690
+ // 123 bytes for the reason. Truncate at the BYTE level rather
691
+ // than character level since a 123-byte UTF-8 sequence might end
692
+ // mid-codepoint — to be RFC-safe we truncate at code-point
693
+ // boundaries.
694
+ var rb = Buffer.from(reason, "utf8");
695
+ if (rb.length > 123) { // allow:raw-byte-literal — RFC 6455 §5.5 (125 - 2)
696
+ // Truncate at last complete codepoint within 123 bytes. Use a
697
+ // fatal TextDecoder to validate; back off one byte at a time
698
+ // until the slice decodes cleanly. Bounded by [123 - 3, 123]
699
+ // since a single UTF-8 codepoint is at most 4 bytes.
700
+ var fatal = new TextDecoder("utf-8", { fatal: true });
701
+ var truncated = rb.subarray(0, 123); // allow:raw-byte-literal — RFC 6455 §5.5
702
+ for (var bi = 0; bi < 4; bi += 1) { // allow:raw-byte-literal — max UTF-8 codepoint width
703
+ try { fatal.decode(truncated); break; }
704
+ catch (_e) { truncated = truncated.subarray(0, truncated.length - 1); }
705
+ }
706
+ rb = truncated;
707
+ }
708
+ var payload = Buffer.alloc(2 + rb.length); // allow:raw-byte-literal — RFC 6455 close-frame layout
709
+ payload.writeUInt16BE(code, 0);
710
+ rb.copy(payload, 2); // allow:raw-byte-literal — RFC 6455 close-frame layout
711
+ this._readyState = "closing";
712
+ this._sendFrame(OPCODE_CLOSE, payload, { fin: true });
713
+ var self = this;
714
+ setTimeout(function () { self._teardown(code, reason, false); }, 1000).unref(); // allow:raw-byte-literal — graceful close grace window
715
+ }
716
+
717
+ _teardown(code, reason, willReconnect) {
718
+ if (this._closed && !willReconnect) return;
719
+ this._closed = !willReconnect;
720
+ if (this._socket && !this._socket.destroyed) {
721
+ try { this._socket.destroy(); } catch (_e) { /* drop-silent */ }
722
+ }
723
+ if (this._pingTimer) { try { this._pingTimer.stop(); } catch (_e) { /* drop-silent */ } this._pingTimer = null; }
724
+ if (this._handshakeTimer) { clearTimeout(this._handshakeTimer); this._handshakeTimer = null; }
725
+ this._readyState = "closed";
726
+ this._parser = null;
727
+ this._fragmentChunks = [];
728
+ this._fragmentOpcode = null;
729
+ if (this._opts.auditOn) {
730
+ try {
731
+ audit().safeEmit({
732
+ action: "wsclient.closed",
733
+ outcome: "success",
734
+ actor: null,
735
+ metadata: { code: code, reason: reason, host: this._opts.parsedUrl.host },
736
+ });
737
+ } catch (_e) { /* drop-silent */ }
738
+ }
739
+ this.emit("close", code, reason);
740
+ if (willReconnect) this._scheduleReconnect();
741
+ }
742
+
743
+ _handleSocketError(err) {
744
+ var permanent = _isPermanentError(err);
745
+ if (this._opts.auditOn) {
746
+ try {
747
+ audit().safeEmit({
748
+ action: "wsclient.error",
749
+ outcome: "fail",
750
+ actor: null,
751
+ metadata: {
752
+ host: this._opts.parsedUrl.host,
753
+ code: err && err.code || "unknown",
754
+ message: err && err.message || String(err),
755
+ attempt: this._reconnectAttempt,
756
+ bytesSent: this._bytesSent,
757
+ bytesReceived: this._bytesReceived,
758
+ permanent: permanent,
759
+ },
760
+ });
761
+ } catch (_e) { /* drop-silent */ }
762
+ }
763
+ this.emit("error", err);
764
+ var rOpts = this._opts.reconnectOpts;
765
+ var willReconnect = rOpts.enabled &&
766
+ !permanent &&
767
+ this._reconnectAttempt < rOpts.maxAttempts &&
768
+ !this._closed;
769
+ this._teardown(CLOSE_ABNORMAL, err.message || "error", willReconnect);
770
+ }
771
+
772
+ _scheduleReconnect() {
773
+ var rOpts = this._opts.reconnectOpts;
774
+ this._reconnectAttempt += 1;
775
+ var attempt = Math.min(this._reconnectAttempt, 30); // allow:raw-byte-literal — clamp 2^attempt overflow
776
+ var ceiling = Math.min(rOpts.maxMs, rOpts.baseMs * Math.pow(2, attempt - 1));
777
+ var delay = Math.floor(Math.random() * ceiling); // allow:math-random-noncrypto — backoff jitter, not security
778
+ var self = this;
779
+ this._reconnectTimer = setTimeout(function () {
780
+ self._reconnectTimer = null;
781
+ if (self._closed) return; // operator-cancelled in flight
782
+ self._dial();
783
+ }, delay);
784
+ if (typeof this._reconnectTimer.unref === "function") this._reconnectTimer.unref();
785
+ this.emit("reconnecting", { attempt: this._reconnectAttempt, delayMs: delay });
786
+ }
787
+
788
+ _cancelReconnect() {
789
+ if (this._reconnectTimer) {
790
+ try { clearTimeout(this._reconnectTimer); } catch (_e) { /* drop-silent */ }
791
+ this._reconnectTimer = null;
792
+ }
793
+ }
794
+
795
+ // Operator-facing API — cancel any pending reconnect AND mark the
796
+ // client closed so future scheduling is also blocked. Pairs with
797
+ // `close()` for "I want this client done, even mid-reconnect".
798
+ cancelReconnect() {
799
+ this._cancelReconnect();
800
+ this._closed = true;
801
+ return this;
802
+ }
803
+
804
+ _startHeartbeat() {
805
+ var self = this;
806
+ this._pongDeadline = Date.now() + this._opts.pongMs;
807
+ this._pingTimer = safeAsync.repeating(function () { self._heartbeat(); }, this._opts.pingMs);
808
+ }
809
+
810
+ _heartbeat() {
811
+ if (this._readyState !== "open") return;
812
+ if (Date.now() > this._pongDeadline) {
813
+ this._handleSocketError(new WsClientError("ws-client/pong-timeout",
814
+ "no pong received within " + this._opts.pongMs + "ms"));
815
+ return;
816
+ }
817
+ this._sendFrame(OPCODE_PING, Buffer.alloc(0), { fin: true });
818
+ }
819
+ }
820
+
821
+ module.exports = {
822
+ connect: connect,
823
+ WsClientError: WsClientError,
824
+ OPCODE_TEXT: OPCODE_TEXT,
825
+ OPCODE_BINARY: OPCODE_BINARY,
826
+ CLOSE_NORMAL: CLOSE_NORMAL,
827
+ CLOSE_GOING_AWAY: CLOSE_GOING_AWAY,
828
+ WS_GUID: WS_GUID,
829
+ };