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/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.js modules so browser builds don't fail */
12
- let _http = null;
13
- let _net = null;
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 MessageQueue {
25
- constructor(maxSize = 100) {
26
- this.queue = [];
27
- this.maxSize = maxSize;
28
- }
29
- push(msg) {
30
- if (this.queue.length >= this.maxSize) {
31
- this.queue.shift();
32
- }
33
- this.queue.push(msg);
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', options = {}) {
30
+ constructor(urlOrPort = 'localhost:3000', o = {}) {
50
31
  this.events = new Map();
51
- this._wildcardHandler = null;
32
+ this._wild = null;
52
33
  this._acks = new Map();
53
34
  this._state = 'disconnected';
54
- this._reconnectAttempts = 0;
55
- this._hbTimer = null;
56
- this._isManualClose = false;
35
+ this._reconnAttempts = 0;
36
+ this._hb = null;
37
+ this._manualClose = false;
57
38
  this.id = null;
58
- this._reconnectTimer = null;
59
- this._messagesSent = 0;
60
- this._messagesReceived = 0;
61
- this._connectTime = 0;
62
- this._lastError = null;
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._nodeSocket = null;
46
+ this._nodeSock = null;
65
47
  this._wsParser = null;
66
- this._tcpSocket = null;
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
- this.options = {
78
- reconnection: options.reconnection !== false,
79
- reconnectionAttempts: options.reconnectionAttempts || 10,
80
- reconnectionDelay: options.reconnectionDelay || 1000,
81
- maxReconnectionDelay: options.maxReconnectionDelay || 30000,
82
- heartbeatInterval: options.heartbeatInterval || 30000,
83
- ackTimeout: options.ackTimeout || 5000,
84
- mode: options.mode || 'ws',
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._messageQueue = new MessageQueue(this.options.messageQueueSize);
96
- if (this.options.logger === false) {
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._messagesSent; }
113
- getMessagesReceived() { return this._messagesReceived; }
114
- getLastError() { return this._lastError; }
115
- getQueueSize() { return this._messageQueue.length; }
116
- getConnectTime() { return this._connectTime; }
117
- setUrl(url) {
118
- this.url = url;
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.options.reconnection,
153
- reconnectionAttempts: this.options.reconnectionAttempts,
154
- reconnectionDelay: this.options.reconnectionDelay,
155
- maxReconnectionDelay: this.options.maxReconnectionDelay,
156
- heartbeatInterval: this.options.heartbeatInterval,
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(event, handler) {
166
- this.events.set(event, handler);
167
- return this;
168
- }
169
- off(event, handler) {
170
- if (this.events.get(event) === handler) {
171
- this.events.delete(event);
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 name', { event });
110
+ this.log.warn('Invalid event', { event });
207
111
  return this;
208
112
  }
209
- if (this.options.hooks.onBeforeEmit) {
210
- const result = this.options.hooks.onBeforeEmit({ event, data });
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 serialized = JSON.stringify(data);
216
- if (serialized.length > this.options.maxPayloadSize) {
217
- this.log.warn('Payload exceeds max size', { event, size: serialized.length });
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.options.reconnection) {
227
- this._messageQueue.push({ event, data, opts, timestamp: Date.now() });
228
- this.log.debug('Message queued', { event, queueSize: this._messageQueue.length });
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
- if (this.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
237
- if (opts.ack) {
238
- this._tcpSocket.write(encodeAckReqFrame(opts.ack, { event, data }, this.options.maxFrameSize));
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._tcpSocket.write(encodeJsonFrame(event, data, this.options.maxFrameSize));
141
+ this._mq.push({ event, data, opts, ts: Date.now() });
142
+ return;
242
143
  }
243
- }
244
- else if (this._nodeSocket && !this._nodeSocket.destroyed) {
245
- const payload = { event, data };
246
- if (opts.ack)
247
- payload._ackName = opts.ack;
248
- this._nodeSocket.write(createWSTextFrameMasked(JSON.stringify(payload)));
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
- this._messageQueue.push({ event, data, opts, timestamp: Date.now() });
258
- return this;
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 (err) {
263
- this.log.error('Emit error', { event, error: String(err) });
264
- if (this.options.hooks.onError) {
265
- this.options.hooks.onError({ error: err instanceof Error ? err : new Error(String(err)), context: 'emit' });
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.options.maxPayloadSize) {
273
- this.log.warn('Binary payload exceeds max size', { event, size: data.byteLength });
164
+ if (data.byteLength > this.opts.maxPayloadSize) {
165
+ this.log.warn('Binary too large', { event });
274
166
  return this;
275
167
  }
276
- if (this.options.hooks.onBeforeEmit) {
277
- const result = this.options.hooks.onBeforeEmit({ event, data });
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.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
285
- this._tcpSocket.write(encodeBinaryFrame(event, new Uint8Array(data), this.options.maxFrameSize));
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._nodeSocket && !this._nodeSocket.destroyed) {
288
- const header = JSON.stringify({ event });
289
- const headerBytes = Buffer.from(header, 'utf8');
290
- const combined = Buffer.alloc(headerBytes.length + 1 + data.byteLength);
291
- headerBytes.copy(combined, 0);
292
- combined[headerBytes.length] = 0;
293
- combined.set(new Uint8Array(data), headerBytes.length + 1);
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 header = JSON.stringify({ event });
298
- const headerBytes = new TextEncoder().encode(header);
299
- const combined = new Uint8Array(headerBytes.length + 1 + data.byteLength);
300
- combined.set(headerBytes, 0);
301
- combined[headerBytes.length] = 0;
302
- combined.set(new Uint8Array(data), headerBytes.length + 1);
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._messagesSent++;
192
+ this._sent++;
306
193
  }
307
- catch (err) {
308
- this.log.error('Binary emit error', { event, error: String(err) });
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
- return this.emitBinary('file', file);
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 timeout = setTimeout(() => {
325
- this._acks.delete(ackName);
326
- reject(new Error(`ACK '${ackName}' timeout after ${this.options.ackTimeout}ms`));
327
- }, this.options.ackTimeout);
328
- timeout.unref();
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.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
211
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
340
212
  try {
341
- this._tcpSocket.write(encodeJoinFrame(room, this.options.maxFrameSize));
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.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
221
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed)
352
222
  try {
353
- this._tcpSocket.write(encodeLeaveFrame(room));
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
- startHeartbeat() {
363
- this.stopHeartbeat();
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
- if (this._state === 'reconnecting' && this._reconnectTimer) {
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.options.mode === 'tcp' && isNode) {
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
- if (callback) {
494
- const check = setInterval(() => {
495
- if (this._state === 'connected') {
496
- clearInterval(check);
497
- callback();
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._isManualClose = true;
507
- if (this._tcpSocket && !this._tcpSocket.destroyed) {
256
+ this._manualClose = true;
257
+ if (this._tcpSock && !this._tcpSock.destroyed)
508
258
  try {
509
- this._tcpSocket.destroy();
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._nodeSocket.write(createWSCloseFrameMasked());
264
+ this._nodeSock.write(createWSCloseFrameMasked());
516
265
  }
517
266
  catch { }
518
267
  try {
519
- this._nodeSocket.end();
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
- return this._state === 'connected';
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
- this._setState('connected');
542
- this._reconnectAttempts = 0;
543
- this._connectTime = Date.now();
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 (err) {
575
- this._lastError = err instanceof Error ? err : new Error(String(err));
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
- _handleBrowserMessage(e) {
409
+ _handleBrowserMsg(e) {
581
410
  try {
582
411
  if (e.data instanceof ArrayBuffer) {
583
- const view = new Uint8Array(e.data);
584
- let headerEnd = -1;
585
- for (let i = 0; i < view.length; i++) {
586
- if (view[i] === 0) {
587
- headerEnd = i;
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 headerStr = new TextDecoder().decode(view.slice(0, headerEnd));
594
- const header = JSON.parse(headerStr);
595
- const buffer = view.slice(headerEnd + 1).buffer;
596
- if (this.options.hooks.onMessage) {
597
- this.options.hooks.onMessage({ event: header.event, data: buffer, isBinary: true });
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
- if (this.options.hooks.onMessage) {
612
- this.options.hooks.onMessage({ event, data, isBinary: false });
613
- }
614
- if (_isAck && this._acks.has(event)) {
615
- const entry = this._acks.get(event);
616
- if (entry.timer)
617
- clearTimeout(entry.timer);
618
- this._acks.delete(event);
619
- entry.handler(data);
620
- return;
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
- const handler = this.events.get(event);
623
- if (handler)
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 loadNodeModules();
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 requestHeaders = {
639
- 'Upgrade': 'websocket',
640
- 'Connection': 'Upgrade',
641
- 'Sec-WebSocket-Key': key,
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._nodeSocket = socket;
659
- this._wsParser = new WSFrameParser(this.options.maxFrameSize);
660
- if (head.length > 0) {
661
- this._processNodeWSData(head);
662
- }
663
- socket.on('data', (data) => {
664
- this._processNodeWSData(data);
665
- });
666
- socket.on('close', () => {
667
- this._setState('disconnected');
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 (err) {
706
- this._lastError = err instanceof Error ? err : new Error(String(err));
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
- _processNodeWSData(data) {
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 frame parse error');
488
+ this.log.error('WS parse error');
719
489
  return;
720
490
  }
721
- for (const frame of frames) {
722
- this._messagesReceived++;
723
- this._handleNodeWSFrame(frame);
491
+ for (const f of frames) {
492
+ this._recv++;
493
+ this._handleNodeFrame(f);
724
494
  }
725
495
  }
726
- _handleNodeWSFrame(frame) {
727
- const { opcode, payload } = frame;
728
- if (opcode === OP_PING) {
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._nodeSocket.write(createWSPongFrameMasked());
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._nodeSocket && !this._nodeSocket.destroyed) {
507
+ if (f.opcode === OP_CLOSE) {
508
+ if (this._nodeSock && !this._nodeSock.destroyed)
741
509
  try {
742
- this._nodeSocket.end();
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
- if (this.options.hooks.onMessage) {
755
- this.options.hooks.onMessage({ event, data, isBinary: false });
756
- }
757
- if (_isAck && this._acks.has(event)) {
758
- const entry = this._acks.get(event);
759
- if (entry.timer)
760
- clearTimeout(entry.timer);
761
- this._acks.delete(event);
762
- entry.handler(data);
763
- return;
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
- const handler = this.events.get(event);
766
- if (handler)
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 headerEnd = -1;
777
- for (let i = 0; i < payload.length; i++) {
778
- if (payload[i] === 0) {
779
- headerEnd = i;
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 headerStr = payload.subarray(0, headerEnd).toString('utf8');
786
- const header = JSON.parse(headerStr);
787
- const buffer = payload.subarray(headerEnd + 1).buffer;
788
- if (this.options.hooks.onMessage) {
789
- this.options.hooks.onMessage({ event: header.event, data: buffer, isBinary: true });
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
- /** TCP mode: connects to WS port + 1 by convention. */
557
+ /* ── TCP ── */
801
558
  async _connectTCP() {
802
559
  try {
803
- await loadNodeModules();
560
+ await loadModules();
804
561
  if (!_net)
805
562
  return;
806
- const parsed = new URL(this.url);
807
- const port = parseInt(parsed.port) + 1 || 3001;
808
- const host = parsed.hostname || 'localhost';
809
- const socketOptions = { host, port };
810
- let socket;
811
- if (this.options.tls && _tls) {
812
- socketOptions.rejectUnauthorized = this.options.rejectUnauthorized;
813
- socket = _tls.connect(socketOptions);
814
- }
815
- else {
816
- socket = _net.createConnection(socketOptions);
817
- }
818
- socket.setTimeout(this.options.ackTimeout, () => {
819
- socket.destroy(new Error('TCP connection timeout'));
820
- });
821
- socket.on('connect', () => {
822
- socket.setTimeout(0);
823
- this._setState('connected');
824
- this._reconnectAttempts = 0;
825
- this._connectTime = Date.now();
826
- this._tcpParser = new FrameParser(this.options.maxFrameSize);
827
- this.log.info('TCP connected', { host, port, tls: this.options.tls });
828
- const handler = this.events.get('connect');
829
- if (handler)
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(frame) {
879
- const { type, event, payload } = frame;
590
+ _handleTCPFrame(f) {
591
+ const { type, event, payload } = f;
880
592
  if (type === FRAME_PING) {
881
- if (this._tcpSocket && !this._tcpSocket.destroyed) {
593
+ if (this._tcpSock && !this._tcpSock.destroyed)
882
594
  try {
883
- this._tcpSocket.write(encodePongFrame());
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
- if (this._acks.has(event)) {
897
- try {
898
- const data = JSON.parse(payload.toString('utf8'));
899
- const entry = this._acks.get(event);
900
- if (entry.timer)
901
- clearTimeout(entry.timer);
902
- this._acks.delete(event);
903
- entry.handler(data);
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
- if (this.options.hooks.onMessage) {
913
- this.options.hooks.onMessage({ event, data, isBinary: false });
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 buffer = copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength);
927
- if (this.options.hooks.onMessage) {
928
- this.options.hooks.onMessage({ event, data: buffer, isBinary: true });
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;