stelar-time-real 2.0.3 → 3.2.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,14 +1,70 @@
1
+ /**
2
+ * @stelar-time-real Client
3
+ *
4
+ * Dual-environment: Browser (native WebSocket) + Node.js (manual WS or TCP binary).
5
+ * No external dependencies.
6
+ */
7
+ 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
+ import { WSFrameParser, generateWSKey, createWSTextFrameMasked, createWSBinaryFrameMasked, createWSCloseFrameMasked, createWSPongFrameMasked, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, } from './websocket.js';
9
+ import { Logger, NULL_LOGGER } from './logger.js';
10
+ 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() {
17
+ if (!_http) {
18
+ _http = await import('http');
19
+ _net = await import('net');
20
+ _tls = await import('tls');
21
+ _https = await import('https');
22
+ }
23
+ }
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
+ }
47
+ }
1
48
  class StelarClient {
2
49
  constructor(urlOrPort = 'localhost:3000', options = {}) {
3
- this.ws = null;
4
50
  this.events = new Map();
5
51
  this._wildcardHandler = null;
6
- this.connected = false;
7
- this.id = null;
52
+ this._acks = new Map();
53
+ this._state = 'disconnected';
8
54
  this._reconnectAttempts = 0;
9
55
  this._hbTimer = null;
10
56
  this._isManualClose = false;
11
- this._acks = new Map();
57
+ this.id = null;
58
+ this._reconnectTimer = null;
59
+ this._messagesSent = 0;
60
+ this._messagesReceived = 0;
61
+ this._connectTime = 0;
62
+ this._lastError = null;
63
+ this._ws = null;
64
+ this._nodeSocket = null;
65
+ this._wsParser = null;
66
+ this._tcpSocket = null;
67
+ this._tcpParser = null;
12
68
  if (typeof urlOrPort === 'number') {
13
69
  this.url = `ws://localhost:${urlOrPort}`;
14
70
  }
@@ -20,16 +76,92 @@ class StelarClient {
20
76
  }
21
77
  this.options = {
22
78
  reconnection: options.reconnection !== false,
23
- reconnectionAttempts: options.reconnectionAttempts || 5,
79
+ reconnectionAttempts: options.reconnectionAttempts || 10,
24
80
  reconnectionDelay: options.reconnectionDelay || 1000,
81
+ maxReconnectionDelay: options.maxReconnectionDelay || 30000,
25
82
  heartbeatInterval: options.heartbeatInterval || 30000,
26
- ackTimeout: options.ackTimeout || 5000
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 || {},
27
94
  };
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
+ }
28
108
  }
109
+ getState() { return this._state; }
110
+ getId() { return this.id; }
111
+ 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; }
29
117
  setUrl(url) {
30
118
  this.url = url;
31
119
  return this;
32
120
  }
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
+ getOptions() {
151
+ 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),
163
+ });
164
+ }
33
165
  on(event, handler) {
34
166
  this.events.set(event, handler);
35
167
  return this;
@@ -40,33 +172,143 @@ class StelarClient {
40
172
  }
41
173
  return this;
42
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
+ }
43
183
  onAll(handler) {
44
184
  this._wildcardHandler = handler;
45
185
  return this;
46
186
  }
47
187
  onAck(name, handler) {
48
- this._acks.set(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
+ }
49
198
  return this;
50
199
  }
51
200
  emit(event, data, opts = {}) {
52
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
53
- const payload = { event, data };
54
- if (opts.ack) {
55
- payload._ackName = opts.ack;
201
+ try {
202
+ if (event)
203
+ validateEventName(event);
204
+ }
205
+ catch {
206
+ this.log.warn('Invalid event name', { event });
207
+ return this;
208
+ }
209
+ if (this.options.hooks.onBeforeEmit) {
210
+ const result = this.options.hooks.onBeforeEmit({ event, data });
211
+ if (result === false)
212
+ return this;
213
+ }
214
+ 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 });
218
+ return this;
219
+ }
220
+ }
221
+ catch {
222
+ this.log.warn('Failed to serialize data', { event });
223
+ return this;
224
+ }
225
+ 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
+ }
232
+ }
233
+ return this;
234
+ }
235
+ 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
+ }
240
+ else {
241
+ this._tcpSocket.write(encodeJsonFrame(event, data, this.options.maxFrameSize));
242
+ }
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));
255
+ }
256
+ else {
257
+ this._messageQueue.push({ event, data, opts, timestamp: Date.now() });
258
+ return this;
56
259
  }
57
- this.ws.send(JSON.stringify(payload));
260
+ this._messagesSent++;
261
+ }
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() });
58
268
  }
59
269
  return this;
60
270
  }
61
271
  emitBinary(event, data) {
62
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
63
- const header = JSON.stringify({ event });
64
- const headerBytes = new TextEncoder().encode(header);
65
- const combined = new Uint8Array(headerBytes.length + 1 + data.byteLength);
66
- combined.set(headerBytes, 0);
67
- combined[headerBytes.length] = 0;
68
- combined.set(new Uint8Array(data), headerBytes.length + 1);
69
- this.ws.send(combined);
272
+ if (data.byteLength > this.options.maxPayloadSize) {
273
+ this.log.warn('Binary payload exceeds max size', { event, size: data.byteLength });
274
+ return this;
275
+ }
276
+ if (this.options.hooks.onBeforeEmit) {
277
+ const result = this.options.hooks.onBeforeEmit({ event, data });
278
+ if (result === false)
279
+ return this;
280
+ }
281
+ if (this._state !== 'connected')
282
+ return this;
283
+ try {
284
+ if (this.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
285
+ this._tcpSocket.write(encodeBinaryFrame(event, new Uint8Array(data), this.options.maxFrameSize));
286
+ }
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));
295
+ }
296
+ 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);
304
+ }
305
+ this._messagesSent++;
306
+ }
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
+ }
70
312
  }
71
313
  return this;
72
314
  }
@@ -76,34 +318,69 @@ class StelarClient {
76
318
  sendImage(blob) {
77
319
  return this.emitBinary('image', blob);
78
320
  }
321
+ /** Send a request and wait for an ACK response. Rejects on timeout. */
79
322
  request(event, data, ackName) {
80
323
  return new Promise((resolve, reject) => {
81
324
  const timeout = setTimeout(() => {
82
- reject(new Error(`ACK '${ackName}' timeout`));
325
+ this._acks.delete(ackName);
326
+ reject(new Error(`ACK '${ackName}' timeout after ${this.options.ackTimeout}ms`));
83
327
  }, this.options.ackTimeout);
328
+ timeout.unref();
84
329
  const handler = (responseData) => {
85
330
  clearTimeout(timeout);
86
331
  this._acks.delete(ackName);
87
332
  resolve(responseData);
88
333
  };
89
- this._acks.set(ackName, handler);
334
+ this._acks.set(ackName, { handler, timer: timeout });
90
335
  this.emit(event, data, { ack: ackName });
91
336
  });
92
337
  }
93
338
  joinRoom(room) {
94
- this.emit('join-room', room);
339
+ if (this.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
340
+ try {
341
+ this._tcpSocket.write(encodeJoinFrame(room, this.options.maxFrameSize));
342
+ }
343
+ catch { }
344
+ }
345
+ else {
346
+ this.emit('join-room', room);
347
+ }
95
348
  return this;
96
349
  }
97
- leaveRoom() {
98
- this.emit('leave-room', {});
350
+ leaveRoom(room) {
351
+ if (this.options.mode === 'tcp' && this._tcpSocket && !this._tcpSocket.destroyed) {
352
+ try {
353
+ this._tcpSocket.write(encodeLeaveFrame(room));
354
+ }
355
+ catch { }
356
+ }
357
+ else {
358
+ this.emit('leave-room', room);
359
+ }
99
360
  return this;
100
361
  }
101
362
  startHeartbeat() {
363
+ this.stopHeartbeat();
102
364
  this._hbTimer = setInterval(() => {
103
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
104
- this.emit('pong', Date.now());
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() }));
105
379
  }
106
380
  }, this.options.heartbeatInterval);
381
+ if (this._hbTimer && typeof this._hbTimer === 'object' && 'unref' in this._hbTimer) {
382
+ this._hbTimer.unref();
383
+ }
107
384
  }
108
385
  stopHeartbeat() {
109
386
  if (this._hbTimer) {
@@ -111,105 +388,577 @@ class StelarClient {
111
388
  this._hbTimer = null;
112
389
  }
113
390
  }
114
- _connect() {
115
- this._isManualClose = false;
116
- this.ws = new WebSocket(this.url);
117
- this.ws.onopen = () => {
118
- this.connected = true;
119
- this._reconnectAttempts = 0;
120
- const handler = this.events.get('connect');
121
- if (handler)
122
- handler(undefined);
123
- this.startHeartbeat();
124
- };
125
- this.ws.binaryType = 'arraybuffer';
126
- this.ws.onmessage = (e) => {
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) {
127
413
  try {
128
- if (e.data instanceof ArrayBuffer) {
129
- const view = new Uint8Array(e.data);
130
- let headerEnd = -1;
131
- for (let i = 0; i < view.length; i++) {
132
- if (view[i] === 0) {
133
- headerEnd = i;
134
- break;
135
- }
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));
136
417
  }
137
- if (headerEnd === -1)
138
- return;
139
- const headerStr = new TextDecoder().decode(view.slice(0, headerEnd));
140
- const header = JSON.parse(headerStr);
141
- const buffer = view.slice(headerEnd + 1);
142
- const handler = this.events.get(header.event);
143
- if (handler) {
144
- handler(buffer.buffer);
418
+ else {
419
+ this._tcpSocket.write(encodeJsonFrame(msg.event, msg.data, this.options.maxFrameSize));
145
420
  }
146
- else if (this._wildcardHandler) {
147
- this._wildcardHandler({ event: header.event, data: buffer.buffer, isBinary: true, buffer: buffer.buffer });
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') {
476
+ return this;
477
+ }
478
+ if (this._state === 'reconnecting' && this._reconnectTimer) {
479
+ clearTimeout(this._reconnectTimer);
480
+ this._reconnectTimer = null;
481
+ }
482
+ this._isManualClose = false;
483
+ this._setState('connecting');
484
+ if (this.options.mode === 'tcp' && isNode) {
485
+ this._connectTCP();
486
+ }
487
+ else if (isNode) {
488
+ this._connectNodeWS();
489
+ }
490
+ else {
491
+ 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();
502
+ }
503
+ return this;
504
+ }
505
+ disconnect() {
506
+ this._isManualClose = true;
507
+ if (this._tcpSocket && !this._tcpSocket.destroyed) {
508
+ try {
509
+ this._tcpSocket.destroy();
510
+ }
511
+ catch { }
512
+ }
513
+ if (this._nodeSocket && !this._nodeSocket.destroyed) {
514
+ try {
515
+ this._nodeSocket.write(createWSCloseFrameMasked());
516
+ }
517
+ catch { }
518
+ try {
519
+ this._nodeSocket.end();
520
+ }
521
+ catch { }
522
+ }
523
+ if (this._ws) {
524
+ try {
525
+ this._ws.close();
526
+ }
527
+ catch { }
528
+ }
529
+ this._fullCleanup();
530
+ this._setState('disconnected');
531
+ return this;
532
+ }
533
+ isConnected() {
534
+ return this._state === 'connected';
535
+ }
536
+ _connectBrowser() {
537
+ try {
538
+ const ws = new WebSocket(this.url);
539
+ 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
+ };
572
+ this._ws = ws;
573
+ }
574
+ catch (err) {
575
+ this._lastError = err instanceof Error ? err : new Error(String(err));
576
+ this._setState('disconnected');
577
+ this._tryReconnect(() => this._connectBrowser());
578
+ }
579
+ }
580
+ _handleBrowserMessage(e) {
581
+ try {
582
+ 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;
588
+ break;
148
589
  }
590
+ }
591
+ if (headerEnd === -1)
149
592
  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 });
150
604
  }
151
- const msg = JSON.parse(e.data);
605
+ return;
606
+ }
607
+ const msg = JSON.parse(e.data);
608
+ const { event, data, _isAck } = msg;
609
+ if (event === 'ping')
610
+ 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;
621
+ }
622
+ const handler = this.events.get(event);
623
+ if (handler)
624
+ handler(data);
625
+ if (this._wildcardHandler)
626
+ this._wildcardHandler({ event, data });
627
+ }
628
+ catch { }
629
+ }
630
+ async _connectNodeWS() {
631
+ try {
632
+ await loadNodeModules();
633
+ if (!_http)
634
+ return;
635
+ const parsed = new URL(this.url);
636
+ const isSecure = parsed.protocol === 'wss:' || this.options.tls;
637
+ 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
+ });
657
+ 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());
702
+ });
703
+ req.end();
704
+ }
705
+ catch (err) {
706
+ this._lastError = err instanceof Error ? err : new Error(String(err));
707
+ this._tryReconnect(() => this._connectNodeWS());
708
+ }
709
+ }
710
+ _processNodeWSData(data) {
711
+ if (!this._wsParser)
712
+ return;
713
+ let frames;
714
+ try {
715
+ frames = this._wsParser.feed(data);
716
+ }
717
+ catch {
718
+ this.log.error('WS frame parse error');
719
+ return;
720
+ }
721
+ for (const frame of frames) {
722
+ this._messagesReceived++;
723
+ this._handleNodeWSFrame(frame);
724
+ }
725
+ }
726
+ _handleNodeWSFrame(frame) {
727
+ const { opcode, payload } = frame;
728
+ if (opcode === OP_PING) {
729
+ if (this._nodeSocket && !this._nodeSocket.destroyed) {
730
+ try {
731
+ this._nodeSocket.write(createWSPongFrameMasked());
732
+ }
733
+ catch { }
734
+ }
735
+ return;
736
+ }
737
+ if (opcode === OP_PONG)
738
+ return;
739
+ if (opcode === OP_CLOSE) {
740
+ if (this._nodeSocket && !this._nodeSocket.destroyed) {
741
+ try {
742
+ this._nodeSocket.end();
743
+ }
744
+ catch { }
745
+ }
746
+ return;
747
+ }
748
+ if (opcode === OP_TEXT) {
749
+ try {
750
+ const msg = JSON.parse(payload.toString('utf8'));
152
751
  const { event, data, _isAck } = msg;
153
752
  if (event === 'ping')
154
753
  return;
754
+ if (this.options.hooks.onMessage) {
755
+ this.options.hooks.onMessage({ event, data, isBinary: false });
756
+ }
155
757
  if (_isAck && this._acks.has(event)) {
156
- const handler = this._acks.get(event);
157
- handler(data);
758
+ const entry = this._acks.get(event);
759
+ if (entry.timer)
760
+ clearTimeout(entry.timer);
761
+ this._acks.delete(event);
762
+ entry.handler(data);
158
763
  return;
159
764
  }
160
765
  const handler = this.events.get(event);
161
766
  if (handler)
162
767
  handler(data);
163
- if (this._wildcardHandler) {
768
+ if (this._wildcardHandler)
164
769
  this._wildcardHandler({ event, data });
165
- }
166
770
  }
167
771
  catch { }
168
- };
169
- this.ws.onclose = () => {
170
- this.connected = false;
171
- this.stopHeartbeat();
172
- const handler = this.events.get('disconnect');
173
- if (handler)
174
- handler(undefined);
175
- if (!this._isManualClose && this.options.reconnection && this._reconnectAttempts < this.options.reconnectionAttempts) {
176
- this._reconnectAttempts++;
177
- const reconHandler = this.events.get('reconnecting');
178
- if (reconHandler)
179
- reconHandler(this._reconnectAttempts);
180
- setTimeout(() => this._connect(), this.options.reconnectionDelay * this._reconnectAttempts);
772
+ return;
773
+ }
774
+ if (opcode === OP_BINARY) {
775
+ try {
776
+ let headerEnd = -1;
777
+ for (let i = 0; i < payload.length; i++) {
778
+ if (payload[i] === 0) {
779
+ headerEnd = i;
780
+ break;
781
+ }
782
+ }
783
+ if (headerEnd === -1)
784
+ 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 });
181
796
  }
182
- };
183
- this.ws.onerror = (err) => {
184
- const handler = this.events.get('error');
185
- if (handler)
186
- handler(err);
187
- };
797
+ catch { }
798
+ }
188
799
  }
189
- connect(callback) {
190
- this._connect();
191
- if (callback) {
192
- const checkConnection = setInterval(() => {
193
- if (this.connected) {
194
- clearInterval(checkConnection);
195
- callback();
800
+ /** TCP mode: connects to WS port + 1 by convention. */
801
+ async _connectTCP() {
802
+ try {
803
+ await loadNodeModules();
804
+ if (!_net)
805
+ 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);
196
849
  }
197
- }, 100);
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));
875
+ this._tryReconnect(() => this._connectTCP());
198
876
  }
199
- return this;
200
- }
201
- disconnect() {
202
- this._isManualClose = true;
203
- this.stopHeartbeat();
204
- if (this.ws)
205
- this.ws.close();
206
- return this;
207
877
  }
208
- isConnected() {
209
- return this.connected;
878
+ _handleTCPFrame(frame) {
879
+ const { type, event, payload } = frame;
880
+ if (type === FRAME_PING) {
881
+ if (this._tcpSocket && !this._tcpSocket.destroyed) {
882
+ try {
883
+ this._tcpSocket.write(encodePongFrame());
884
+ }
885
+ catch { }
886
+ }
887
+ return;
888
+ }
889
+ if (type === FRAME_PONG)
890
+ return;
891
+ if (type === FRAME_CONNECT) {
892
+ this.id = payload.toString('utf8');
893
+ return;
894
+ }
895
+ 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);
904
+ }
905
+ catch { }
906
+ }
907
+ return;
908
+ }
909
+ if (type === FRAME_JSON) {
910
+ try {
911
+ 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 });
920
+ }
921
+ catch { }
922
+ return;
923
+ }
924
+ if (type === FRAME_BINARY) {
925
+ 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
+ }
210
936
  }
211
- getUrl() {
212
- return this.url;
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;
948
+ }
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);
213
962
  }
214
963
  }
215
964
  export default StelarClient;