@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +41 -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-sign.js +1 -1
  9. package/lib/audit.js +68 -2
  10. package/lib/auth/acr-vocabulary.js +265 -0
  11. package/lib/auth/auth-time-tracker.js +111 -0
  12. package/lib/auth/elevation-grant.js +306 -0
  13. package/lib/auth/jwt.js +13 -0
  14. package/lib/auth/lockout.js +16 -3
  15. package/lib/auth/oauth.js +15 -1
  16. package/lib/auth/password.js +22 -2
  17. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  18. package/lib/auth/sd-jwt-vc.js +7 -2
  19. package/lib/auth/step-up-policy.js +335 -0
  20. package/lib/auth/step-up.js +445 -0
  21. package/lib/break-glass.js +53 -14
  22. package/lib/cache-redis.js +1 -1
  23. package/lib/cache.js +6 -1
  24. package/lib/cli.js +3 -3
  25. package/lib/cluster.js +24 -1
  26. package/lib/compliance-ai-act-logging.js +190 -0
  27. package/lib/compliance-ai-act-prohibited.js +205 -0
  28. package/lib/compliance-ai-act-risk.js +189 -0
  29. package/lib/compliance-ai-act-transparency.js +200 -0
  30. package/lib/compliance-ai-act.js +558 -0
  31. package/lib/compliance.js +12 -2
  32. package/lib/config-drift.js +2 -2
  33. package/lib/crypto-field.js +21 -1
  34. package/lib/crypto.js +114 -1
  35. package/lib/db.js +35 -4
  36. package/lib/dev.js +30 -3
  37. package/lib/dual-control.js +19 -1
  38. package/lib/external-db.js +10 -0
  39. package/lib/file-upload.js +30 -3
  40. package/lib/flag-cache.js +136 -0
  41. package/lib/flag-evaluation-context.js +135 -0
  42. package/lib/flag-providers.js +279 -0
  43. package/lib/flag-targeting.js +210 -0
  44. package/lib/flag.js +284 -0
  45. package/lib/guard-all.js +33 -16
  46. package/lib/guard-csv.js +16 -2
  47. package/lib/guard-html.js +35 -0
  48. package/lib/guard-svg.js +20 -0
  49. package/lib/http-client.js +57 -11
  50. package/lib/inbox.js +391 -0
  51. package/lib/log-stream-syslog.js +8 -0
  52. package/lib/log-stream.js +1 -1
  53. package/lib/mail-arc-sign.js +372 -0
  54. package/lib/mail-auth.js +2 -0
  55. package/lib/mail.js +40 -0
  56. package/lib/middleware/ai-act-disclosure.js +166 -0
  57. package/lib/middleware/asyncapi-serve.js +136 -0
  58. package/lib/middleware/attach-user.js +25 -2
  59. package/lib/middleware/bearer-auth.js +71 -6
  60. package/lib/middleware/body-parser.js +13 -0
  61. package/lib/middleware/cors.js +10 -0
  62. package/lib/middleware/csrf-protect.js +34 -3
  63. package/lib/middleware/dpop.js +3 -3
  64. package/lib/middleware/flag-context.js +76 -0
  65. package/lib/middleware/host-allowlist.js +1 -1
  66. package/lib/middleware/index.js +15 -0
  67. package/lib/middleware/openapi-serve.js +143 -0
  68. package/lib/middleware/require-aal.js +2 -2
  69. package/lib/middleware/require-step-up.js +186 -0
  70. package/lib/middleware/trace-propagate.js +1 -1
  71. package/lib/mtls-ca.js +23 -29
  72. package/lib/mtls-engine-default.js +21 -1
  73. package/lib/network-tls.js +21 -6
  74. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  75. package/lib/observability-otlp-exporter.js +35 -2
  76. package/lib/openapi-paths-builder.js +248 -0
  77. package/lib/openapi-schema-walk.js +192 -0
  78. package/lib/openapi-security.js +169 -0
  79. package/lib/openapi-yaml.js +154 -0
  80. package/lib/openapi.js +443 -0
  81. package/lib/outbox.js +3 -3
  82. package/lib/permissions.js +10 -1
  83. package/lib/pqc-agent.js +22 -1
  84. package/lib/pqc-software.js +195 -0
  85. package/lib/pubsub.js +8 -4
  86. package/lib/redact.js +26 -1
  87. package/lib/retention.js +26 -0
  88. package/lib/router.js +1 -0
  89. package/lib/scheduler.js +57 -1
  90. package/lib/session.js +3 -3
  91. package/lib/ssrf-guard.js +19 -4
  92. package/lib/static.js +12 -0
  93. package/lib/totp.js +16 -0
  94. package/lib/vault/index.js +3 -0
  95. package/lib/vault-aad.js +259 -0
  96. package/lib/vendor/MANIFEST.json +29 -0
  97. package/lib/vendor/noble-post-quantum.cjs +18 -0
  98. package/lib/ws-client.js +978 -0
  99. package/package.json +1 -1
  100. package/sbom.cyclonedx.json +6 -6
@@ -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
+ };