@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.
- package/CHANGELOG.md +17 -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.js +6 -0
- 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/step-up-policy.js +335 -0
- package/lib/auth/step-up.js +445 -0
- package/lib/compliance-ai-act-logging.js +186 -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 +2 -0
- package/lib/crypto.js +32 -0
- 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/inbox.js +367 -0
- package/lib/mail-arc-sign.js +372 -0
- package/lib/mail-auth.js +2 -0
- package/lib/middleware/ai-act-disclosure.js +166 -0
- package/lib/middleware/asyncapi-serve.js +136 -0
- package/lib/middleware/flag-context.js +76 -0
- package/lib/middleware/index.js +15 -0
- package/lib/middleware/openapi-serve.js +143 -0
- package/lib/middleware/require-step-up.js +186 -0
- 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/pqc-software.js +195 -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 +829 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/ws-client.js
ADDED
|
@@ -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
|
+
};
|