stelar-time-real 3.3.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client.d.ts +10 -3
- package/src/client.d.ts.map +1 -1
- package/src/client.js +105 -58
- package/src/client.ts +92 -42
- package/src/index.d.ts +8 -6
- package/src/index.d.ts.map +1 -1
- package/src/index.js +161 -105
- package/src/index.ts +127 -79
- package/src/logger.d.ts +0 -1
- package/src/logger.d.ts.map +1 -1
- package/src/logger.js +5 -8
- package/src/logger.ts +6 -10
- package/src/protocol.d.ts +5 -5
- package/src/protocol.d.ts.map +1 -1
- package/src/protocol.js +39 -25
- package/src/protocol.ts +31 -41
- package/src/websocket.d.ts +14 -8
- package/src/websocket.d.ts.map +1 -1
- package/src/websocket.js +81 -32
- package/src/websocket.ts +68 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stelar-time-real",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"description": "Zero-dependency production real-time library. Custom binary TCP + manual WebSocket. No ws package. Rate limiting (custom/per-event/per-client), hooks, custom IP tracker, custom health check, runtime config, rooms, ACKs, middleware, TLS/SSL.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
package/src/client.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stelar-time-real Client — Browser WS / Node WS / binary TCP
|
|
3
|
-
*/
|
|
1
|
+
/** @stelar-time-real Client — Browser WS / Node WS / binary TCP */
|
|
4
2
|
import { Logger, type LogLevel } from './logger.js';
|
|
5
3
|
export interface StelarClientHooks {
|
|
6
4
|
onBeforeEmit?: (i: {
|
|
@@ -48,6 +46,7 @@ export interface StelarClientOptions {
|
|
|
48
46
|
tls?: boolean;
|
|
49
47
|
rejectUnauthorized?: boolean;
|
|
50
48
|
headers?: Record<string, string>;
|
|
49
|
+
compression?: boolean;
|
|
51
50
|
customReconnectDelay?: (attempt: number, baseDelay: number, maxDelay: number) => number;
|
|
52
51
|
hooks?: StelarClientHooks;
|
|
53
52
|
}
|
|
@@ -80,6 +79,10 @@ declare class StelarClient {
|
|
|
80
79
|
private _wsParser;
|
|
81
80
|
private _tcpSock;
|
|
82
81
|
private _tcpParser;
|
|
82
|
+
private _compress;
|
|
83
|
+
private _serverCompress;
|
|
84
|
+
private _writePaused;
|
|
85
|
+
private _writeQueue;
|
|
83
86
|
private log;
|
|
84
87
|
constructor(urlOrPort?: string | number, o?: StelarClientOptions);
|
|
85
88
|
getState(): ConnectionState;
|
|
@@ -102,6 +105,7 @@ declare class StelarClient {
|
|
|
102
105
|
mode: "ws" | "tcp";
|
|
103
106
|
maxPayloadSize: number;
|
|
104
107
|
messageQueueSize: number;
|
|
108
|
+
compression: boolean;
|
|
105
109
|
hasCustomReconnectDelay: boolean;
|
|
106
110
|
hooks: string[];
|
|
107
111
|
}>;
|
|
@@ -135,6 +139,9 @@ declare class StelarClient {
|
|
|
135
139
|
private _fullCleanup;
|
|
136
140
|
private _tryReconnect;
|
|
137
141
|
private _onConnected;
|
|
142
|
+
private _writeTCP;
|
|
143
|
+
private _writeNodeWS;
|
|
144
|
+
private _flushQueue;
|
|
138
145
|
private _connectBrowser;
|
|
139
146
|
private _handleBrowserMsg;
|
|
140
147
|
private _connectNodeWS;
|
package/src/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["client.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["client.ts"],"names":[],"mappings":"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"}
|
package/src/client.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stelar-time-real Client — Browser WS / Node WS / binary TCP
|
|
3
|
-
*/
|
|
1
|
+
/** @stelar-time-real Client — Browser WS / Node WS / binary TCP */
|
|
4
2
|
import { FrameParser, encodeJsonFrame, encodeBinaryFrame, encodeAckReqFrame, encodePingFrame, encodePongFrame, encodeJoinFrame, encodeLeaveFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_RES, FRAME_CONNECT, validateEventName, DEFAULT_MAX_FRAME_SIZE, } from './protocol.js';
|
|
5
|
-
import { WSFrameParser, generateWSKey, createWSTextFrameMasked, createWSBinaryFrameMasked, createWSCloseFrameMasked, createWSPongFrameMasked, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, } from './websocket.js';
|
|
3
|
+
import { WSFrameParser, generateWSKey, createWSTextFrameMasked, createWSBinaryFrameMasked, createWSCloseFrameMasked, createWSPongFrameMasked, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, clientWantsCompression, } from './websocket.js';
|
|
6
4
|
import { Logger, NULL_LOGGER } from './logger.js';
|
|
7
5
|
const isNode = typeof process !== 'undefined' && process.versions?.node != null;
|
|
8
|
-
/* Lazy-load Node modules for browser compat */
|
|
9
6
|
let _http, _net, _tls, _https;
|
|
10
7
|
async function loadModules() {
|
|
11
8
|
if (!_http) {
|
|
@@ -26,6 +23,31 @@ class MsgQueue {
|
|
|
26
23
|
get length() { return this.q.length; }
|
|
27
24
|
clear() { this.q = []; }
|
|
28
25
|
}
|
|
26
|
+
/** WS binary framing: [4B headerLen BE][header JSON][binary payload] — length-prefixed, not null-delimited */
|
|
27
|
+
function encodeWSBinary(event, data) {
|
|
28
|
+
const hdr = Buffer.from(JSON.stringify({ event }), 'utf8');
|
|
29
|
+
const payload = new Uint8Array(data);
|
|
30
|
+
const frame = Buffer.alloc(4 + hdr.length + payload.length);
|
|
31
|
+
frame.writeUInt32BE(hdr.length, 0);
|
|
32
|
+
hdr.copy(frame, 4);
|
|
33
|
+
frame.set(payload, 4 + hdr.length);
|
|
34
|
+
return frame;
|
|
35
|
+
}
|
|
36
|
+
function parseWSBinary(payload) {
|
|
37
|
+
if (payload.length < 4)
|
|
38
|
+
return null;
|
|
39
|
+
const hdrLen = payload.readUInt32BE(0);
|
|
40
|
+
if (hdrLen > payload.length - 4)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const hdr = JSON.parse(payload.subarray(4, 4 + hdrLen).toString('utf8'));
|
|
44
|
+
const buf = payload.subarray(4 + hdrLen);
|
|
45
|
+
return { event: hdr.event, buffer: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
29
51
|
class StelarClient {
|
|
30
52
|
constructor(urlOrPort = 'localhost:3000', o = {}) {
|
|
31
53
|
this.events = new Map();
|
|
@@ -47,6 +69,10 @@ class StelarClient {
|
|
|
47
69
|
this._wsParser = null;
|
|
48
70
|
this._tcpSock = null;
|
|
49
71
|
this._tcpParser = null;
|
|
72
|
+
this._compress = false;
|
|
73
|
+
this._serverCompress = false;
|
|
74
|
+
this._writePaused = false;
|
|
75
|
+
this._writeQueue = [];
|
|
50
76
|
if (typeof urlOrPort === 'number')
|
|
51
77
|
this.url = `ws://localhost:${urlOrPort}`;
|
|
52
78
|
else if (urlOrPort.includes('://'))
|
|
@@ -61,6 +87,7 @@ class StelarClient {
|
|
|
61
87
|
maxFrameSize: o.maxFrameSize || DEFAULT_MAX_FRAME_SIZE, messageQueueSize: o.messageQueueSize || 100,
|
|
62
88
|
logger: o.logger !== undefined ? o.logger : 'warn', tls: o.tls || false,
|
|
63
89
|
rejectUnauthorized: o.rejectUnauthorized !== false, headers: o.headers || {},
|
|
90
|
+
compression: o.compression || false,
|
|
64
91
|
customReconnectDelay: o.customReconnectDelay, hooks: o.hooks || {},
|
|
65
92
|
};
|
|
66
93
|
this._mq = new MsgQueue(this.opts.messageQueueSize);
|
|
@@ -76,7 +103,7 @@ class StelarClient {
|
|
|
76
103
|
getConnectTime() { return this._connTime; }
|
|
77
104
|
setUrl(u) { this.url = u; return this; }
|
|
78
105
|
updateOptions(o) {
|
|
79
|
-
for (const k of ['reconnection', 'reconnectionAttempts', 'reconnectionDelay', 'maxReconnectionDelay', 'heartbeatInterval', 'ackTimeout', 'maxPayloadSize', 'maxFrameSize', 'messageQueueSize', 'headers'])
|
|
106
|
+
for (const k of ['reconnection', 'reconnectionAttempts', 'reconnectionDelay', 'maxReconnectionDelay', 'heartbeatInterval', 'ackTimeout', 'maxPayloadSize', 'maxFrameSize', 'messageQueueSize', 'headers', 'compression'])
|
|
80
107
|
if (o[k] !== undefined)
|
|
81
108
|
this.opts[k] = o[k];
|
|
82
109
|
if (o.customReconnectDelay !== undefined)
|
|
@@ -91,6 +118,7 @@ class StelarClient {
|
|
|
91
118
|
reconnectionDelay: this.opts.reconnectionDelay, maxReconnectionDelay: this.opts.maxReconnectionDelay,
|
|
92
119
|
heartbeatInterval: this.opts.heartbeatInterval, ackTimeout: this.opts.ackTimeout, mode: this.opts.mode,
|
|
93
120
|
maxPayloadSize: this.opts.maxPayloadSize, messageQueueSize: this.opts.messageQueueSize,
|
|
121
|
+
compression: this.opts.compression,
|
|
94
122
|
hasCustomReconnectDelay: !!this.opts.customReconnectDelay, hooks: Object.keys(this.opts.hooks),
|
|
95
123
|
});
|
|
96
124
|
}
|
|
@@ -132,9 +160,9 @@ class StelarClient {
|
|
|
132
160
|
try {
|
|
133
161
|
const send = (wsPayload, tcpPayload) => {
|
|
134
162
|
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
|
|
135
|
-
this.
|
|
163
|
+
this._writeTCP(tcpPayload());
|
|
136
164
|
else if (this._nodeSock && !this._nodeSock.destroyed)
|
|
137
|
-
this.
|
|
165
|
+
this._writeNodeWS(wsPayload());
|
|
138
166
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN)
|
|
139
167
|
this._ws.send(JSON.stringify({ event, data, ...(opts.ack ? { _ackName: opts.ack } : {}), ...(opts._correlationId ? { _correlationId: opts._correlationId } : {}) }));
|
|
140
168
|
else {
|
|
@@ -145,12 +173,12 @@ class StelarClient {
|
|
|
145
173
|
};
|
|
146
174
|
if (opts.ack) {
|
|
147
175
|
send(() => { const p = { event, data, _ackName: opts.ack }; if (opts._correlationId)
|
|
148
|
-
p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); }, () => { const d = { event, data }; if (opts._correlationId)
|
|
176
|
+
p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p), this._compress); }, () => { const d = { event, data }; if (opts._correlationId)
|
|
149
177
|
d._correlationId = opts._correlationId; return encodeAckReqFrame(opts.ack, d, this.opts.maxFrameSize); });
|
|
150
178
|
}
|
|
151
179
|
else {
|
|
152
180
|
send(() => { const p = { event, data }; if (opts._correlationId)
|
|
153
|
-
p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); }, () => encodeJsonFrame(event, data, this.opts.maxFrameSize));
|
|
181
|
+
p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p), this._compress); }, () => encodeJsonFrame(event, data, this.opts.maxFrameSize));
|
|
154
182
|
}
|
|
155
183
|
}
|
|
156
184
|
catch (e) {
|
|
@@ -170,24 +198,16 @@ class StelarClient {
|
|
|
170
198
|
if (this._state !== 'connected')
|
|
171
199
|
return this;
|
|
172
200
|
try {
|
|
201
|
+
const safeCopy = Buffer.from(new Uint8Array(data));
|
|
173
202
|
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
|
|
174
|
-
this.
|
|
203
|
+
this._writeTCP(encodeBinaryFrame(event, safeCopy, this.opts.maxFrameSize));
|
|
175
204
|
}
|
|
176
205
|
else if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
177
|
-
|
|
178
|
-
const c = Buffer.alloc(hdr.length + 1 + data.byteLength);
|
|
179
|
-
hdr.copy(c, 0);
|
|
180
|
-
c[hdr.length] = 0;
|
|
181
|
-
c.set(new Uint8Array(data), hdr.length + 1);
|
|
182
|
-
this._nodeSock.write(createWSBinaryFrameMasked(c));
|
|
206
|
+
this._writeNodeWS(createWSBinaryFrameMasked(encodeWSBinary(event, safeCopy)));
|
|
183
207
|
}
|
|
184
208
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
c.set(hdr, 0);
|
|
188
|
-
c[hdr.length] = 0;
|
|
189
|
-
c.set(new Uint8Array(data), hdr.length + 1);
|
|
190
|
-
this._ws.send(c);
|
|
209
|
+
const frame = encodeWSBinary(event, safeCopy);
|
|
210
|
+
this._ws.send(frame);
|
|
191
211
|
}
|
|
192
212
|
this._sent++;
|
|
193
213
|
}
|
|
@@ -298,7 +318,7 @@ class StelarClient {
|
|
|
298
318
|
catch { }
|
|
299
319
|
else if (this._nodeSock && !this._nodeSock.destroyed)
|
|
300
320
|
try {
|
|
301
|
-
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() })));
|
|
321
|
+
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() }), this._compress));
|
|
302
322
|
}
|
|
303
323
|
catch { }
|
|
304
324
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN)
|
|
@@ -336,7 +356,7 @@ class StelarClient {
|
|
|
336
356
|
this._tcpSock.write(m.opts.ack ? encodeAckReqFrame(m.opts.ack, p, this.opts.maxFrameSize) : encodeJsonFrame(m.event, m.data, this.opts.maxFrameSize));
|
|
337
357
|
}
|
|
338
358
|
else if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
339
|
-
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify(p)));
|
|
359
|
+
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify(p), this._compress));
|
|
340
360
|
}
|
|
341
361
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
342
362
|
this._ws.send(JSON.stringify(p));
|
|
@@ -364,6 +384,8 @@ class StelarClient {
|
|
|
364
384
|
this._tcpSock = null;
|
|
365
385
|
this._tcpParser = null;
|
|
366
386
|
this._ws = null;
|
|
387
|
+
this._writePaused = false;
|
|
388
|
+
this._writeQueue = [];
|
|
367
389
|
}
|
|
368
390
|
_tryReconnect(fn) {
|
|
369
391
|
if (this._manualClose || !this.opts.reconnection)
|
|
@@ -389,6 +411,43 @@ class StelarClient {
|
|
|
389
411
|
this._startHB();
|
|
390
412
|
this._drain();
|
|
391
413
|
}
|
|
414
|
+
/* ── Backpressure-aware writes ── */
|
|
415
|
+
_writeTCP(buf) {
|
|
416
|
+
if (!this._tcpSock || this._tcpSock.destroyed)
|
|
417
|
+
return;
|
|
418
|
+
if (this._writePaused) {
|
|
419
|
+
this._writeQueue.push(buf);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const ok = this._tcpSock.write(buf);
|
|
423
|
+
if (!ok)
|
|
424
|
+
this._writePaused = true;
|
|
425
|
+
}
|
|
426
|
+
_writeNodeWS(buf) {
|
|
427
|
+
if (!this._nodeSock || this._nodeSock.destroyed)
|
|
428
|
+
return;
|
|
429
|
+
if (this._writePaused) {
|
|
430
|
+
this._writeQueue.push(buf);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const ok = this._nodeSock.write(buf);
|
|
434
|
+
if (!ok)
|
|
435
|
+
this._writePaused = true;
|
|
436
|
+
}
|
|
437
|
+
_flushQueue() {
|
|
438
|
+
this._writePaused = false;
|
|
439
|
+
while (this._writeQueue.length) {
|
|
440
|
+
const buf = this._writeQueue.shift();
|
|
441
|
+
const sock = this.opts.mode === 'tcp' ? this._tcpSock : this._nodeSock;
|
|
442
|
+
if (sock && !sock.destroyed) {
|
|
443
|
+
const ok = sock.write(buf);
|
|
444
|
+
if (!ok) {
|
|
445
|
+
this._writePaused = true;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
392
451
|
/* ── Browser WS ── */
|
|
393
452
|
_connectBrowser() {
|
|
394
453
|
try {
|
|
@@ -409,20 +468,13 @@ class StelarClient {
|
|
|
409
468
|
_handleBrowserMsg(e) {
|
|
410
469
|
try {
|
|
411
470
|
if (e.data instanceof ArrayBuffer) {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (v[i] === 0) {
|
|
416
|
-
end = i;
|
|
417
|
-
break;
|
|
418
|
-
}
|
|
419
|
-
if (end === -1)
|
|
471
|
+
const buf = Buffer.from(e.data);
|
|
472
|
+
const parsed = parseWSBinary(buf);
|
|
473
|
+
if (!parsed)
|
|
420
474
|
return;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.
|
|
424
|
-
this.events.get(hdr.event)?.(buf);
|
|
425
|
-
this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
|
|
475
|
+
this.opts.hooks.onMessage?.({ event: parsed.event, data: parsed.buffer, isBinary: true });
|
|
476
|
+
this.events.get(parsed.event)?.(parsed.buffer);
|
|
477
|
+
this._wild?.({ event: parsed.event, data: parsed.buffer, isBinary: true, buffer: parsed.buffer });
|
|
426
478
|
return;
|
|
427
479
|
}
|
|
428
480
|
const msg = JSON.parse(e.data), { event, data, _isAck } = msg;
|
|
@@ -454,10 +506,15 @@ class StelarClient {
|
|
|
454
506
|
const parsed = new URL(this.url), secure = parsed.protocol === 'wss:' || this.opts.tls;
|
|
455
507
|
const key = generateWSKey();
|
|
456
508
|
const hdrs = { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13', ...this.opts.headers };
|
|
509
|
+
if (this.opts.compression)
|
|
510
|
+
hdrs['Sec-WebSocket-Extensions'] = 'permessage-deflate; client_no_context_takeover; server_no_context_takeover';
|
|
457
511
|
const mod = secure && _https ? _https : _http;
|
|
458
512
|
const req = mod.request({ hostname: parsed.hostname, port: parseInt(parsed.port) || (secure ? 443 : 80), path: parsed.pathname + parsed.search, method: 'GET', headers: hdrs, rejectUnauthorized: this.opts.rejectUnauthorized });
|
|
459
513
|
req.setTimeout(this.opts.ackTimeout, () => req.destroy(new Error('Timeout')));
|
|
460
|
-
req.on('upgrade', (
|
|
514
|
+
req.on('upgrade', (res, socket, head) => {
|
|
515
|
+
const extHeader = res.headers['sec-websocket-extensions'];
|
|
516
|
+
this._serverCompress = this.opts.compression && !!extHeader && clientWantsCompression(extHeader);
|
|
517
|
+
this._compress = this._serverCompress;
|
|
461
518
|
this._nodeSock = socket;
|
|
462
519
|
this._wsParser = new WSFrameParser(this.opts.maxFrameSize);
|
|
463
520
|
if (head.length > 0)
|
|
@@ -465,8 +522,8 @@ class StelarClient {
|
|
|
465
522
|
socket.on('data', (d) => this._processNodeWS(d));
|
|
466
523
|
socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectNodeWS()); });
|
|
467
524
|
socket.on('error', (e) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'node-ws' }); });
|
|
468
|
-
socket.on('drain', () =>
|
|
469
|
-
this.log.info('Node WS connected', { secure });
|
|
525
|
+
socket.on('drain', () => this._flushQueue());
|
|
526
|
+
this.log.info('Node WS connected', { secure, compressed: this._compress });
|
|
470
527
|
this._onConnected();
|
|
471
528
|
});
|
|
472
529
|
req.on('error', (e) => { this._lastErr = e; this.events.get('error')?.(e); this._tryReconnect(() => this._connectNodeWS()); });
|
|
@@ -536,22 +593,12 @@ class StelarClient {
|
|
|
536
593
|
return;
|
|
537
594
|
}
|
|
538
595
|
if (f.opcode === OP_BINARY) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
if (end === -1)
|
|
547
|
-
return;
|
|
548
|
-
const hdr = JSON.parse(f.payload.subarray(0, end).toString('utf8'));
|
|
549
|
-
const buf = f.payload.subarray(end + 1).buffer;
|
|
550
|
-
this.opts.hooks.onMessage?.({ event: hdr.event, data: buf, isBinary: true });
|
|
551
|
-
this.events.get(hdr.event)?.(buf);
|
|
552
|
-
this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
|
|
553
|
-
}
|
|
554
|
-
catch { }
|
|
596
|
+
const parsed = parseWSBinary(f.payload);
|
|
597
|
+
if (!parsed)
|
|
598
|
+
return;
|
|
599
|
+
this.opts.hooks.onMessage?.({ event: parsed.event, data: parsed.buffer, isBinary: true });
|
|
600
|
+
this.events.get(parsed.event)?.(parsed.buffer);
|
|
601
|
+
this._wild?.({ event: parsed.event, data: parsed.buffer, isBinary: true, buffer: parsed.buffer });
|
|
555
602
|
}
|
|
556
603
|
}
|
|
557
604
|
/* ── TCP ── */
|
|
@@ -579,7 +626,7 @@ class StelarClient {
|
|
|
579
626
|
} });
|
|
580
627
|
socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectTCP()); });
|
|
581
628
|
socket.on('error', (e) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'tcp' }); });
|
|
582
|
-
socket.on('drain', () =>
|
|
629
|
+
socket.on('drain', () => this._flushQueue());
|
|
583
630
|
this._tcpSock = socket;
|
|
584
631
|
}
|
|
585
632
|
catch (e) {
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @stelar-time-real Client — Browser WS / Node WS / binary TCP
|
|
3
|
-
*/
|
|
1
|
+
/** @stelar-time-real Client — Browser WS / Node WS / binary TCP */
|
|
4
2
|
|
|
5
3
|
import {
|
|
6
4
|
FrameParser, encodeJsonFrame, encodeBinaryFrame, encodeAckReqFrame,
|
|
@@ -13,7 +11,8 @@ import {
|
|
|
13
11
|
WSFrameParser, generateWSKey, createWSTextFrameMasked,
|
|
14
12
|
createWSBinaryFrameMasked, createWSCloseFrameMasked,
|
|
15
13
|
createWSPongFrameMasked, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
|
|
16
|
-
CLOSE_NORMAL, DEFAULT_MAX_WS_FRAME_SIZE,
|
|
14
|
+
CLOSE_NORMAL, DEFAULT_MAX_WS_FRAME_SIZE, clientWantsCompression,
|
|
15
|
+
createWSTextFrame, buildUpgradeResponse,
|
|
17
16
|
} from './websocket.js';
|
|
18
17
|
|
|
19
18
|
import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
|
|
@@ -36,6 +35,7 @@ export interface StelarClientOptions {
|
|
|
36
35
|
mode?: 'ws' | 'tcp'; maxPayloadSize?: number; maxFrameSize?: number;
|
|
37
36
|
messageQueueSize?: number; logger?: Logger | LogLevel | false; tls?: boolean;
|
|
38
37
|
rejectUnauthorized?: boolean; headers?: Record<string, string>;
|
|
38
|
+
compression?: boolean;
|
|
39
39
|
customReconnectDelay?: (attempt: number, baseDelay: number, maxDelay: number) => number;
|
|
40
40
|
hooks?: StelarClientHooks;
|
|
41
41
|
}
|
|
@@ -44,7 +44,6 @@ export interface StelarEmitOptions { ack?: string; _correlationId?: string; }
|
|
|
44
44
|
export type StelarEventHandler = (data: unknown) => void;
|
|
45
45
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
|
46
46
|
|
|
47
|
-
/* Lazy-load Node modules for browser compat */
|
|
48
47
|
let _http: typeof import('http') | null, _net: typeof import('net') | null,
|
|
49
48
|
_tls: typeof import('tls') | null, _https: typeof import('https') | null;
|
|
50
49
|
|
|
@@ -63,6 +62,28 @@ class MsgQueue {
|
|
|
63
62
|
clear() { this.q = []; }
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
/** WS binary framing: [4B headerLen BE][header JSON][binary payload] — length-prefixed, not null-delimited */
|
|
66
|
+
function encodeWSBinary(event: string, data: Uint8Array | ArrayBuffer): Buffer {
|
|
67
|
+
const hdr = Buffer.from(JSON.stringify({ event }), 'utf8');
|
|
68
|
+
const payload = new Uint8Array(data);
|
|
69
|
+
const frame = Buffer.alloc(4 + hdr.length + payload.length);
|
|
70
|
+
frame.writeUInt32BE(hdr.length, 0);
|
|
71
|
+
hdr.copy(frame, 4);
|
|
72
|
+
frame.set(payload, 4 + hdr.length);
|
|
73
|
+
return frame;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseWSBinary(payload: Buffer): { event: string; buffer: ArrayBuffer } | null {
|
|
77
|
+
if (payload.length < 4) return null;
|
|
78
|
+
const hdrLen = payload.readUInt32BE(0);
|
|
79
|
+
if (hdrLen > payload.length - 4) return null;
|
|
80
|
+
try {
|
|
81
|
+
const hdr = JSON.parse(payload.subarray(4, 4 + hdrLen).toString('utf8'));
|
|
82
|
+
const buf = payload.subarray(4 + hdrLen);
|
|
83
|
+
return { event: hdr.event, buffer: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer };
|
|
84
|
+
} catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
class StelarClient {
|
|
67
88
|
private url: string;
|
|
68
89
|
private opts: Required<Omit<StelarClientOptions, 'customReconnectDelay' | 'hooks'>> & {
|
|
@@ -85,6 +106,10 @@ class StelarClient {
|
|
|
85
106
|
private _wsParser: WSFrameParser | null = null;
|
|
86
107
|
private _tcpSock: InstanceType<typeof import('net').Socket> | null = null;
|
|
87
108
|
private _tcpParser: FrameParser | null = null;
|
|
109
|
+
private _compress = false;
|
|
110
|
+
private _serverCompress = false;
|
|
111
|
+
private _writePaused = false;
|
|
112
|
+
private _writeQueue: Buffer[] = [];
|
|
88
113
|
private log: Logger;
|
|
89
114
|
|
|
90
115
|
constructor(urlOrPort: string | number = 'localhost:3000', o: StelarClientOptions = {}) {
|
|
@@ -99,6 +124,7 @@ class StelarClient {
|
|
|
99
124
|
maxFrameSize: o.maxFrameSize || DEFAULT_MAX_FRAME_SIZE, messageQueueSize: o.messageQueueSize || 100,
|
|
100
125
|
logger: o.logger !== undefined ? o.logger as any : 'warn', tls: o.tls || false,
|
|
101
126
|
rejectUnauthorized: o.rejectUnauthorized !== false, headers: o.headers || {},
|
|
127
|
+
compression: o.compression || false,
|
|
102
128
|
customReconnectDelay: o.customReconnectDelay, hooks: o.hooks || {},
|
|
103
129
|
};
|
|
104
130
|
this._mq = new MsgQueue(this.opts.messageQueueSize);
|
|
@@ -116,7 +142,7 @@ class StelarClient {
|
|
|
116
142
|
setUrl(u: string) { this.url = u; return this; }
|
|
117
143
|
|
|
118
144
|
updateOptions(o: Partial<StelarClientOptions>): this {
|
|
119
|
-
for (const k of ['reconnection', 'reconnectionAttempts', 'reconnectionDelay', 'maxReconnectionDelay', 'heartbeatInterval', 'ackTimeout', 'maxPayloadSize', 'maxFrameSize', 'messageQueueSize', 'headers'] as const)
|
|
145
|
+
for (const k of ['reconnection', 'reconnectionAttempts', 'reconnectionDelay', 'maxReconnectionDelay', 'heartbeatInterval', 'ackTimeout', 'maxPayloadSize', 'maxFrameSize', 'messageQueueSize', 'headers', 'compression'] as const)
|
|
120
146
|
if ((o as any)[k] !== undefined) (this.opts as any)[k] = (o as any)[k];
|
|
121
147
|
if (o.customReconnectDelay !== undefined) this.opts.customReconnectDelay = o.customReconnectDelay;
|
|
122
148
|
if (o.hooks !== undefined) this.opts.hooks = { ...this.opts.hooks, ...o.hooks };
|
|
@@ -129,6 +155,7 @@ class StelarClient {
|
|
|
129
155
|
reconnectionDelay: this.opts.reconnectionDelay, maxReconnectionDelay: this.opts.maxReconnectionDelay,
|
|
130
156
|
heartbeatInterval: this.opts.heartbeatInterval, ackTimeout: this.opts.ackTimeout, mode: this.opts.mode,
|
|
131
157
|
maxPayloadSize: this.opts.maxPayloadSize, messageQueueSize: this.opts.messageQueueSize,
|
|
158
|
+
compression: this.opts.compression,
|
|
132
159
|
hasCustomReconnectDelay: !!this.opts.customReconnectDelay, hooks: Object.keys(this.opts.hooks),
|
|
133
160
|
});
|
|
134
161
|
}
|
|
@@ -150,20 +177,20 @@ class StelarClient {
|
|
|
150
177
|
}
|
|
151
178
|
try {
|
|
152
179
|
const send = (wsPayload: () => Buffer, tcpPayload: () => Buffer) => {
|
|
153
|
-
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) this.
|
|
154
|
-
else if (this._nodeSock && !this._nodeSock.destroyed) this.
|
|
180
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) this._writeTCP(tcpPayload());
|
|
181
|
+
else if (this._nodeSock && !this._nodeSock.destroyed) this._writeNodeWS(wsPayload());
|
|
155
182
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ event, data, ...(opts.ack ? { _ackName: opts.ack } : {}), ...(opts._correlationId ? { _correlationId: opts._correlationId } : {}) }));
|
|
156
183
|
else { this._mq.push({ event, data, opts, ts: Date.now() }); return; }
|
|
157
184
|
this._sent++;
|
|
158
185
|
};
|
|
159
186
|
if (opts.ack) {
|
|
160
187
|
send(
|
|
161
|
-
() => { const p: Record<string, unknown> = { event, data, _ackName: opts.ack }; if (opts._correlationId) p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); },
|
|
188
|
+
() => { const p: Record<string, unknown> = { event, data, _ackName: opts.ack }; if (opts._correlationId) p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p), this._compress); },
|
|
162
189
|
() => { const d: Record<string, unknown> = { event, data }; if (opts._correlationId) d._correlationId = opts._correlationId; return encodeAckReqFrame(opts.ack!, d, this.opts.maxFrameSize); },
|
|
163
190
|
);
|
|
164
191
|
} else {
|
|
165
192
|
send(
|
|
166
|
-
() => { const p: Record<string, unknown> = { event, data }; if (opts._correlationId) p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); },
|
|
193
|
+
() => { const p: Record<string, unknown> = { event, data }; if (opts._correlationId) p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p), this._compress); },
|
|
167
194
|
() => encodeJsonFrame(event, data, this.opts.maxFrameSize),
|
|
168
195
|
);
|
|
169
196
|
}
|
|
@@ -180,18 +207,14 @@ class StelarClient {
|
|
|
180
207
|
if (this.opts.hooks.onBeforeEmit?.({ event, data }) === false) return this;
|
|
181
208
|
if (this._state !== 'connected') return this;
|
|
182
209
|
try {
|
|
210
|
+
const safeCopy = Buffer.from(new Uint8Array(data));
|
|
183
211
|
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
|
|
184
|
-
this.
|
|
212
|
+
this._writeTCP(encodeBinaryFrame(event, safeCopy, this.opts.maxFrameSize));
|
|
185
213
|
} else if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
186
|
-
|
|
187
|
-
const c = Buffer.alloc(hdr.length + 1 + data.byteLength);
|
|
188
|
-
hdr.copy(c, 0); c[hdr.length] = 0; c.set(new Uint8Array(data), hdr.length + 1);
|
|
189
|
-
this._nodeSock.write(createWSBinaryFrameMasked(c));
|
|
214
|
+
this._writeNodeWS(createWSBinaryFrameMasked(encodeWSBinary(event, safeCopy)));
|
|
190
215
|
} else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
c.set(hdr, 0); c[hdr.length] = 0; c.set(new Uint8Array(data), hdr.length + 1);
|
|
194
|
-
this._ws.send(c);
|
|
216
|
+
const frame = encodeWSBinary(event, safeCopy);
|
|
217
|
+
this._ws.send(frame);
|
|
195
218
|
}
|
|
196
219
|
this._sent++;
|
|
197
220
|
} catch (e) { this.log.error('Binary emit error', { event, error: String(e) }); }
|
|
@@ -258,7 +281,7 @@ class StelarClient {
|
|
|
258
281
|
this._stopHB();
|
|
259
282
|
this._hb = setInterval(() => {
|
|
260
283
|
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) try { this._tcpSock.write(encodePingFrame()); } catch {}
|
|
261
|
-
else if (this._nodeSock && !this._nodeSock.destroyed) try { this._nodeSock.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() }))); } catch {}
|
|
284
|
+
else if (this._nodeSock && !this._nodeSock.destroyed) try { this._nodeSock.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() }), this._compress)); } catch {}
|
|
262
285
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ event: 'pong', data: Date.now() }));
|
|
263
286
|
}, this.opts.heartbeatInterval);
|
|
264
287
|
this._hb?.unref?.();
|
|
@@ -287,7 +310,7 @@ class StelarClient {
|
|
|
287
310
|
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
|
|
288
311
|
this._tcpSock.write(m.opts.ack ? encodeAckReqFrame(m.opts.ack, p, this.opts.maxFrameSize) : encodeJsonFrame(m.event, m.data, this.opts.maxFrameSize));
|
|
289
312
|
} else if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
290
|
-
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify(p)));
|
|
313
|
+
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify(p), this._compress));
|
|
291
314
|
} else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
292
315
|
this._ws.send(JSON.stringify(p));
|
|
293
316
|
}
|
|
@@ -303,6 +326,7 @@ class StelarClient {
|
|
|
303
326
|
this._stopHB(); this._cleanupAcks();
|
|
304
327
|
if (this._reconnTimer) { clearTimeout(this._reconnTimer); this._reconnTimer = null; }
|
|
305
328
|
this._nodeSock = null; this._wsParser = null; this._tcpSock = null; this._tcpParser = null; this._ws = null;
|
|
329
|
+
this._writePaused = false; this._writeQueue = [];
|
|
306
330
|
}
|
|
307
331
|
|
|
308
332
|
private _tryReconnect(fn: () => void) {
|
|
@@ -320,6 +344,34 @@ class StelarClient {
|
|
|
320
344
|
this.events.get('connect')?.(undefined); this._startHB(); this._drain();
|
|
321
345
|
}
|
|
322
346
|
|
|
347
|
+
/* ── Backpressure-aware writes ── */
|
|
348
|
+
|
|
349
|
+
private _writeTCP(buf: Buffer) {
|
|
350
|
+
if (!this._tcpSock || this._tcpSock.destroyed) return;
|
|
351
|
+
if (this._writePaused) { this._writeQueue.push(buf); return; }
|
|
352
|
+
const ok = this._tcpSock.write(buf);
|
|
353
|
+
if (!ok) this._writePaused = true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private _writeNodeWS(buf: Buffer) {
|
|
357
|
+
if (!this._nodeSock || this._nodeSock.destroyed) return;
|
|
358
|
+
if (this._writePaused) { this._writeQueue.push(buf); return; }
|
|
359
|
+
const ok = this._nodeSock.write(buf);
|
|
360
|
+
if (!ok) this._writePaused = true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private _flushQueue() {
|
|
364
|
+
this._writePaused = false;
|
|
365
|
+
while (this._writeQueue.length) {
|
|
366
|
+
const buf = this._writeQueue.shift()!;
|
|
367
|
+
const sock = this.opts.mode === 'tcp' ? this._tcpSock : this._nodeSock;
|
|
368
|
+
if (sock && !sock.destroyed) {
|
|
369
|
+
const ok = sock.write(buf);
|
|
370
|
+
if (!ok) { this._writePaused = true; break; }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
323
375
|
/* ── Browser WS ── */
|
|
324
376
|
|
|
325
377
|
private _connectBrowser() {
|
|
@@ -336,14 +388,12 @@ class StelarClient {
|
|
|
336
388
|
private _handleBrowserMsg(e: MessageEvent) {
|
|
337
389
|
try {
|
|
338
390
|
if (e.data instanceof ArrayBuffer) {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
this.
|
|
345
|
-
this.events.get(hdr.event)?.(buf);
|
|
346
|
-
this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
|
|
391
|
+
const buf = Buffer.from(e.data);
|
|
392
|
+
const parsed = parseWSBinary(buf);
|
|
393
|
+
if (!parsed) return;
|
|
394
|
+
this.opts.hooks.onMessage?.({ event: parsed.event, data: parsed.buffer, isBinary: true });
|
|
395
|
+
this.events.get(parsed.event)?.(parsed.buffer);
|
|
396
|
+
this._wild?.({ event: parsed.event, data: parsed.buffer, isBinary: true, buffer: parsed.buffer });
|
|
347
397
|
return;
|
|
348
398
|
}
|
|
349
399
|
const msg = JSON.parse(e.data as string), { event, data, _isAck } = msg;
|
|
@@ -366,17 +416,21 @@ class StelarClient {
|
|
|
366
416
|
const parsed = new URL(this.url), secure = parsed.protocol === 'wss:' || this.opts.tls;
|
|
367
417
|
const key = generateWSKey();
|
|
368
418
|
const hdrs: Record<string, string> = { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13', ...this.opts.headers };
|
|
419
|
+
if (this.opts.compression) hdrs['Sec-WebSocket-Extensions'] = 'permessage-deflate; client_no_context_takeover; server_no_context_takeover';
|
|
369
420
|
const mod = secure && _https ? _https : _http;
|
|
370
421
|
const req = mod.request({ hostname: parsed.hostname, port: parseInt(parsed.port) || (secure ? 443 : 80), path: parsed.pathname + parsed.search, method: 'GET', headers: hdrs, rejectUnauthorized: this.opts.rejectUnauthorized });
|
|
371
422
|
req.setTimeout(this.opts.ackTimeout, () => req.destroy(new Error('Timeout')));
|
|
372
|
-
req.on('upgrade', (
|
|
423
|
+
req.on('upgrade', (res, socket, head) => {
|
|
424
|
+
const extHeader = res.headers['sec-websocket-extensions'];
|
|
425
|
+
this._serverCompress = this.opts.compression && !!extHeader && clientWantsCompression(extHeader as string);
|
|
426
|
+
this._compress = this._serverCompress;
|
|
373
427
|
this._nodeSock = socket; this._wsParser = new WSFrameParser(this.opts.maxFrameSize);
|
|
374
428
|
if (head.length > 0) this._processNodeWS(head);
|
|
375
429
|
socket.on('data', (d: Buffer) => this._processNodeWS(d));
|
|
376
430
|
socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectNodeWS()); });
|
|
377
431
|
socket.on('error', (e: Error) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'node-ws' }); });
|
|
378
|
-
socket.on('drain', () =>
|
|
379
|
-
this.log.info('Node WS connected', { secure });
|
|
432
|
+
socket.on('drain', () => this._flushQueue());
|
|
433
|
+
this.log.info('Node WS connected', { secure, compressed: this._compress });
|
|
380
434
|
this._onConnected();
|
|
381
435
|
});
|
|
382
436
|
req.on('error', (e: Error) => { this._lastErr = e; this.events.get('error')?.(e); this._tryReconnect(() => this._connectNodeWS()); });
|
|
@@ -411,15 +465,11 @@ class StelarClient {
|
|
|
411
465
|
}
|
|
412
466
|
|
|
413
467
|
if (f.opcode === OP_BINARY) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
this.opts.hooks.onMessage?.({ event: hdr.event, data: buf, isBinary: true });
|
|
420
|
-
this.events.get(hdr.event)?.(buf);
|
|
421
|
-
this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
|
|
422
|
-
} catch {}
|
|
468
|
+
const parsed = parseWSBinary(f.payload);
|
|
469
|
+
if (!parsed) return;
|
|
470
|
+
this.opts.hooks.onMessage?.({ event: parsed.event, data: parsed.buffer, isBinary: true });
|
|
471
|
+
this.events.get(parsed.event)?.(parsed.buffer);
|
|
472
|
+
this._wild?.({ event: parsed.event, data: parsed.buffer, isBinary: true, buffer: parsed.buffer });
|
|
423
473
|
}
|
|
424
474
|
}
|
|
425
475
|
|
|
@@ -436,7 +486,7 @@ class StelarClient {
|
|
|
436
486
|
socket.on('data', (d: Buffer) => { if (!this._tcpParser) return; let frames; try { frames = this._tcpParser.feed(d); } catch { this.log.error('TCP parse error'); socket.destroy(); return; } for (const f of frames) { this._recv++; this._handleTCPFrame(f); } });
|
|
437
487
|
socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectTCP()); });
|
|
438
488
|
socket.on('error', (e: Error) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'tcp' }); });
|
|
439
|
-
socket.on('drain', () =>
|
|
489
|
+
socket.on('drain', () => this._flushQueue());
|
|
440
490
|
this._tcpSock = socket;
|
|
441
491
|
} catch (e) { this._lastErr = e instanceof Error ? e : new Error(String(e)); this._tryReconnect(() => this._connectTCP()); }
|
|
442
492
|
}
|