stelar-time-real 3.2.0 → 3.3.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/README.md +429 -429
- package/package.json +1 -1
- package/src/client.d.ts +47 -59
- package/src/client.d.ts.map +1 -1
- package/src/client.js +406 -728
- package/src/client.ts +317 -908
- package/src/index.d.ts +84 -124
- package/src/index.d.ts.map +1 -1
- package/src/index.js +740 -1165
- package/src/index.ts +552 -1574
- package/src/logger.d.ts +12 -17
- package/src/logger.d.ts.map +1 -1
- package/src/logger.js +34 -90
- package/src/logger.ts +31 -98
- package/src/protocol.d.ts +16 -34
- package/src/protocol.d.ts.map +1 -1
- package/src/protocol.js +56 -148
- package/src/protocol.ts +66 -188
- package/src/websocket.d.ts +21 -43
- package/src/websocket.d.ts.map +1 -1
- package/src/websocket.js +106 -216
- package/src/websocket.ts +78 -279
package/src/client.js
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @stelar-time-real Client
|
|
3
|
-
*
|
|
4
|
-
* Dual-environment: Browser (native WebSocket) + Node.js (manual WS or TCP binary).
|
|
5
|
-
* No external dependencies.
|
|
2
|
+
* @stelar-time-real Client — Browser WS / Node WS / binary TCP
|
|
6
3
|
*/
|
|
7
4
|
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';
|
|
8
5
|
import { WSFrameParser, generateWSKey, createWSTextFrameMasked, createWSBinaryFrameMasked, createWSCloseFrameMasked, createWSPongFrameMasked, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, } from './websocket.js';
|
|
9
6
|
import { Logger, NULL_LOGGER } from './logger.js';
|
|
10
7
|
const isNode = typeof process !== 'undefined' && process.versions?.node != null;
|
|
11
|
-
/* Lazy-load Node
|
|
12
|
-
let _http
|
|
13
|
-
|
|
14
|
-
let _tls = null;
|
|
15
|
-
let _https = null;
|
|
16
|
-
async function loadNodeModules() {
|
|
8
|
+
/* Lazy-load Node modules for browser compat */
|
|
9
|
+
let _http, _net, _tls, _https;
|
|
10
|
+
async function loadModules() {
|
|
17
11
|
if (!_http) {
|
|
18
12
|
_http = await import('http');
|
|
19
13
|
_net = await import('net');
|
|
@@ -21,693 +15,469 @@ async function loadNodeModules() {
|
|
|
21
15
|
_https = await import('https');
|
|
22
16
|
}
|
|
23
17
|
}
|
|
24
|
-
class
|
|
25
|
-
constructor(
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
}
|
|
29
|
-
push(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
drain() {
|
|
37
|
-
const msgs = this.queue;
|
|
38
|
-
this.queue = [];
|
|
39
|
-
return msgs;
|
|
40
|
-
}
|
|
41
|
-
get length() {
|
|
42
|
-
return this.queue.length;
|
|
43
|
-
}
|
|
44
|
-
clear() {
|
|
45
|
-
this.queue = [];
|
|
46
|
-
}
|
|
18
|
+
class MsgQueue {
|
|
19
|
+
constructor(max = 100) {
|
|
20
|
+
this.max = max;
|
|
21
|
+
this.q = [];
|
|
22
|
+
}
|
|
23
|
+
push(m) { if (this.q.length >= this.max)
|
|
24
|
+
this.q.shift(); this.q.push(m); return true; }
|
|
25
|
+
drain() { const m = this.q; this.q = []; return m; }
|
|
26
|
+
get length() { return this.q.length; }
|
|
27
|
+
clear() { this.q = []; }
|
|
47
28
|
}
|
|
48
29
|
class StelarClient {
|
|
49
|
-
constructor(urlOrPort = 'localhost:3000',
|
|
30
|
+
constructor(urlOrPort = 'localhost:3000', o = {}) {
|
|
50
31
|
this.events = new Map();
|
|
51
|
-
this.
|
|
32
|
+
this._wild = null;
|
|
52
33
|
this._acks = new Map();
|
|
53
34
|
this._state = 'disconnected';
|
|
54
|
-
this.
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
35
|
+
this._reconnAttempts = 0;
|
|
36
|
+
this._hb = null;
|
|
37
|
+
this._manualClose = false;
|
|
57
38
|
this.id = null;
|
|
58
|
-
this.
|
|
59
|
-
this.
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
62
|
-
this.
|
|
39
|
+
this._reconnTimer = null;
|
|
40
|
+
this._ackCounter = 0;
|
|
41
|
+
this._sent = 0;
|
|
42
|
+
this._recv = 0;
|
|
43
|
+
this._connTime = 0;
|
|
44
|
+
this._lastErr = null;
|
|
63
45
|
this._ws = null;
|
|
64
|
-
this.
|
|
46
|
+
this._nodeSock = null;
|
|
65
47
|
this._wsParser = null;
|
|
66
|
-
this.
|
|
48
|
+
this._tcpSock = null;
|
|
67
49
|
this._tcpParser = null;
|
|
68
|
-
if (typeof urlOrPort === 'number')
|
|
50
|
+
if (typeof urlOrPort === 'number')
|
|
69
51
|
this.url = `ws://localhost:${urlOrPort}`;
|
|
70
|
-
|
|
71
|
-
else if (urlOrPort.includes('://')) {
|
|
52
|
+
else if (urlOrPort.includes('://'))
|
|
72
53
|
this.url = urlOrPort.startsWith('http') ? 'ws' + urlOrPort.slice(4) : urlOrPort;
|
|
73
|
-
|
|
74
|
-
else {
|
|
54
|
+
else
|
|
75
55
|
this.url = `ws://${urlOrPort}`;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
maxPayloadSize: options.maxPayloadSize || 10 * 1024 * 1024,
|
|
86
|
-
maxFrameSize: options.maxFrameSize || DEFAULT_MAX_FRAME_SIZE,
|
|
87
|
-
messageQueueSize: options.messageQueueSize || 100,
|
|
88
|
-
logger: options.logger !== undefined ? options.logger : 'warn',
|
|
89
|
-
tls: options.tls || false,
|
|
90
|
-
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
91
|
-
headers: options.headers || {},
|
|
92
|
-
customReconnectDelay: options.customReconnectDelay,
|
|
93
|
-
hooks: options.hooks || {},
|
|
56
|
+
this.opts = {
|
|
57
|
+
reconnection: o.reconnection !== false, reconnectionAttempts: o.reconnectionAttempts || 10,
|
|
58
|
+
reconnectionDelay: o.reconnectionDelay || 1000, maxReconnectionDelay: o.maxReconnectionDelay || 30000,
|
|
59
|
+
heartbeatInterval: o.heartbeatInterval || 30000, ackTimeout: o.ackTimeout || 5000,
|
|
60
|
+
mode: o.mode || 'ws', maxPayloadSize: o.maxPayloadSize || 10 * 1024 * 1024,
|
|
61
|
+
maxFrameSize: o.maxFrameSize || DEFAULT_MAX_FRAME_SIZE, messageQueueSize: o.messageQueueSize || 100,
|
|
62
|
+
logger: o.logger !== undefined ? o.logger : 'warn', tls: o.tls || false,
|
|
63
|
+
rejectUnauthorized: o.rejectUnauthorized !== false, headers: o.headers || {},
|
|
64
|
+
customReconnectDelay: o.customReconnectDelay, hooks: o.hooks || {},
|
|
94
65
|
};
|
|
95
|
-
this.
|
|
96
|
-
|
|
97
|
-
this.log = NULL_LOGGER;
|
|
98
|
-
}
|
|
99
|
-
else if (this.options.logger instanceof Logger) {
|
|
100
|
-
this.log = this.options.logger;
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
this.log = new Logger({
|
|
104
|
-
level: this.options.logger || 'warn',
|
|
105
|
-
prefix: 'stelar:client',
|
|
106
|
-
});
|
|
107
|
-
}
|
|
66
|
+
this._mq = new MsgQueue(this.opts.messageQueueSize);
|
|
67
|
+
this.log = o.logger === false ? NULL_LOGGER : o.logger instanceof Logger ? o.logger : new Logger({ level: o.logger || 'warn', prefix: 'stelar:client' });
|
|
108
68
|
}
|
|
109
69
|
getState() { return this._state; }
|
|
110
70
|
getId() { return this.id; }
|
|
111
71
|
getUrl() { return this.url; }
|
|
112
|
-
getMessagesSent() { return this.
|
|
113
|
-
getMessagesReceived() { return this.
|
|
114
|
-
getLastError() { return this.
|
|
115
|
-
getQueueSize() { return this.
|
|
116
|
-
getConnectTime() { return this.
|
|
117
|
-
setUrl(
|
|
118
|
-
|
|
72
|
+
getMessagesSent() { return this._sent; }
|
|
73
|
+
getMessagesReceived() { return this._recv; }
|
|
74
|
+
getLastError() { return this._lastErr; }
|
|
75
|
+
getQueueSize() { return this._mq.length; }
|
|
76
|
+
getConnectTime() { return this._connTime; }
|
|
77
|
+
setUrl(u) { this.url = u; return this; }
|
|
78
|
+
updateOptions(o) {
|
|
79
|
+
for (const k of ['reconnection', 'reconnectionAttempts', 'reconnectionDelay', 'maxReconnectionDelay', 'heartbeatInterval', 'ackTimeout', 'maxPayloadSize', 'maxFrameSize', 'messageQueueSize', 'headers'])
|
|
80
|
+
if (o[k] !== undefined)
|
|
81
|
+
this.opts[k] = o[k];
|
|
82
|
+
if (o.customReconnectDelay !== undefined)
|
|
83
|
+
this.opts.customReconnectDelay = o.customReconnectDelay;
|
|
84
|
+
if (o.hooks !== undefined)
|
|
85
|
+
this.opts.hooks = { ...this.opts.hooks, ...o.hooks };
|
|
119
86
|
return this;
|
|
120
87
|
}
|
|
121
|
-
/** Update client options at runtime. Changes take effect immediately. */
|
|
122
|
-
updateOptions(options) {
|
|
123
|
-
if (options.reconnection !== undefined)
|
|
124
|
-
this.options.reconnection = options.reconnection;
|
|
125
|
-
if (options.reconnectionAttempts !== undefined)
|
|
126
|
-
this.options.reconnectionAttempts = options.reconnectionAttempts;
|
|
127
|
-
if (options.reconnectionDelay !== undefined)
|
|
128
|
-
this.options.reconnectionDelay = options.reconnectionDelay;
|
|
129
|
-
if (options.maxReconnectionDelay !== undefined)
|
|
130
|
-
this.options.maxReconnectionDelay = options.maxReconnectionDelay;
|
|
131
|
-
if (options.heartbeatInterval !== undefined)
|
|
132
|
-
this.options.heartbeatInterval = options.heartbeatInterval;
|
|
133
|
-
if (options.ackTimeout !== undefined)
|
|
134
|
-
this.options.ackTimeout = options.ackTimeout;
|
|
135
|
-
if (options.maxPayloadSize !== undefined)
|
|
136
|
-
this.options.maxPayloadSize = options.maxPayloadSize;
|
|
137
|
-
if (options.maxFrameSize !== undefined)
|
|
138
|
-
this.options.maxFrameSize = options.maxFrameSize;
|
|
139
|
-
if (options.messageQueueSize !== undefined)
|
|
140
|
-
this.options.messageQueueSize = options.messageQueueSize;
|
|
141
|
-
if (options.headers !== undefined)
|
|
142
|
-
this.options.headers = options.headers;
|
|
143
|
-
if (options.customReconnectDelay !== undefined)
|
|
144
|
-
this.options.customReconnectDelay = options.customReconnectDelay;
|
|
145
|
-
if (options.hooks !== undefined)
|
|
146
|
-
this.options.hooks = { ...this.options.hooks, ...options.hooks };
|
|
147
|
-
return this;
|
|
148
|
-
}
|
|
149
|
-
/** Read-only snapshot of current client options. */
|
|
150
88
|
getOptions() {
|
|
151
89
|
return Object.freeze({
|
|
152
|
-
reconnection: this.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
ackTimeout: this.options.ackTimeout,
|
|
158
|
-
mode: this.options.mode,
|
|
159
|
-
maxPayloadSize: this.options.maxPayloadSize,
|
|
160
|
-
messageQueueSize: this.options.messageQueueSize,
|
|
161
|
-
hasCustomReconnectDelay: !!this.options.customReconnectDelay,
|
|
162
|
-
hooks: Object.keys(this.options.hooks),
|
|
90
|
+
reconnection: this.opts.reconnection, reconnectionAttempts: this.opts.reconnectionAttempts,
|
|
91
|
+
reconnectionDelay: this.opts.reconnectionDelay, maxReconnectionDelay: this.opts.maxReconnectionDelay,
|
|
92
|
+
heartbeatInterval: this.opts.heartbeatInterval, ackTimeout: this.opts.ackTimeout, mode: this.opts.mode,
|
|
93
|
+
maxPayloadSize: this.opts.maxPayloadSize, messageQueueSize: this.opts.messageQueueSize,
|
|
94
|
+
hasCustomReconnectDelay: !!this.opts.customReconnectDelay, hooks: Object.keys(this.opts.hooks),
|
|
163
95
|
});
|
|
164
96
|
}
|
|
165
|
-
on(
|
|
166
|
-
|
|
167
|
-
return this;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
return this;
|
|
174
|
-
}
|
|
175
|
-
once(event, handler) {
|
|
176
|
-
const wrapped = (data) => {
|
|
177
|
-
this.off(event, wrapped);
|
|
178
|
-
handler(data);
|
|
179
|
-
};
|
|
180
|
-
this.on(event, wrapped);
|
|
181
|
-
return this;
|
|
182
|
-
}
|
|
183
|
-
onAll(handler) {
|
|
184
|
-
this._wildcardHandler = handler;
|
|
185
|
-
return this;
|
|
186
|
-
}
|
|
187
|
-
onAck(name, handler) {
|
|
188
|
-
this._acks.set(name, { handler, timer: null });
|
|
189
|
-
return this;
|
|
190
|
-
}
|
|
191
|
-
removeAllListeners(event) {
|
|
192
|
-
if (event) {
|
|
193
|
-
this.events.delete(event);
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
this.events.clear();
|
|
197
|
-
}
|
|
198
|
-
return this;
|
|
199
|
-
}
|
|
97
|
+
on(ev, h) { this.events.set(ev, h); return this; }
|
|
98
|
+
off(ev, h) { if (this.events.get(ev) === h)
|
|
99
|
+
this.events.delete(ev); return this; }
|
|
100
|
+
once(ev, h) { const w = (d) => { this.off(ev, w); h(d); }; this.on(ev, w); return this; }
|
|
101
|
+
onAll(h) { this._wild = h; return this; }
|
|
102
|
+
onAck(name, h) { this._acks.set(name, { handler: h, timer: null }); return this; }
|
|
103
|
+
removeAllListeners(ev) { ev ? this.events.delete(ev) : this.events.clear(); return this; }
|
|
200
104
|
emit(event, data, opts = {}) {
|
|
201
105
|
try {
|
|
202
106
|
if (event)
|
|
203
107
|
validateEventName(event);
|
|
204
108
|
}
|
|
205
109
|
catch {
|
|
206
|
-
this.log.warn('Invalid event
|
|
110
|
+
this.log.warn('Invalid event', { event });
|
|
207
111
|
return this;
|
|
208
112
|
}
|
|
209
|
-
if (this.
|
|
210
|
-
|
|
211
|
-
if (result === false)
|
|
212
|
-
return this;
|
|
213
|
-
}
|
|
113
|
+
if (this.opts.hooks.onBeforeEmit?.({ event, data }) === false)
|
|
114
|
+
return this;
|
|
214
115
|
try {
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
-
this.log.warn('Payload
|
|
116
|
+
const s = JSON.stringify(data);
|
|
117
|
+
if (s.length > this.opts.maxPayloadSize) {
|
|
118
|
+
this.log.warn('Payload too large', { event });
|
|
218
119
|
return this;
|
|
219
120
|
}
|
|
220
121
|
}
|
|
221
122
|
catch {
|
|
222
|
-
this.log.warn('Failed to serialize data', { event });
|
|
223
123
|
return this;
|
|
224
124
|
}
|
|
225
125
|
if (this._state !== 'connected') {
|
|
226
|
-
if (this.
|
|
227
|
-
this.
|
|
228
|
-
this.
|
|
229
|
-
if (this.options.hooks.onMessageQueued) {
|
|
230
|
-
this.options.hooks.onMessageQueued({ event, data, queueSize: this._messageQueue.length });
|
|
231
|
-
}
|
|
126
|
+
if (this.opts.reconnection) {
|
|
127
|
+
this._mq.push({ event, data, opts, ts: Date.now() });
|
|
128
|
+
this.opts.hooks.onMessageQueued?.({ event, data, queueSize: this._mq.length });
|
|
232
129
|
}
|
|
233
130
|
return this;
|
|
234
131
|
}
|
|
235
132
|
try {
|
|
236
|
-
|
|
237
|
-
if (opts.
|
|
238
|
-
this.
|
|
239
|
-
|
|
133
|
+
const send = (wsPayload, tcpPayload) => {
|
|
134
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
|
|
135
|
+
this._tcpSock.write(tcpPayload());
|
|
136
|
+
else if (this._nodeSock && !this._nodeSock.destroyed)
|
|
137
|
+
this._nodeSock.write(wsPayload());
|
|
138
|
+
else if (this._ws && this._ws.readyState === WebSocket.OPEN)
|
|
139
|
+
this._ws.send(JSON.stringify({ event, data, ...(opts.ack ? { _ackName: opts.ack } : {}), ...(opts._correlationId ? { _correlationId: opts._correlationId } : {}) }));
|
|
240
140
|
else {
|
|
241
|
-
this.
|
|
141
|
+
this._mq.push({ event, data, opts, ts: Date.now() });
|
|
142
|
+
return;
|
|
242
143
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (opts.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
251
|
-
const payload = { event, data };
|
|
252
|
-
if (opts.ack)
|
|
253
|
-
payload._ackName = opts.ack;
|
|
254
|
-
this._ws.send(JSON.stringify(payload));
|
|
144
|
+
this._sent++;
|
|
145
|
+
};
|
|
146
|
+
if (opts.ack) {
|
|
147
|
+
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)
|
|
149
|
+
d._correlationId = opts._correlationId; return encodeAckReqFrame(opts.ack, d, this.opts.maxFrameSize); });
|
|
255
150
|
}
|
|
256
151
|
else {
|
|
257
|
-
|
|
258
|
-
|
|
152
|
+
send(() => { const p = { event, data }; if (opts._correlationId)
|
|
153
|
+
p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); }, () => encodeJsonFrame(event, data, this.opts.maxFrameSize));
|
|
259
154
|
}
|
|
260
|
-
this._messagesSent++;
|
|
261
155
|
}
|
|
262
|
-
catch (
|
|
263
|
-
this.log.error('Emit error', { event, error: String(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
this._messageQueue.push({ event, data, opts, timestamp: Date.now() });
|
|
156
|
+
catch (e) {
|
|
157
|
+
this.log.error('Emit error', { event, error: String(e) });
|
|
158
|
+
this.opts.hooks.onError?.({ error: e instanceof Error ? e : new Error(String(e)), context: 'emit' });
|
|
159
|
+
this._mq.push({ event, data, opts, ts: Date.now() });
|
|
268
160
|
}
|
|
269
161
|
return this;
|
|
270
162
|
}
|
|
271
163
|
emitBinary(event, data) {
|
|
272
|
-
if (data.byteLength > this.
|
|
273
|
-
this.log.warn('Binary
|
|
164
|
+
if (data.byteLength > this.opts.maxPayloadSize) {
|
|
165
|
+
this.log.warn('Binary too large', { event });
|
|
274
166
|
return this;
|
|
275
167
|
}
|
|
276
|
-
if (this.
|
|
277
|
-
|
|
278
|
-
if (result === false)
|
|
279
|
-
return this;
|
|
280
|
-
}
|
|
168
|
+
if (this.opts.hooks.onBeforeEmit?.({ event, data }) === false)
|
|
169
|
+
return this;
|
|
281
170
|
if (this._state !== 'connected')
|
|
282
171
|
return this;
|
|
283
172
|
try {
|
|
284
|
-
if (this.
|
|
285
|
-
this.
|
|
173
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
|
|
174
|
+
this._tcpSock.write(encodeBinaryFrame(event, new Uint8Array(data), this.opts.maxFrameSize));
|
|
286
175
|
}
|
|
287
|
-
else if (this.
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
this._nodeSocket.write(createWSBinaryFrameMasked(combined));
|
|
176
|
+
else if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
177
|
+
const hdr = Buffer.from(JSON.stringify({ event }), 'utf8');
|
|
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));
|
|
295
183
|
}
|
|
296
184
|
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
this._ws.send(combined);
|
|
185
|
+
const hdr = new TextEncoder().encode(JSON.stringify({ event }));
|
|
186
|
+
const c = new Uint8Array(hdr.length + 1 + data.byteLength);
|
|
187
|
+
c.set(hdr, 0);
|
|
188
|
+
c[hdr.length] = 0;
|
|
189
|
+
c.set(new Uint8Array(data), hdr.length + 1);
|
|
190
|
+
this._ws.send(c);
|
|
304
191
|
}
|
|
305
|
-
this.
|
|
192
|
+
this._sent++;
|
|
306
193
|
}
|
|
307
|
-
catch (
|
|
308
|
-
this.log.error('Binary emit error', { event, error: String(
|
|
309
|
-
if (this.options.hooks.onError) {
|
|
310
|
-
this.options.hooks.onError({ error: err instanceof Error ? err : new Error(String(err)), context: 'emitBinary' });
|
|
311
|
-
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
this.log.error('Binary emit error', { event, error: String(e) });
|
|
312
196
|
}
|
|
313
197
|
return this;
|
|
314
198
|
}
|
|
315
|
-
sendFile(file)
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
sendImage(blob) {
|
|
319
|
-
return this.emitBinary('image', blob);
|
|
320
|
-
}
|
|
321
|
-
/** Send a request and wait for an ACK response. Rejects on timeout. */
|
|
199
|
+
sendFile(f) { return this.emitBinary('file', f); }
|
|
200
|
+
sendImage(b) { return this.emitBinary('image', b); }
|
|
322
201
|
request(event, data, ackName) {
|
|
323
202
|
return new Promise((resolve, reject) => {
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const handler = (responseData) => {
|
|
330
|
-
clearTimeout(timeout);
|
|
331
|
-
this._acks.delete(ackName);
|
|
332
|
-
resolve(responseData);
|
|
333
|
-
};
|
|
334
|
-
this._acks.set(ackName, { handler, timer: timeout });
|
|
335
|
-
this.emit(event, data, { ack: ackName });
|
|
203
|
+
const corrId = `${ackName}#${++this._ackCounter}`;
|
|
204
|
+
const t = setTimeout(() => { this._acks.delete(corrId); reject(new Error(`ACK '${ackName}' timeout`)); }, this.opts.ackTimeout);
|
|
205
|
+
t.unref();
|
|
206
|
+
this._acks.set(corrId, { handler: (d) => { clearTimeout(t); this._acks.delete(corrId); resolve(d); }, timer: t });
|
|
207
|
+
this.emit(event, data, { ack: ackName, _correlationId: corrId });
|
|
336
208
|
});
|
|
337
209
|
}
|
|
338
210
|
joinRoom(room) {
|
|
339
|
-
if (this.
|
|
211
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
|
|
340
212
|
try {
|
|
341
|
-
this.
|
|
213
|
+
this._tcpSock.write(encodeJoinFrame(room, this.opts.maxFrameSize));
|
|
342
214
|
}
|
|
343
215
|
catch { }
|
|
344
|
-
|
|
345
|
-
else {
|
|
216
|
+
else
|
|
346
217
|
this.emit('join-room', room);
|
|
347
|
-
}
|
|
348
218
|
return this;
|
|
349
219
|
}
|
|
350
220
|
leaveRoom(room) {
|
|
351
|
-
if (this.
|
|
221
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
|
|
352
222
|
try {
|
|
353
|
-
this.
|
|
223
|
+
this._tcpSock.write(encodeLeaveFrame(room));
|
|
354
224
|
}
|
|
355
225
|
catch { }
|
|
356
|
-
|
|
357
|
-
else {
|
|
226
|
+
else
|
|
358
227
|
this.emit('leave-room', room);
|
|
359
|
-
}
|
|
360
228
|
return this;
|
|
361
229
|
}
|
|
362
|
-
|
|
363
|
-
this.
|
|
364
|
-
this._hbTimer = setInterval(() => {
|
|
365
|
-
if (this.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
|
|
366
|
-
try {
|
|
367
|
-
this._tcpSocket.write(encodePingFrame());
|
|
368
|
-
}
|
|
369
|
-
catch { }
|
|
370
|
-
}
|
|
371
|
-
else if (this._nodeSocket && !this._nodeSocket.destroyed) {
|
|
372
|
-
try {
|
|
373
|
-
this._nodeSocket.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() })));
|
|
374
|
-
}
|
|
375
|
-
catch { }
|
|
376
|
-
}
|
|
377
|
-
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
378
|
-
this._ws.send(JSON.stringify({ event: 'pong', data: Date.now() }));
|
|
379
|
-
}
|
|
380
|
-
}, this.options.heartbeatInterval);
|
|
381
|
-
if (this._hbTimer && typeof this._hbTimer === 'object' && 'unref' in this._hbTimer) {
|
|
382
|
-
this._hbTimer.unref();
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
stopHeartbeat() {
|
|
386
|
-
if (this._hbTimer) {
|
|
387
|
-
clearInterval(this._hbTimer);
|
|
388
|
-
this._hbTimer = null;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
_getReconnectDelay() {
|
|
392
|
-
const base = this.options.reconnectionDelay;
|
|
393
|
-
const max = this.options.maxReconnectionDelay;
|
|
394
|
-
if (this.options.hooks.onReconnectDelay) {
|
|
395
|
-
const defaultDelay = Math.min(base * Math.pow(1.5, this._reconnectAttempts - 1), max);
|
|
396
|
-
const customDelay = this.options.hooks.onReconnectDelay({ attempt: this._reconnectAttempts, defaultDelay });
|
|
397
|
-
if (typeof customDelay === 'number')
|
|
398
|
-
return customDelay;
|
|
399
|
-
}
|
|
400
|
-
if (this.options.customReconnectDelay) {
|
|
401
|
-
return this.options.customReconnectDelay(this._reconnectAttempts, base, max);
|
|
402
|
-
}
|
|
403
|
-
const delay = Math.min(base * Math.pow(1.5, this._reconnectAttempts - 1), max);
|
|
404
|
-
const jitter = delay * 0.2 * Math.random();
|
|
405
|
-
return Math.floor(delay + jitter);
|
|
406
|
-
}
|
|
407
|
-
_drainQueue() {
|
|
408
|
-
if (this._messageQueue.length === 0)
|
|
409
|
-
return;
|
|
410
|
-
const messages = this._messageQueue.drain();
|
|
411
|
-
this.log.info('Draining message queue', { count: messages.length });
|
|
412
|
-
for (const msg of messages) {
|
|
413
|
-
try {
|
|
414
|
-
if (this.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
|
|
415
|
-
if (msg.opts.ack) {
|
|
416
|
-
this._tcpSocket.write(encodeAckReqFrame(msg.opts.ack, { event: msg.event, data: msg.data }, this.options.maxFrameSize));
|
|
417
|
-
}
|
|
418
|
-
else {
|
|
419
|
-
this._tcpSocket.write(encodeJsonFrame(msg.event, msg.data, this.options.maxFrameSize));
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
else if (this._nodeSocket && !this._nodeSocket.destroyed) {
|
|
423
|
-
const payload = { event: msg.event, data: msg.data };
|
|
424
|
-
if (msg.opts.ack)
|
|
425
|
-
payload._ackName = msg.opts.ack;
|
|
426
|
-
this._nodeSocket.write(createWSTextFrameMasked(JSON.stringify(payload)));
|
|
427
|
-
}
|
|
428
|
-
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
429
|
-
const payload = { event: msg.event, data: msg.data };
|
|
430
|
-
if (msg.opts.ack)
|
|
431
|
-
payload._ackName = msg.opts.ack;
|
|
432
|
-
this._ws.send(JSON.stringify(payload));
|
|
433
|
-
}
|
|
434
|
-
this._messagesSent++;
|
|
435
|
-
}
|
|
436
|
-
catch (err) {
|
|
437
|
-
this.log.error('Queue drain error', { event: msg.event, error: String(err) });
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
if (this.options.hooks.onQueueDrained) {
|
|
441
|
-
this.options.hooks.onQueueDrained({ count: messages.length });
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
_setState(state) {
|
|
445
|
-
const prev = this._state;
|
|
446
|
-
this._state = state;
|
|
447
|
-
if (prev !== state) {
|
|
448
|
-
this.log.debug('State changed', { from: prev, to: state });
|
|
449
|
-
if (this.options.hooks.onStateChange) {
|
|
450
|
-
this.options.hooks.onStateChange({ from: prev, to: state });
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
_cleanupAcks() {
|
|
455
|
-
for (const [, entry] of this._acks) {
|
|
456
|
-
if (entry.timer)
|
|
457
|
-
clearTimeout(entry.timer);
|
|
458
|
-
}
|
|
459
|
-
this._acks.clear();
|
|
460
|
-
}
|
|
461
|
-
_fullCleanup() {
|
|
462
|
-
this.stopHeartbeat();
|
|
463
|
-
this._cleanupAcks();
|
|
464
|
-
if (this._reconnectTimer) {
|
|
465
|
-
clearTimeout(this._reconnectTimer);
|
|
466
|
-
this._reconnectTimer = null;
|
|
467
|
-
}
|
|
468
|
-
this._nodeSocket = null;
|
|
469
|
-
this._wsParser = null;
|
|
470
|
-
this._tcpSocket = null;
|
|
471
|
-
this._tcpParser = null;
|
|
472
|
-
this._ws = null;
|
|
473
|
-
}
|
|
474
|
-
connect(callback) {
|
|
475
|
-
if (this._state === 'connected' || this._state === 'connecting') {
|
|
230
|
+
connect(cb) {
|
|
231
|
+
if (this._state === 'connected' || this._state === 'connecting')
|
|
476
232
|
return this;
|
|
233
|
+
if (this._state === 'reconnecting' && this._reconnTimer) {
|
|
234
|
+
clearTimeout(this._reconnTimer);
|
|
235
|
+
this._reconnTimer = null;
|
|
477
236
|
}
|
|
478
|
-
|
|
479
|
-
clearTimeout(this._reconnectTimer);
|
|
480
|
-
this._reconnectTimer = null;
|
|
481
|
-
}
|
|
482
|
-
this._isManualClose = false;
|
|
237
|
+
this._manualClose = false;
|
|
483
238
|
this._setState('connecting');
|
|
484
|
-
if (this.
|
|
239
|
+
if (this.opts.mode === 'tcp' && isNode)
|
|
485
240
|
this._connectTCP();
|
|
486
|
-
|
|
487
|
-
else if (isNode) {
|
|
241
|
+
else if (isNode)
|
|
488
242
|
this._connectNodeWS();
|
|
489
|
-
|
|
490
|
-
else {
|
|
243
|
+
else
|
|
491
244
|
this._connectBrowser();
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}, 50);
|
|
500
|
-
const safetyTimeout = setTimeout(() => clearInterval(check), this.options.ackTimeout);
|
|
501
|
-
safetyTimeout.unref();
|
|
245
|
+
if (cb) {
|
|
246
|
+
const check = setInterval(() => { if (this._state === 'connected') {
|
|
247
|
+
clearInterval(check);
|
|
248
|
+
cb();
|
|
249
|
+
} }, 50);
|
|
250
|
+
const safety = setTimeout(() => clearInterval(check), this.opts.ackTimeout);
|
|
251
|
+
safety.unref();
|
|
502
252
|
}
|
|
503
253
|
return this;
|
|
504
254
|
}
|
|
505
255
|
disconnect() {
|
|
506
|
-
this.
|
|
507
|
-
if (this.
|
|
256
|
+
this._manualClose = true;
|
|
257
|
+
if (this._tcpSock && !this._tcpSock.destroyed)
|
|
508
258
|
try {
|
|
509
|
-
this.
|
|
259
|
+
this._tcpSock.destroy();
|
|
510
260
|
}
|
|
511
261
|
catch { }
|
|
512
|
-
|
|
513
|
-
if (this._nodeSocket && !this._nodeSocket.destroyed) {
|
|
262
|
+
if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
514
263
|
try {
|
|
515
|
-
this.
|
|
264
|
+
this._nodeSock.write(createWSCloseFrameMasked());
|
|
516
265
|
}
|
|
517
266
|
catch { }
|
|
518
267
|
try {
|
|
519
|
-
this.
|
|
268
|
+
this._nodeSock.end();
|
|
520
269
|
}
|
|
521
270
|
catch { }
|
|
522
271
|
}
|
|
523
|
-
if (this._ws)
|
|
272
|
+
if (this._ws)
|
|
524
273
|
try {
|
|
525
274
|
this._ws.close();
|
|
526
275
|
}
|
|
527
276
|
catch { }
|
|
528
|
-
}
|
|
529
277
|
this._fullCleanup();
|
|
530
278
|
this._setState('disconnected');
|
|
531
279
|
return this;
|
|
532
280
|
}
|
|
533
|
-
isConnected() {
|
|
534
|
-
|
|
281
|
+
isConnected() { return this._state === 'connected'; }
|
|
282
|
+
/* ── Private ── */
|
|
283
|
+
_setState(s) {
|
|
284
|
+
const prev = this._state;
|
|
285
|
+
this._state = s;
|
|
286
|
+
if (prev !== s) {
|
|
287
|
+
this.log.debug('State', { from: prev, to: s });
|
|
288
|
+
this.opts.hooks.onStateChange?.({ from: prev, to: s });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
_startHB() {
|
|
292
|
+
this._stopHB();
|
|
293
|
+
this._hb = setInterval(() => {
|
|
294
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
|
|
295
|
+
try {
|
|
296
|
+
this._tcpSock.write(encodePingFrame());
|
|
297
|
+
}
|
|
298
|
+
catch { }
|
|
299
|
+
else if (this._nodeSock && !this._nodeSock.destroyed)
|
|
300
|
+
try {
|
|
301
|
+
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() })));
|
|
302
|
+
}
|
|
303
|
+
catch { }
|
|
304
|
+
else if (this._ws && this._ws.readyState === WebSocket.OPEN)
|
|
305
|
+
this._ws.send(JSON.stringify({ event: 'pong', data: Date.now() }));
|
|
306
|
+
}, this.opts.heartbeatInterval);
|
|
307
|
+
this._hb?.unref?.();
|
|
308
|
+
}
|
|
309
|
+
_stopHB() { if (this._hb) {
|
|
310
|
+
clearInterval(this._hb);
|
|
311
|
+
this._hb = null;
|
|
312
|
+
} }
|
|
313
|
+
_getDelay() {
|
|
314
|
+
const base = this.opts.reconnectionDelay, max = this.opts.maxReconnectionDelay;
|
|
315
|
+
const def = Math.min(base * Math.pow(1.5, this._reconnAttempts - 1), max);
|
|
316
|
+
const custom = this.opts.hooks.onReconnectDelay?.({ attempt: this._reconnAttempts, defaultDelay: def });
|
|
317
|
+
if (typeof custom === 'number')
|
|
318
|
+
return custom;
|
|
319
|
+
if (this.opts.customReconnectDelay)
|
|
320
|
+
return this.opts.customReconnectDelay(this._reconnAttempts, base, max);
|
|
321
|
+
return Math.floor(def + def * 0.2 * Math.random());
|
|
322
|
+
}
|
|
323
|
+
_drain() {
|
|
324
|
+
if (!this._mq.length)
|
|
325
|
+
return;
|
|
326
|
+
const msgs = this._mq.drain();
|
|
327
|
+
this.log.info('Draining queue', { count: msgs.length });
|
|
328
|
+
for (const m of msgs) {
|
|
329
|
+
try {
|
|
330
|
+
const p = { event: m.event, data: m.data };
|
|
331
|
+
if (m.opts.ack)
|
|
332
|
+
p._ackName = m.opts.ack;
|
|
333
|
+
if (m.opts._correlationId)
|
|
334
|
+
p._correlationId = m.opts._correlationId;
|
|
335
|
+
if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
|
|
336
|
+
this._tcpSock.write(m.opts.ack ? encodeAckReqFrame(m.opts.ack, p, this.opts.maxFrameSize) : encodeJsonFrame(m.event, m.data, this.opts.maxFrameSize));
|
|
337
|
+
}
|
|
338
|
+
else if (this._nodeSock && !this._nodeSock.destroyed) {
|
|
339
|
+
this._nodeSock.write(createWSTextFrameMasked(JSON.stringify(p)));
|
|
340
|
+
}
|
|
341
|
+
else if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
342
|
+
this._ws.send(JSON.stringify(p));
|
|
343
|
+
}
|
|
344
|
+
this._sent++;
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
this.log.error('Drain error', { event: m.event, error: String(e) });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
this.opts.hooks.onQueueDrained?.({ count: msgs.length });
|
|
351
|
+
}
|
|
352
|
+
_cleanupAcks() { for (const [, e] of this._acks)
|
|
353
|
+
if (e.timer)
|
|
354
|
+
clearTimeout(e.timer); this._acks.clear(); }
|
|
355
|
+
_fullCleanup() {
|
|
356
|
+
this._stopHB();
|
|
357
|
+
this._cleanupAcks();
|
|
358
|
+
if (this._reconnTimer) {
|
|
359
|
+
clearTimeout(this._reconnTimer);
|
|
360
|
+
this._reconnTimer = null;
|
|
361
|
+
}
|
|
362
|
+
this._nodeSock = null;
|
|
363
|
+
this._wsParser = null;
|
|
364
|
+
this._tcpSock = null;
|
|
365
|
+
this._tcpParser = null;
|
|
366
|
+
this._ws = null;
|
|
535
367
|
}
|
|
368
|
+
_tryReconnect(fn) {
|
|
369
|
+
if (this._manualClose || !this.opts.reconnection)
|
|
370
|
+
return;
|
|
371
|
+
if (this._reconnAttempts >= this.opts.reconnectionAttempts) {
|
|
372
|
+
this.log.warn('Max reconnect attempts');
|
|
373
|
+
this.events.get('reconnect_failed')?.(undefined);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this._reconnAttempts++;
|
|
377
|
+
this._setState('reconnecting');
|
|
378
|
+
const delay = this._getDelay();
|
|
379
|
+
this.log.info('Reconnecting', { attempt: this._reconnAttempts, delay });
|
|
380
|
+
this.events.get('reconnecting')?.(this._reconnAttempts);
|
|
381
|
+
this._reconnTimer = setTimeout(() => { this._reconnTimer = null; if (!this._manualClose)
|
|
382
|
+
fn(); }, delay);
|
|
383
|
+
}
|
|
384
|
+
_onConnected() {
|
|
385
|
+
this._setState('connected');
|
|
386
|
+
this._reconnAttempts = 0;
|
|
387
|
+
this._connTime = Date.now();
|
|
388
|
+
this.events.get('connect')?.(undefined);
|
|
389
|
+
this._startHB();
|
|
390
|
+
this._drain();
|
|
391
|
+
}
|
|
392
|
+
/* ── Browser WS ── */
|
|
536
393
|
_connectBrowser() {
|
|
537
394
|
try {
|
|
538
395
|
const ws = new WebSocket(this.url);
|
|
539
396
|
ws.binaryType = 'arraybuffer';
|
|
540
|
-
ws.onopen = () =>
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
this.log.info('Browser WS connected');
|
|
545
|
-
const handler = this.events.get('connect');
|
|
546
|
-
if (handler)
|
|
547
|
-
handler(undefined);
|
|
548
|
-
this.startHeartbeat();
|
|
549
|
-
this._drainQueue();
|
|
550
|
-
};
|
|
551
|
-
ws.onmessage = (e) => {
|
|
552
|
-
this._messagesReceived++;
|
|
553
|
-
this._handleBrowserMessage(e);
|
|
554
|
-
};
|
|
555
|
-
ws.onclose = (e) => {
|
|
556
|
-
this._setState('disconnected');
|
|
557
|
-
this._fullCleanup();
|
|
558
|
-
const handler = this.events.get('disconnect');
|
|
559
|
-
if (handler)
|
|
560
|
-
handler({ code: e.code, reason: e.reason });
|
|
561
|
-
this._tryReconnect(() => this._connectBrowser());
|
|
562
|
-
};
|
|
563
|
-
ws.onerror = () => {
|
|
564
|
-
this._lastError = new Error('WebSocket error');
|
|
565
|
-
const handler = this.events.get('error');
|
|
566
|
-
if (handler)
|
|
567
|
-
handler(this._lastError);
|
|
568
|
-
if (this.options.hooks.onError) {
|
|
569
|
-
this.options.hooks.onError({ error: this._lastError, context: 'browser-ws' });
|
|
570
|
-
}
|
|
571
|
-
};
|
|
397
|
+
ws.onopen = () => this._onConnected();
|
|
398
|
+
ws.onmessage = (e) => { this._recv++; this._handleBrowserMsg(e); };
|
|
399
|
+
ws.onclose = (e) => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.({ code: e.code, reason: e.reason }); this._tryReconnect(() => this._connectBrowser()); };
|
|
400
|
+
ws.onerror = () => { this._lastErr = new Error('WebSocket error'); this.events.get('error')?.(this._lastErr); this.opts.hooks.onError?.({ error: this._lastErr, context: 'browser-ws' }); };
|
|
572
401
|
this._ws = ws;
|
|
573
402
|
}
|
|
574
|
-
catch (
|
|
575
|
-
this.
|
|
403
|
+
catch (e) {
|
|
404
|
+
this._lastErr = e instanceof Error ? e : new Error(String(e));
|
|
576
405
|
this._setState('disconnected');
|
|
577
406
|
this._tryReconnect(() => this._connectBrowser());
|
|
578
407
|
}
|
|
579
408
|
}
|
|
580
|
-
|
|
409
|
+
_handleBrowserMsg(e) {
|
|
581
410
|
try {
|
|
582
411
|
if (e.data instanceof ArrayBuffer) {
|
|
583
|
-
const
|
|
584
|
-
let
|
|
585
|
-
for (let i = 0; i <
|
|
586
|
-
if (
|
|
587
|
-
|
|
412
|
+
const v = new Uint8Array(e.data);
|
|
413
|
+
let end = -1;
|
|
414
|
+
for (let i = 0; i < v.length; i++)
|
|
415
|
+
if (v[i] === 0) {
|
|
416
|
+
end = i;
|
|
588
417
|
break;
|
|
589
418
|
}
|
|
590
|
-
|
|
591
|
-
if (headerEnd === -1)
|
|
419
|
+
if (end === -1)
|
|
592
420
|
return;
|
|
593
|
-
const
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
const handler = this.events.get(header.event);
|
|
600
|
-
if (handler)
|
|
601
|
-
handler(buffer);
|
|
602
|
-
if (this._wildcardHandler) {
|
|
603
|
-
this._wildcardHandler({ event: header.event, data: buffer, isBinary: true, buffer });
|
|
604
|
-
}
|
|
421
|
+
const hdr = JSON.parse(new TextDecoder().decode(v.slice(0, end)));
|
|
422
|
+
const buf = v.slice(end + 1).buffer;
|
|
423
|
+
this.opts.hooks.onMessage?.({ event: hdr.event, data: buf, isBinary: true });
|
|
424
|
+
this.events.get(hdr.event)?.(buf);
|
|
425
|
+
this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
|
|
605
426
|
return;
|
|
606
427
|
}
|
|
607
|
-
const msg = JSON.parse(e.data);
|
|
608
|
-
const { event, data, _isAck } = msg;
|
|
428
|
+
const msg = JSON.parse(e.data), { event, data, _isAck } = msg;
|
|
609
429
|
if (event === 'ping')
|
|
610
430
|
return;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
431
|
+
this.opts.hooks.onMessage?.({ event, data, isBinary: false });
|
|
432
|
+
if (_isAck) {
|
|
433
|
+
const key = msg._correlationId || event;
|
|
434
|
+
if (this._acks.has(key)) {
|
|
435
|
+
const entry = this._acks.get(key);
|
|
436
|
+
if (entry.timer)
|
|
437
|
+
clearTimeout(entry.timer);
|
|
438
|
+
this._acks.delete(key);
|
|
439
|
+
entry.handler(data);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
621
442
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
handler(data);
|
|
625
|
-
if (this._wildcardHandler)
|
|
626
|
-
this._wildcardHandler({ event, data });
|
|
443
|
+
this.events.get(event)?.(data);
|
|
444
|
+
this._wild?.({ event, data });
|
|
627
445
|
}
|
|
628
446
|
catch { }
|
|
629
447
|
}
|
|
448
|
+
/* ── Node WS ── */
|
|
630
449
|
async _connectNodeWS() {
|
|
631
450
|
try {
|
|
632
|
-
await
|
|
451
|
+
await loadModules();
|
|
633
452
|
if (!_http)
|
|
634
453
|
return;
|
|
635
|
-
const parsed = new URL(this.url);
|
|
636
|
-
const isSecure = parsed.protocol === 'wss:' || this.options.tls;
|
|
454
|
+
const parsed = new URL(this.url), secure = parsed.protocol === 'wss:' || this.opts.tls;
|
|
637
455
|
const key = generateWSKey();
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
'Sec-WebSocket-Version': '13',
|
|
643
|
-
...this.options.headers,
|
|
644
|
-
};
|
|
645
|
-
const reqModule = isSecure && _https ? _https : _http;
|
|
646
|
-
const req = reqModule.request({
|
|
647
|
-
hostname: parsed.hostname,
|
|
648
|
-
port: parseInt(parsed.port) || (isSecure ? 443 : 80),
|
|
649
|
-
path: parsed.pathname + parsed.search,
|
|
650
|
-
method: 'GET',
|
|
651
|
-
headers: requestHeaders,
|
|
652
|
-
rejectUnauthorized: this.options.rejectUnauthorized,
|
|
653
|
-
});
|
|
654
|
-
req.setTimeout(this.options.ackTimeout, () => {
|
|
655
|
-
req.destroy(new Error('Connection timeout'));
|
|
656
|
-
});
|
|
456
|
+
const hdrs = { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13', ...this.opts.headers };
|
|
457
|
+
const mod = secure && _https ? _https : _http;
|
|
458
|
+
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
|
+
req.setTimeout(this.opts.ackTimeout, () => req.destroy(new Error('Timeout')));
|
|
657
460
|
req.on('upgrade', (_res, socket, head) => {
|
|
658
|
-
this.
|
|
659
|
-
this._wsParser = new WSFrameParser(this.
|
|
660
|
-
if (head.length > 0)
|
|
661
|
-
this.
|
|
662
|
-
|
|
663
|
-
socket.on('
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
this._fullCleanup();
|
|
669
|
-
const handler = this.events.get('disconnect');
|
|
670
|
-
if (handler)
|
|
671
|
-
handler(undefined);
|
|
672
|
-
this._tryReconnect(() => this._connectNodeWS());
|
|
673
|
-
});
|
|
674
|
-
socket.on('error', (err) => {
|
|
675
|
-
this._lastError = err;
|
|
676
|
-
const handler = this.events.get('error');
|
|
677
|
-
if (handler)
|
|
678
|
-
handler(err);
|
|
679
|
-
if (this.options.hooks.onError) {
|
|
680
|
-
this.options.hooks.onError({ error: err, context: 'node-ws' });
|
|
681
|
-
}
|
|
682
|
-
});
|
|
683
|
-
socket.on('drain', () => {
|
|
684
|
-
socket.resume();
|
|
685
|
-
});
|
|
686
|
-
this._setState('connected');
|
|
687
|
-
this._reconnectAttempts = 0;
|
|
688
|
-
this._connectTime = Date.now();
|
|
689
|
-
this.log.info('Node.js WS connected', { secure: isSecure });
|
|
690
|
-
const connectHandler = this.events.get('connect');
|
|
691
|
-
if (connectHandler)
|
|
692
|
-
connectHandler(undefined);
|
|
693
|
-
this.startHeartbeat();
|
|
694
|
-
this._drainQueue();
|
|
695
|
-
});
|
|
696
|
-
req.on('error', (err) => {
|
|
697
|
-
this._lastError = err;
|
|
698
|
-
const handler = this.events.get('error');
|
|
699
|
-
if (handler)
|
|
700
|
-
handler(err);
|
|
701
|
-
this._tryReconnect(() => this._connectNodeWS());
|
|
461
|
+
this._nodeSock = socket;
|
|
462
|
+
this._wsParser = new WSFrameParser(this.opts.maxFrameSize);
|
|
463
|
+
if (head.length > 0)
|
|
464
|
+
this._processNodeWS(head);
|
|
465
|
+
socket.on('data', (d) => this._processNodeWS(d));
|
|
466
|
+
socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectNodeWS()); });
|
|
467
|
+
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', () => socket.resume());
|
|
469
|
+
this.log.info('Node WS connected', { secure });
|
|
470
|
+
this._onConnected();
|
|
702
471
|
});
|
|
472
|
+
req.on('error', (e) => { this._lastErr = e; this.events.get('error')?.(e); this._tryReconnect(() => this._connectNodeWS()); });
|
|
703
473
|
req.end();
|
|
704
474
|
}
|
|
705
|
-
catch (
|
|
706
|
-
this.
|
|
475
|
+
catch (e) {
|
|
476
|
+
this._lastErr = e instanceof Error ? e : new Error(String(e));
|
|
707
477
|
this._tryReconnect(() => this._connectNodeWS());
|
|
708
478
|
}
|
|
709
479
|
}
|
|
710
|
-
|
|
480
|
+
_processNodeWS(data) {
|
|
711
481
|
if (!this._wsParser)
|
|
712
482
|
return;
|
|
713
483
|
let frames;
|
|
@@ -715,175 +485,116 @@ class StelarClient {
|
|
|
715
485
|
frames = this._wsParser.feed(data);
|
|
716
486
|
}
|
|
717
487
|
catch {
|
|
718
|
-
this.log.error('WS
|
|
488
|
+
this.log.error('WS parse error');
|
|
719
489
|
return;
|
|
720
490
|
}
|
|
721
|
-
for (const
|
|
722
|
-
this.
|
|
723
|
-
this.
|
|
491
|
+
for (const f of frames) {
|
|
492
|
+
this._recv++;
|
|
493
|
+
this._handleNodeFrame(f);
|
|
724
494
|
}
|
|
725
495
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if (this._nodeSocket && !this._nodeSocket.destroyed) {
|
|
496
|
+
_handleNodeFrame(f) {
|
|
497
|
+
if (f.opcode === OP_PING) {
|
|
498
|
+
if (this._nodeSock && !this._nodeSock.destroyed)
|
|
730
499
|
try {
|
|
731
|
-
this.
|
|
500
|
+
this._nodeSock.write(createWSPongFrameMasked());
|
|
732
501
|
}
|
|
733
502
|
catch { }
|
|
734
|
-
}
|
|
735
503
|
return;
|
|
736
504
|
}
|
|
737
|
-
if (opcode === OP_PONG)
|
|
505
|
+
if (f.opcode === OP_PONG)
|
|
738
506
|
return;
|
|
739
|
-
if (opcode === OP_CLOSE) {
|
|
740
|
-
if (this.
|
|
507
|
+
if (f.opcode === OP_CLOSE) {
|
|
508
|
+
if (this._nodeSock && !this._nodeSock.destroyed)
|
|
741
509
|
try {
|
|
742
|
-
this.
|
|
510
|
+
this._nodeSock.end();
|
|
743
511
|
}
|
|
744
512
|
catch { }
|
|
745
|
-
}
|
|
746
513
|
return;
|
|
747
514
|
}
|
|
748
|
-
if (opcode === OP_TEXT) {
|
|
515
|
+
if (f.opcode === OP_TEXT) {
|
|
749
516
|
try {
|
|
750
|
-
const msg = JSON.parse(payload.toString('utf8'));
|
|
751
|
-
const { event, data, _isAck } = msg;
|
|
517
|
+
const msg = JSON.parse(f.payload.toString('utf8')), { event, data, _isAck } = msg;
|
|
752
518
|
if (event === 'ping')
|
|
753
519
|
return;
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
520
|
+
this.opts.hooks.onMessage?.({ event, data, isBinary: false });
|
|
521
|
+
if (_isAck) {
|
|
522
|
+
const key = msg._correlationId || event;
|
|
523
|
+
if (this._acks.has(key)) {
|
|
524
|
+
const e = this._acks.get(key);
|
|
525
|
+
if (e.timer)
|
|
526
|
+
clearTimeout(e.timer);
|
|
527
|
+
this._acks.delete(key);
|
|
528
|
+
e.handler(data);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
764
531
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
handler(data);
|
|
768
|
-
if (this._wildcardHandler)
|
|
769
|
-
this._wildcardHandler({ event, data });
|
|
532
|
+
this.events.get(event)?.(data);
|
|
533
|
+
this._wild?.({ event, data });
|
|
770
534
|
}
|
|
771
535
|
catch { }
|
|
772
536
|
return;
|
|
773
537
|
}
|
|
774
|
-
if (opcode === OP_BINARY) {
|
|
538
|
+
if (f.opcode === OP_BINARY) {
|
|
775
539
|
try {
|
|
776
|
-
let
|
|
777
|
-
for (let i = 0; i < payload.length; i++)
|
|
778
|
-
if (payload[i] === 0) {
|
|
779
|
-
|
|
540
|
+
let end = -1;
|
|
541
|
+
for (let i = 0; i < f.payload.length; i++)
|
|
542
|
+
if (f.payload[i] === 0) {
|
|
543
|
+
end = i;
|
|
780
544
|
break;
|
|
781
545
|
}
|
|
782
|
-
|
|
783
|
-
if (headerEnd === -1)
|
|
546
|
+
if (end === -1)
|
|
784
547
|
return;
|
|
785
|
-
const
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
const handler = this.events.get(header.event);
|
|
792
|
-
if (handler)
|
|
793
|
-
handler(buffer);
|
|
794
|
-
if (this._wildcardHandler)
|
|
795
|
-
this._wildcardHandler({ event: header.event, data: buffer, isBinary: true, buffer });
|
|
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 });
|
|
796
553
|
}
|
|
797
554
|
catch { }
|
|
798
555
|
}
|
|
799
556
|
}
|
|
800
|
-
|
|
557
|
+
/* ── TCP ── */
|
|
801
558
|
async _connectTCP() {
|
|
802
559
|
try {
|
|
803
|
-
await
|
|
560
|
+
await loadModules();
|
|
804
561
|
if (!_net)
|
|
805
562
|
return;
|
|
806
|
-
const parsed = new URL(this.url);
|
|
807
|
-
const
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
if (this.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
handler(undefined);
|
|
831
|
-
this.startHeartbeat();
|
|
832
|
-
this._drainQueue();
|
|
833
|
-
});
|
|
834
|
-
socket.on('data', (data) => {
|
|
835
|
-
if (!this._tcpParser)
|
|
836
|
-
return;
|
|
837
|
-
let frames;
|
|
838
|
-
try {
|
|
839
|
-
frames = this._tcpParser.feed(data);
|
|
840
|
-
}
|
|
841
|
-
catch {
|
|
842
|
-
this.log.error('TCP frame parse error');
|
|
843
|
-
socket.destroy();
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
for (const frame of frames) {
|
|
847
|
-
this._messagesReceived++;
|
|
848
|
-
this._handleTCPFrame(frame);
|
|
849
|
-
}
|
|
850
|
-
});
|
|
851
|
-
socket.on('close', () => {
|
|
852
|
-
this._setState('disconnected');
|
|
853
|
-
this._fullCleanup();
|
|
854
|
-
const handler = this.events.get('disconnect');
|
|
855
|
-
if (handler)
|
|
856
|
-
handler(undefined);
|
|
857
|
-
this._tryReconnect(() => this._connectTCP());
|
|
858
|
-
});
|
|
859
|
-
socket.on('error', (err) => {
|
|
860
|
-
this._lastError = err;
|
|
861
|
-
const handler = this.events.get('error');
|
|
862
|
-
if (handler)
|
|
863
|
-
handler(err);
|
|
864
|
-
if (this.options.hooks.onError) {
|
|
865
|
-
this.options.hooks.onError({ error: err, context: 'tcp' });
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
socket.on('drain', () => {
|
|
869
|
-
socket.resume();
|
|
870
|
-
});
|
|
871
|
-
this._tcpSocket = socket;
|
|
872
|
-
}
|
|
873
|
-
catch (err) {
|
|
874
|
-
this._lastError = err instanceof Error ? err : new Error(String(err));
|
|
563
|
+
const parsed = new URL(this.url), port = parseInt(parsed.port) + 1 || 3001, host = parsed.hostname || 'localhost';
|
|
564
|
+
const sockOpts = { host, port };
|
|
565
|
+
const socket = this.opts.tls && _tls ? _tls.connect({ ...sockOpts, rejectUnauthorized: this.opts.rejectUnauthorized }) : _net.createConnection(sockOpts);
|
|
566
|
+
socket.setTimeout(this.opts.ackTimeout, () => socket.destroy(new Error('TCP timeout')));
|
|
567
|
+
socket.on('connect', () => { socket.setTimeout(0); this._tcpParser = new FrameParser(this.opts.maxFrameSize); this.log.info('TCP connected', { host, port }); this._onConnected(); });
|
|
568
|
+
socket.on('data', (d) => { if (!this._tcpParser)
|
|
569
|
+
return; let frames; try {
|
|
570
|
+
frames = this._tcpParser.feed(d);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
this.log.error('TCP parse error');
|
|
574
|
+
socket.destroy();
|
|
575
|
+
return;
|
|
576
|
+
} for (const f of frames) {
|
|
577
|
+
this._recv++;
|
|
578
|
+
this._handleTCPFrame(f);
|
|
579
|
+
} });
|
|
580
|
+
socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectTCP()); });
|
|
581
|
+
socket.on('error', (e) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'tcp' }); });
|
|
582
|
+
socket.on('drain', () => socket.resume());
|
|
583
|
+
this._tcpSock = socket;
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
this._lastErr = e instanceof Error ? e : new Error(String(e));
|
|
875
587
|
this._tryReconnect(() => this._connectTCP());
|
|
876
588
|
}
|
|
877
589
|
}
|
|
878
|
-
_handleTCPFrame(
|
|
879
|
-
const { type, event, payload } =
|
|
590
|
+
_handleTCPFrame(f) {
|
|
591
|
+
const { type, event, payload } = f;
|
|
880
592
|
if (type === FRAME_PING) {
|
|
881
|
-
if (this.
|
|
593
|
+
if (this._tcpSock && !this._tcpSock.destroyed)
|
|
882
594
|
try {
|
|
883
|
-
this.
|
|
595
|
+
this._tcpSock.write(encodePongFrame());
|
|
884
596
|
}
|
|
885
597
|
catch { }
|
|
886
|
-
}
|
|
887
598
|
return;
|
|
888
599
|
}
|
|
889
600
|
if (type === FRAME_PONG)
|
|
@@ -893,72 +604,39 @@ class StelarClient {
|
|
|
893
604
|
return;
|
|
894
605
|
}
|
|
895
606
|
if (type === FRAME_ACK_RES) {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
this._acks.
|
|
903
|
-
|
|
607
|
+
try {
|
|
608
|
+
const raw = JSON.parse(payload.toString('utf8'));
|
|
609
|
+
const data = raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
|
|
610
|
+
const corrId = raw && typeof raw === 'object' && '_correlationId' in raw ? String(raw._correlationId) : undefined;
|
|
611
|
+
const key = corrId || event;
|
|
612
|
+
if (this._acks.has(key)) {
|
|
613
|
+
const e = this._acks.get(key);
|
|
614
|
+
if (e.timer)
|
|
615
|
+
clearTimeout(e.timer);
|
|
616
|
+
this._acks.delete(key);
|
|
617
|
+
e.handler(data);
|
|
904
618
|
}
|
|
905
|
-
catch { }
|
|
906
619
|
}
|
|
620
|
+
catch { }
|
|
907
621
|
return;
|
|
908
622
|
}
|
|
909
623
|
if (type === FRAME_JSON) {
|
|
910
624
|
try {
|
|
911
625
|
const data = JSON.parse(payload.toString('utf8'));
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
const handler = this.events.get(event);
|
|
916
|
-
if (handler)
|
|
917
|
-
handler(data);
|
|
918
|
-
if (this._wildcardHandler)
|
|
919
|
-
this._wildcardHandler({ event, data });
|
|
626
|
+
this.opts.hooks.onMessage?.({ event, data, isBinary: false });
|
|
627
|
+
this.events.get(event)?.(data);
|
|
628
|
+
this._wild?.({ event, data });
|
|
920
629
|
}
|
|
921
630
|
catch { }
|
|
922
631
|
return;
|
|
923
632
|
}
|
|
924
633
|
if (type === FRAME_BINARY) {
|
|
925
634
|
const copy = Buffer.from(payload);
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
const handler = this.events.get(event);
|
|
931
|
-
if (handler)
|
|
932
|
-
handler(buffer);
|
|
933
|
-
if (this._wildcardHandler)
|
|
934
|
-
this._wildcardHandler({ event, data: buffer, isBinary: true, buffer });
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
_tryReconnect(connectFn) {
|
|
938
|
-
if (this._isManualClose)
|
|
939
|
-
return;
|
|
940
|
-
if (!this.options.reconnection)
|
|
941
|
-
return;
|
|
942
|
-
if (this._reconnectAttempts >= this.options.reconnectionAttempts) {
|
|
943
|
-
this.log.warn('Max reconnection attempts reached', { attempts: this._reconnectAttempts });
|
|
944
|
-
const handler = this.events.get('reconnect_failed');
|
|
945
|
-
if (handler)
|
|
946
|
-
handler(undefined);
|
|
947
|
-
return;
|
|
635
|
+
const buf = copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength);
|
|
636
|
+
this.opts.hooks.onMessage?.({ event, data: buf, isBinary: true });
|
|
637
|
+
this.events.get(event)?.(buf);
|
|
638
|
+
this._wild?.({ event, data: buf, isBinary: true, buffer: buf });
|
|
948
639
|
}
|
|
949
|
-
this._reconnectAttempts++;
|
|
950
|
-
this._setState('reconnecting');
|
|
951
|
-
const delay = this._getReconnectDelay();
|
|
952
|
-
this.log.info('Reconnecting', { attempt: this._reconnectAttempts, delay });
|
|
953
|
-
const handler = this.events.get('reconnecting');
|
|
954
|
-
if (handler)
|
|
955
|
-
handler(this._reconnectAttempts);
|
|
956
|
-
this._reconnectTimer = setTimeout(() => {
|
|
957
|
-
this._reconnectTimer = null;
|
|
958
|
-
if (!this._isManualClose) {
|
|
959
|
-
connectFn();
|
|
960
|
-
}
|
|
961
|
-
}, delay);
|
|
962
640
|
}
|
|
963
641
|
}
|
|
964
642
|
export default StelarClient;
|