stelar-time-real 2.0.4 → 3.2.1

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