stelar-time-real 3.2.1 → 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.ts CHANGED
@@ -1,45 +1,19 @@
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
 
8
5
  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,
6
+ FrameParser, encodeJsonFrame, encodeBinaryFrame, encodeAckReqFrame,
7
+ encodePingFrame, encodePongFrame, encodeJoinFrame, encodeLeaveFrame,
8
+ FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_RES,
9
+ FRAME_CONNECT, validateEventName, DEFAULT_MAX_FRAME_SIZE, ProtocolError,
26
10
  } from './protocol.js';
27
11
 
28
12
  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,
13
+ WSFrameParser, generateWSKey, createWSTextFrameMasked,
14
+ createWSBinaryFrameMasked, createWSCloseFrameMasked,
15
+ createWSPongFrameMasked, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
16
+ CLOSE_NORMAL, DEFAULT_MAX_WS_FRAME_SIZE,
43
17
  } from './websocket.js';
44
18
 
45
19
  import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
@@ -47,1023 +21,458 @@ import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
47
21
  const isNode = typeof process !== 'undefined' && process.versions?.node != null;
48
22
 
49
23
  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;
24
+ onBeforeEmit?: (i: { event: string; data: unknown }) => boolean | void;
25
+ onMessage?: (i: { event: string; data: unknown; isBinary: boolean }) => void;
26
+ onStateChange?: (i: { from: ConnectionState; to: ConnectionState }) => void;
27
+ onReconnectDelay?: (i: { attempt: number; defaultDelay: number }) => number | void;
28
+ onMessageQueued?: (i: { event: string; data: unknown; queueSize: number }) => void;
29
+ onQueueDrained?: (i: { count: number }) => void;
30
+ onError?: (i: { error: Error; context: string }) => void;
64
31
  }
65
32
 
66
33
  export interface StelarClientOptions {
67
- reconnection?: boolean;
68
- reconnectionAttempts?: number;
69
- reconnectionDelay?: number;
70
- maxReconnectionDelay?: number;
71
- heartbeatInterval?: number;
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 */
34
+ reconnection?: boolean; reconnectionAttempts?: number; reconnectionDelay?: number;
35
+ maxReconnectionDelay?: number; heartbeatInterval?: number; ackTimeout?: number;
36
+ mode?: 'ws' | 'tcp'; maxPayloadSize?: number; maxFrameSize?: number;
37
+ messageQueueSize?: number; logger?: Logger | LogLevel | false; tls?: boolean;
38
+ rejectUnauthorized?: boolean; headers?: Record<string, string>;
82
39
  customReconnectDelay?: (attempt: number, baseDelay: number, maxDelay: number) => number;
83
40
  hooks?: StelarClientHooks;
84
41
  }
85
42
 
86
- export interface StelarEmitOptions {
87
- ack?: string;
88
- }
89
-
43
+ export interface StelarEmitOptions { ack?: string; _correlationId?: string; }
90
44
  export type StelarEventHandler = (data: unknown) => void;
91
45
  export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
92
46
 
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
- }
47
+ /* Lazy-load Node modules for browser compat */
48
+ let _http: typeof import('http') | null, _net: typeof import('net') | null,
49
+ _tls: typeof import('tls') | null, _https: typeof import('https') | null;
107
50
 
108
- interface QueuedMessage {
109
- event: string;
110
- data: unknown;
111
- opts: StelarEmitOptions;
112
- timestamp: number;
51
+ async function loadModules() {
52
+ if (!_http) { _http = await import('http'); _net = await import('net'); _tls = await import('tls'); _https = await import('https'); }
113
53
  }
114
54
 
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
- }
55
+ interface QMsg { event: string; data: unknown; opts: StelarEmitOptions; ts: number; }
136
56
 
137
- get length(): number {
138
- return this.queue.length;
139
- }
140
-
141
- clear(): void {
142
- this.queue = [];
143
- }
57
+ class MsgQueue {
58
+ private q: QMsg[] = [];
59
+ constructor(private max = 100) {}
60
+ push(m: QMsg) { if (this.q.length >= this.max) this.q.shift(); this.q.push(m); return true; }
61
+ drain() { const m = this.q; this.q = []; return m; }
62
+ get length() { return this.q.length; }
63
+ clear() { this.q = []; }
144
64
  }
145
65
 
146
66
  class StelarClient {
147
67
  private url: string;
148
- private options: Required<Omit<StelarClientOptions, 'customReconnectDelay' | 'hooks'>> & {
149
- customReconnectDelay?: (attempt: number, baseDelay: number, maxDelay: number) => number;
150
- hooks: StelarClientHooks;
68
+ private opts: Required<Omit<StelarClientOptions, 'customReconnectDelay' | 'hooks'>> & {
69
+ customReconnectDelay?: (a: number, b: number, m: number) => number; hooks: StelarClientHooks;
151
70
  };
152
71
  private events = new Map<string, StelarEventHandler>();
153
- private _wildcardHandler: ((data: { event: string; data: unknown; isBinary?: boolean; buffer?: ArrayBuffer }) => void) | null = null;
72
+ private _wild: ((d: { event: string; data: unknown; isBinary?: boolean; buffer?: ArrayBuffer }) => void) | null = null;
154
73
  private _acks = new Map<string, { handler: StelarEventHandler; timer: ReturnType<typeof setTimeout> }>();
155
74
  private _state: ConnectionState = 'disconnected';
156
- private _reconnectAttempts = 0;
157
- private _hbTimer: ReturnType<typeof setInterval> | null = null;
158
- private _isManualClose = false;
75
+ private _reconnAttempts = 0;
76
+ private _hb: ReturnType<typeof setInterval> | null = null;
77
+ private _manualClose = false;
159
78
  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
-
79
+ private _mq: MsgQueue;
80
+ private _reconnTimer: ReturnType<typeof setTimeout> | null = null;
81
+ private _ackCounter = 0;
82
+ private _sent = 0; private _recv = 0; private _connTime = 0; private _lastErr: Error | null = null;
168
83
  private _ws: WebSocket | null = null;
169
- private _nodeSocket: InstanceType<typeof import('net').Socket> | null = null;
84
+ private _nodeSock: InstanceType<typeof import('net').Socket> | null = null;
170
85
  private _wsParser: WSFrameParser | null = null;
171
- private _tcpSocket: InstanceType<typeof import('net').Socket> | null = null;
86
+ private _tcpSock: InstanceType<typeof import('net').Socket> | null = null;
172
87
  private _tcpParser: FrameParser | null = null;
173
88
  private log: Logger;
174
89
 
175
- constructor(urlOrPort: string | number = 'localhost:3000', options: StelarClientOptions = {}) {
176
- if (typeof urlOrPort === 'number') {
177
- this.url = `ws://localhost:${urlOrPort}`;
178
- } else if (urlOrPort.includes('://')) {
179
- this.url = urlOrPort.startsWith('http') ? 'ws' + urlOrPort.slice(4) : urlOrPort;
180
- } else {
181
- this.url = `ws://${urlOrPort}`;
182
- }
183
-
184
- this.options = {
185
- reconnection: options.reconnection !== false,
186
- reconnectionAttempts: options.reconnectionAttempts || 10,
187
- reconnectionDelay: options.reconnectionDelay || 1000,
188
- maxReconnectionDelay: options.maxReconnectionDelay || 30000,
189
- heartbeatInterval: options.heartbeatInterval || 30000,
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 || {},
90
+ constructor(urlOrPort: string | number = 'localhost:3000', o: StelarClientOptions = {}) {
91
+ if (typeof urlOrPort === 'number') this.url = `ws://localhost:${urlOrPort}`;
92
+ else if (urlOrPort.includes('://')) this.url = urlOrPort.startsWith('http') ? 'ws' + urlOrPort.slice(4) : urlOrPort;
93
+ else this.url = `ws://${urlOrPort}`;
94
+ this.opts = {
95
+ reconnection: o.reconnection !== false, reconnectionAttempts: o.reconnectionAttempts || 10,
96
+ reconnectionDelay: o.reconnectionDelay || 1000, maxReconnectionDelay: o.maxReconnectionDelay || 30000,
97
+ heartbeatInterval: o.heartbeatInterval || 30000, ackTimeout: o.ackTimeout || 5000,
98
+ mode: o.mode || 'ws', maxPayloadSize: o.maxPayloadSize || 10 * 1024 * 1024,
99
+ maxFrameSize: o.maxFrameSize || DEFAULT_MAX_FRAME_SIZE, messageQueueSize: o.messageQueueSize || 100,
100
+ logger: o.logger !== undefined ? o.logger as any : 'warn', tls: o.tls || false,
101
+ rejectUnauthorized: o.rejectUnauthorized !== false, headers: o.headers || {},
102
+ customReconnectDelay: o.customReconnectDelay, hooks: o.hooks || {},
201
103
  };
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
- }
215
- }
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
-
226
- setUrl(url: string): this {
227
- this.url = url;
104
+ this._mq = new MsgQueue(this.opts.messageQueueSize);
105
+ this.log = o.logger === false ? NULL_LOGGER : o.logger instanceof Logger ? o.logger : new Logger({ level: (o.logger as LogLevel) || 'warn', prefix: 'stelar:client' });
106
+ }
107
+
108
+ getState() { return this._state; }
109
+ getId() { return this.id; }
110
+ getUrl() { return this.url; }
111
+ getMessagesSent() { return this._sent; }
112
+ getMessagesReceived() { return this._recv; }
113
+ getLastError() { return this._lastErr; }
114
+ getQueueSize() { return this._mq.length; }
115
+ getConnectTime() { return this._connTime; }
116
+ setUrl(u: string) { this.url = u; return this; }
117
+
118
+ updateOptions(o: Partial<StelarClientOptions>): this {
119
+ for (const k of ['reconnection', 'reconnectionAttempts', 'reconnectionDelay', 'maxReconnectionDelay', 'heartbeatInterval', 'ackTimeout', 'maxPayloadSize', 'maxFrameSize', 'messageQueueSize', 'headers'] as const)
120
+ if ((o as any)[k] !== undefined) (this.opts as any)[k] = (o as any)[k];
121
+ if (o.customReconnectDelay !== undefined) this.opts.customReconnectDelay = o.customReconnectDelay;
122
+ if (o.hooks !== undefined) this.opts.hooks = { ...this.opts.hooks, ...o.hooks };
228
123
  return this;
229
124
  }
230
125
 
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
- }> {
126
+ getOptions() {
262
127
  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),
128
+ reconnection: this.opts.reconnection, reconnectionAttempts: this.opts.reconnectionAttempts,
129
+ reconnectionDelay: this.opts.reconnectionDelay, maxReconnectionDelay: this.opts.maxReconnectionDelay,
130
+ heartbeatInterval: this.opts.heartbeatInterval, ackTimeout: this.opts.ackTimeout, mode: this.opts.mode,
131
+ maxPayloadSize: this.opts.maxPayloadSize, messageQueueSize: this.opts.messageQueueSize,
132
+ hasCustomReconnectDelay: !!this.opts.customReconnectDelay, hooks: Object.keys(this.opts.hooks),
274
133
  });
275
134
  }
276
135
 
277
- on(event: string, handler: StelarEventHandler): this {
278
- this.events.set(event, handler);
279
- return this;
280
- }
281
-
282
- off(event: string, handler: StelarEventHandler): this {
283
- if (this.events.get(event) === handler) {
284
- this.events.delete(event);
285
- }
286
- return this;
287
- }
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
-
298
- onAll(handler: (data: { event: string; data: unknown; isBinary?: boolean; buffer?: ArrayBuffer }) => void): this {
299
- this._wildcardHandler = handler;
300
- return this;
301
- }
302
-
303
- onAck(name: string, handler: StelarEventHandler): this {
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
- }
314
- return this;
315
- }
136
+ on(ev: string, h: StelarEventHandler) { this.events.set(ev, h); return this; }
137
+ off(ev: string, h: StelarEventHandler) { if (this.events.get(ev) === h) this.events.delete(ev); return this; }
138
+ once(ev: string, h: StelarEventHandler) { const w = (d: unknown) => { this.off(ev, w); h(d); }; this.on(ev, w); return this; }
139
+ onAll(h: (d: { event: string; data: unknown; isBinary?: boolean; buffer?: ArrayBuffer }) => void) { this._wild = h; return this; }
140
+ onAck(name: string, h: StelarEventHandler) { this._acks.set(name, { handler: h, timer: null as any }); return this; }
141
+ removeAllListeners(ev?: string) { ev ? this.events.delete(ev) : this.events.clear(); return this; }
316
142
 
317
143
  emit(event: string, data?: unknown, opts: StelarEmitOptions = {}): this {
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
-
144
+ try { if (event) validateEventName(event); } catch { this.log.warn('Invalid event', { event }); return this; }
145
+ if (this.opts.hooks.onBeforeEmit?.({ event, data }) === false) return this;
146
+ try { const s = JSON.stringify(data); if (s.length > this.opts.maxPayloadSize) { this.log.warn('Payload too large', { event }); return this; } } catch { return this; }
341
147
  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
- }
148
+ if (this.opts.reconnection) { this._mq.push({ event, data, opts, ts: Date.now() }); this.opts.hooks.onMessageQueued?.({ event, data, queueSize: this._mq.length }); }
349
149
  return this;
350
150
  }
351
-
352
151
  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));
152
+ const send = (wsPayload: () => Buffer, tcpPayload: () => Buffer) => {
153
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) this._tcpSock.write(tcpPayload());
154
+ else if (this._nodeSock && !this._nodeSock.destroyed) this._nodeSock.write(wsPayload());
155
+ else if (this._ws && this._ws.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ event, data, ...(opts.ack ? { _ackName: opts.ack } : {}), ...(opts._correlationId ? { _correlationId: opts._correlationId } : {}) }));
156
+ else { this._mq.push({ event, data, opts, ts: Date.now() }); return; }
157
+ this._sent++;
158
+ };
159
+ if (opts.ack) {
160
+ send(
161
+ () => { const p: Record<string, unknown> = { event, data, _ackName: opts.ack }; if (opts._correlationId) p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); },
162
+ () => { const d: Record<string, unknown> = { event, data }; if (opts._correlationId) d._correlationId = opts._correlationId; return encodeAckReqFrame(opts.ack!, d, this.opts.maxFrameSize); },
163
+ );
367
164
  } 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' });
165
+ send(
166
+ () => { const p: Record<string, unknown> = { event, data }; if (opts._correlationId) p._correlationId = opts._correlationId; return createWSTextFrameMasked(JSON.stringify(p)); },
167
+ () => encodeJsonFrame(event, data, this.opts.maxFrameSize),
168
+ );
376
169
  }
377
- this._messageQueue.push({ event, data, opts, timestamp: Date.now() });
170
+ } catch (e) {
171
+ this.log.error('Emit error', { event, error: String(e) });
172
+ this.opts.hooks.onError?.({ error: e instanceof Error ? e : new Error(String(e)), context: 'emit' });
173
+ this._mq.push({ event, data, opts, ts: Date.now() });
378
174
  }
379
-
380
175
  return this;
381
176
  }
382
177
 
383
178
  emitBinary(event: string, data: ArrayBuffer): this {
384
- if (data.byteLength > this.options.maxPayloadSize) {
385
- this.log.warn('Binary payload exceeds max size', { event, size: data.byteLength });
386
- return this;
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
-
179
+ if (data.byteLength > this.opts.maxPayloadSize) { this.log.warn('Binary too large', { event }); return this; }
180
+ if (this.opts.hooks.onBeforeEmit?.({ event, data }) === false) return this;
394
181
  if (this._state !== 'connected') return this;
395
-
396
182
  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));
183
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
184
+ this._tcpSock.write(encodeBinaryFrame(event, new Uint8Array(data), this.opts.maxFrameSize));
185
+ } else if (this._nodeSock && !this._nodeSock.destroyed) {
186
+ const hdr = Buffer.from(JSON.stringify({ event }), 'utf8');
187
+ const c = Buffer.alloc(hdr.length + 1 + data.byteLength);
188
+ hdr.copy(c, 0); c[hdr.length] = 0; c.set(new Uint8Array(data), hdr.length + 1);
189
+ this._nodeSock.write(createWSBinaryFrameMasked(c));
407
190
  } 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' });
191
+ const hdr = new TextEncoder().encode(JSON.stringify({ event }));
192
+ const c = new Uint8Array(hdr.length + 1 + data.byteLength);
193
+ c.set(hdr, 0); c[hdr.length] = 0; c.set(new Uint8Array(data), hdr.length + 1);
194
+ this._ws.send(c);
421
195
  }
422
- }
423
-
196
+ this._sent++;
197
+ } catch (e) { this.log.error('Binary emit error', { event, error: String(e) }); }
424
198
  return this;
425
199
  }
426
200
 
427
- sendFile(file: ArrayBuffer): this {
428
- return this.emitBinary('file', file);
429
- }
430
-
431
- sendImage(blob: ArrayBuffer): this {
432
- return this.emitBinary('image', blob);
433
- }
201
+ sendFile(f: ArrayBuffer) { return this.emitBinary('file', f); }
202
+ sendImage(b: ArrayBuffer) { return this.emitBinary('image', b); }
434
203
 
435
- /** Send a request and wait for an ACK response. Rejects on timeout. */
436
204
  request(event: string, data: unknown, ackName: string): Promise<unknown> {
437
205
  return new Promise((resolve, reject) => {
438
- const timeout = setTimeout(() => {
439
- this._acks.delete(ackName);
440
- reject(new Error(`ACK '${ackName}' timeout after ${this.options.ackTimeout}ms`));
441
- }, this.options.ackTimeout);
442
- timeout.unref();
443
-
444
- const handler: StelarEventHandler = (responseData) => {
445
- clearTimeout(timeout);
446
- this._acks.delete(ackName);
447
- resolve(responseData);
448
- };
449
-
450
- this._acks.set(ackName, { handler, timer: timeout });
451
- this.emit(event, data, { ack: ackName });
206
+ const corrId = `${ackName}#${++this._ackCounter}`;
207
+ const t = setTimeout(() => { this._acks.delete(corrId); reject(new Error(`ACK '${ackName}' timeout`)); }, this.opts.ackTimeout);
208
+ t.unref();
209
+ this._acks.set(corrId, { handler: (d) => { clearTimeout(t); this._acks.delete(corrId); resolve(d); }, timer: t });
210
+ this.emit(event, data, { ack: ackName, _correlationId: corrId });
452
211
  });
453
212
  }
454
213
 
455
- joinRoom(room: string): this {
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
- }
214
+ joinRoom(room: string) {
215
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) try { this._tcpSock.write(encodeJoinFrame(room, this.opts.maxFrameSize)); } catch {}
216
+ else this.emit('join-room', room);
461
217
  return this;
462
218
  }
463
219
 
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
- }
220
+ leaveRoom(room: string) {
221
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) try { this._tcpSock.write(encodeLeaveFrame(room)); } catch {}
222
+ else this.emit('leave-room', room);
470
223
  return this;
471
224
  }
472
225
 
473
- private startHeartbeat(): void {
474
- this.stopHeartbeat();
475
- this._hbTimer = setInterval(() => {
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() }));
482
- }
483
- }, this.options.heartbeatInterval);
484
- if (this._hbTimer && typeof this._hbTimer === 'object' && 'unref' in this._hbTimer) {
485
- this._hbTimer.unref();
226
+ connect(cb?: () => void): this {
227
+ if (this._state === 'connected' || this._state === 'connecting') return this;
228
+ if (this._state === 'reconnecting' && this._reconnTimer) { clearTimeout(this._reconnTimer); this._reconnTimer = null; }
229
+ this._manualClose = false; this._setState('connecting');
230
+ if (this.opts.mode === 'tcp' && isNode) this._connectTCP();
231
+ else if (isNode) this._connectNodeWS();
232
+ else this._connectBrowser();
233
+ if (cb) {
234
+ const check = setInterval(() => { if (this._state === 'connected') { clearInterval(check); cb(); } }, 50);
235
+ const safety = setTimeout(() => clearInterval(check), this.opts.ackTimeout); safety.unref();
486
236
  }
237
+ return this;
487
238
  }
488
239
 
489
- private stopHeartbeat(): void {
490
- if (this._hbTimer) {
491
- clearInterval(this._hbTimer);
492
- this._hbTimer = null;
493
- }
240
+ disconnect(): this {
241
+ this._manualClose = true;
242
+ if (this._tcpSock && !this._tcpSock.destroyed) try { this._tcpSock.destroy(); } catch {}
243
+ if (this._nodeSock && !this._nodeSock.destroyed) { try { this._nodeSock.write(createWSCloseFrameMasked()); } catch {} try { this._nodeSock.end(); } catch {} }
244
+ if (this._ws) try { this._ws.close(); } catch {}
245
+ this._fullCleanup(); this._setState('disconnected'); return this;
494
246
  }
495
247
 
496
- private _getReconnectDelay(): number {
497
- const base = this.options.reconnectionDelay;
498
- const max = this.options.maxReconnectionDelay;
248
+ isConnected() { return this._state === 'connected'; }
499
249
 
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
- }
250
+ /* ── Private ── */
505
251
 
506
- if (this.options.customReconnectDelay) {
507
- return this.options.customReconnectDelay(this._reconnectAttempts, base, max);
508
- }
252
+ private _setState(s: ConnectionState) {
253
+ const prev = this._state; this._state = s;
254
+ if (prev !== s) { this.log.debug('State', { from: prev, to: s }); this.opts.hooks.onStateChange?.({ from: prev, to: s }); }
255
+ }
509
256
 
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);
257
+ private _startHB() {
258
+ this._stopHB();
259
+ this._hb = setInterval(() => {
260
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) try { this._tcpSock.write(encodePingFrame()); } catch {}
261
+ else if (this._nodeSock && !this._nodeSock.destroyed) try { this._nodeSock.write(createWSTextFrameMasked(JSON.stringify({ event: 'pong', data: Date.now() }))); } catch {}
262
+ else if (this._ws && this._ws.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ event: 'pong', data: Date.now() }));
263
+ }, this.opts.heartbeatInterval);
264
+ this._hb?.unref?.();
513
265
  }
514
266
 
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 });
267
+ private _stopHB() { if (this._hb) { clearInterval(this._hb); this._hb = null; } }
268
+
269
+ private _getDelay(): number {
270
+ const base = this.opts.reconnectionDelay, max = this.opts.maxReconnectionDelay;
271
+ const def = Math.min(base * Math.pow(1.5, this._reconnAttempts - 1), max);
272
+ const custom = this.opts.hooks.onReconnectDelay?.({ attempt: this._reconnAttempts, defaultDelay: def });
273
+ if (typeof custom === 'number') return custom;
274
+ if (this.opts.customReconnectDelay) return this.opts.customReconnectDelay(this._reconnAttempts, base, max);
275
+ return Math.floor(def + def * 0.2 * Math.random());
276
+ }
519
277
 
520
- for (const msg of messages) {
278
+ private _drain() {
279
+ if (!this._mq.length) return;
280
+ const msgs = this._mq.drain();
281
+ this.log.info('Draining queue', { count: msgs.length });
282
+ for (const m of msgs) {
521
283
  try {
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));
527
- }
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)));
284
+ const p: Record<string, unknown> = { event: m.event, data: m.data };
285
+ if (m.opts.ack) p._ackName = m.opts.ack;
286
+ if (m.opts._correlationId) p._correlationId = m.opts._correlationId;
287
+ if (this.opts.mode === 'tcp' && this._tcpSock && !this._tcpSock.destroyed) {
288
+ this._tcpSock.write(m.opts.ack ? encodeAckReqFrame(m.opts.ack, p, this.opts.maxFrameSize) : encodeJsonFrame(m.event, m.data, this.opts.maxFrameSize));
289
+ } else if (this._nodeSock && !this._nodeSock.destroyed) {
290
+ this._nodeSock.write(createWSTextFrameMasked(JSON.stringify(p)));
532
291
  } 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));
292
+ this._ws.send(JSON.stringify(p));
536
293
  }
537
- this._messagesSent++;
538
- } catch (err) {
539
- this.log.error('Queue drain error', { event: msg.event, error: String(err) });
540
- }
541
- }
542
-
543
- if (this.options.hooks.onQueueDrained) {
544
- this.options.hooks.onQueueDrained({ count: messages.length });
294
+ this._sent++;
295
+ } catch (e) { this.log.error('Drain error', { event: m.event, error: String(e) }); }
545
296
  }
297
+ this.opts.hooks.onQueueDrained?.({ count: msgs.length });
546
298
  }
547
299
 
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
- }
300
+ private _cleanupAcks() { for (const [, e] of this._acks) if (e.timer) clearTimeout(e.timer); this._acks.clear(); }
558
301
 
559
- private _cleanupAcks(): void {
560
- for (const [, entry] of this._acks) {
561
- if (entry.timer) clearTimeout(entry.timer);
562
- }
563
- this._acks.clear();
302
+ private _fullCleanup() {
303
+ this._stopHB(); this._cleanupAcks();
304
+ if (this._reconnTimer) { clearTimeout(this._reconnTimer); this._reconnTimer = null; }
305
+ this._nodeSock = null; this._wsParser = null; this._tcpSock = null; this._tcpParser = null; this._ws = null;
564
306
  }
565
307
 
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;
308
+ private _tryReconnect(fn: () => void) {
309
+ if (this._manualClose || !this.opts.reconnection) return;
310
+ if (this._reconnAttempts >= this.opts.reconnectionAttempts) { this.log.warn('Max reconnect attempts'); this.events.get('reconnect_failed')?.(undefined); return; }
311
+ this._reconnAttempts++; this._setState('reconnecting');
312
+ const delay = this._getDelay();
313
+ this.log.info('Reconnecting', { attempt: this._reconnAttempts, delay });
314
+ this.events.get('reconnecting')?.(this._reconnAttempts);
315
+ this._reconnTimer = setTimeout(() => { this._reconnTimer = null; if (!this._manualClose) fn(); }, delay);
578
316
  }
579
317
 
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();
606
- }
607
- }, 50);
608
- const safetyTimeout = setTimeout(() => clearInterval(check), this.options.ackTimeout);
609
- safetyTimeout.unref();
610
- }
611
-
612
- return this;
318
+ private _onConnected() {
319
+ this._setState('connected'); this._reconnAttempts = 0; this._connTime = Date.now();
320
+ this.events.get('connect')?.(undefined); this._startHB(); this._drain();
613
321
  }
614
322
 
615
- disconnect(): this {
616
- this._isManualClose = true;
323
+ /* ── Browser WS ── */
617
324
 
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 {
325
+ private _connectBrowser() {
639
326
  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
-
327
+ const ws = new WebSocket(this.url); ws.binaryType = 'arraybuffer';
328
+ ws.onopen = () => this._onConnected();
329
+ ws.onmessage = (e) => { this._recv++; this._handleBrowserMsg(e); };
330
+ ws.onclose = (e) => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.({ code: e.code, reason: e.reason }); this._tryReconnect(() => this._connectBrowser()); };
331
+ ws.onerror = () => { this._lastErr = new Error('WebSocket error'); this.events.get('error')?.(this._lastErr); this.opts.hooks.onError?.({ error: this._lastErr!, context: 'browser-ws' }); };
676
332
  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
- }
333
+ } catch (e) { this._lastErr = e instanceof Error ? e : new Error(String(e)); this._setState('disconnected'); this._tryReconnect(() => this._connectBrowser()); }
682
334
  }
683
335
 
684
- private _handleBrowserMessage(e: MessageEvent): void {
336
+ private _handleBrowserMsg(e: MessageEvent) {
685
337
  try {
686
338
  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
- }
339
+ const v = new Uint8Array(e.data); let end = -1;
340
+ for (let i = 0; i < v.length; i++) if (v[i] === 0) { end = i; break; }
341
+ if (end === -1) return;
342
+ const hdr = JSON.parse(new TextDecoder().decode(v.slice(0, end)));
343
+ const buf = v.slice(end + 1).buffer as ArrayBuffer;
344
+ this.opts.hooks.onMessage?.({ event: hdr.event, data: buf, isBinary: true });
345
+ this.events.get(hdr.event)?.(buf);
346
+ this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
707
347
  return;
708
348
  }
709
-
710
- const msg = JSON.parse(e.data as string);
711
- const { event, data, _isAck } = msg;
712
-
349
+ const msg = JSON.parse(e.data as string), { event, data, _isAck } = msg;
713
350
  if (event === 'ping') return;
714
-
715
- if (this.options.hooks.onMessage) {
716
- this.options.hooks.onMessage({ event, data, isBinary: false });
351
+ this.opts.hooks.onMessage?.({ event, data, isBinary: false });
352
+ if (_isAck) {
353
+ const key = msg._correlationId || event;
354
+ if (this._acks.has(key)) { const entry = this._acks.get(key)!; if (entry.timer) clearTimeout(entry.timer); this._acks.delete(key); entry.handler(data); return; }
717
355
  }
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 });
356
+ this.events.get(event)?.(data);
357
+ this._wild?.({ event, data });
730
358
  } catch {}
731
359
  }
732
360
 
733
- private async _connectNodeWS(): Promise<void> {
734
- try {
735
- await loadNodeModules();
736
- if (!_http) return;
361
+ /* ── Node WS ── */
737
362
 
738
- const parsed = new URL(this.url);
739
- const isSecure = parsed.protocol === 'wss:' || this.options.tls;
363
+ private async _connectNodeWS() {
364
+ try {
365
+ await loadModules(); if (!_http) return;
366
+ const parsed = new URL(this.url), secure = parsed.protocol === 'wss:' || this.opts.tls;
740
367
  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
-
368
+ const hdrs: Record<string, string> = { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13', ...this.opts.headers };
369
+ const mod = secure && _https ? _https : _http;
370
+ const req = mod.request({ hostname: parsed.hostname, port: parseInt(parsed.port) || (secure ? 443 : 80), path: parsed.pathname + parsed.search, method: 'GET', headers: hdrs, rejectUnauthorized: this.opts.rejectUnauthorized });
371
+ req.setTimeout(this.opts.ackTimeout, () => req.destroy(new Error('Timeout')));
765
372
  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());
373
+ this._nodeSock = socket; this._wsParser = new WSFrameParser(this.opts.maxFrameSize);
374
+ if (head.length > 0) this._processNodeWS(head);
375
+ socket.on('data', (d: Buffer) => this._processNodeWS(d));
376
+ socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectNodeWS()); });
377
+ socket.on('error', (e: Error) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'node-ws' }); });
378
+ socket.on('drain', () => socket.resume());
379
+ this.log.info('Node WS connected', { secure });
380
+ this._onConnected();
813
381
  });
814
-
382
+ req.on('error', (e: Error) => { this._lastErr = e; this.events.get('error')?.(e); this._tryReconnect(() => this._connectNodeWS()); });
815
383
  req.end();
816
- } catch (err) {
817
- this._lastError = err instanceof Error ? err : new Error(String(err));
818
- this._tryReconnect(() => this._connectNodeWS());
819
- }
384
+ } catch (e) { this._lastErr = e instanceof Error ? e : new Error(String(e)); this._tryReconnect(() => this._connectNodeWS()); }
820
385
  }
821
386
 
822
- private _processNodeWSData(data: Buffer): void {
387
+ private _processNodeWS(data: Buffer) {
823
388
  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
- }
389
+ let frames; try { frames = this._wsParser.feed(data); } catch { this.log.error('WS parse error'); return; }
390
+ for (const f of frames) { this._recv++; this._handleNodeFrame(f); }
835
391
  }
836
392
 
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
- }
393
+ private _handleNodeFrame(f: { opcode: number; payload: Buffer }) {
394
+ if (f.opcode === OP_PING) { if (this._nodeSock && !this._nodeSock.destroyed) try { this._nodeSock.write(createWSPongFrameMasked()); } catch {} return; }
395
+ if (f.opcode === OP_PONG) return;
396
+ if (f.opcode === OP_CLOSE) { if (this._nodeSock && !this._nodeSock.destroyed) try { this._nodeSock.end(); } catch {} return; }
855
397
 
856
- if (opcode === OP_TEXT) {
398
+ if (f.opcode === OP_TEXT) {
857
399
  try {
858
- const msg = JSON.parse(payload.toString('utf8'));
859
- const { event, data, _isAck } = msg;
400
+ const msg = JSON.parse(f.payload.toString('utf8')), { event, data, _isAck } = msg;
860
401
  if (event === 'ping') return;
861
-
862
- if (this.options.hooks.onMessage) {
863
- this.options.hooks.onMessage({ event, data, isBinary: false });
864
- }
865
-
866
- if (_isAck && this._acks.has(event)) {
867
- const entry = this._acks.get(event)!;
868
- if (entry.timer) clearTimeout(entry.timer);
869
- this._acks.delete(event);
870
- entry.handler(data);
871
- return;
402
+ this.opts.hooks.onMessage?.({ event, data, isBinary: false });
403
+ if (_isAck) {
404
+ const key = msg._correlationId || event;
405
+ if (this._acks.has(key)) { const e = this._acks.get(key)!; if (e.timer) clearTimeout(e.timer); this._acks.delete(key); e.handler(data); return; }
872
406
  }
873
-
874
- const handler = this.events.get(event);
875
- if (handler) handler(data);
876
- if (this._wildcardHandler) this._wildcardHandler({ event, data });
407
+ this.events.get(event)?.(data);
408
+ this._wild?.({ event, data });
877
409
  } catch {}
878
410
  return;
879
411
  }
880
412
 
881
- if (opcode === OP_BINARY) {
413
+ if (f.opcode === OP_BINARY) {
882
414
  try {
883
- let headerEnd = -1;
884
- for (let i = 0; i < payload.length; i++) {
885
- if (payload[i] === 0) { headerEnd = i; break; }
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 });
415
+ let end = -1; for (let i = 0; i < f.payload.length; i++) if (f.payload[i] === 0) { end = i; break; }
416
+ if (end === -1) return;
417
+ const hdr = JSON.parse(f.payload.subarray(0, end).toString('utf8'));
418
+ const buf = f.payload.subarray(end + 1).buffer as ArrayBuffer;
419
+ this.opts.hooks.onMessage?.({ event: hdr.event, data: buf, isBinary: true });
420
+ this.events.get(hdr.event)?.(buf);
421
+ this._wild?.({ event: hdr.event, data: buf, isBinary: true, buffer: buf });
899
422
  } catch {}
900
423
  }
901
424
  }
902
425
 
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 };
914
-
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);
922
- }
923
-
924
- socket.setTimeout(this.options.ackTimeout, () => {
925
- socket.destroy(new Error('TCP connection timeout'));
926
- });
927
-
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' });
972
- }
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());
983
- }
984
- }
985
-
986
- private _handleTCPFrame(frame: { type: number; event: string; payload: Buffer }): void {
987
- const { type, event, payload } = frame;
988
-
989
- if (type === FRAME_PING) {
990
- if (this._tcpSocket && !this._tcpSocket.destroyed) {
991
- try { this._tcpSocket.write(encodePongFrame()); } catch {}
992
- }
993
- return;
994
- }
426
+ /* ── TCP ── */
995
427
 
428
+ private async _connectTCP() {
429
+ try {
430
+ await loadModules(); if (!_net) return;
431
+ const parsed = new URL(this.url), port = parseInt(parsed.port) + 1 || 3001, host = parsed.hostname || 'localhost';
432
+ const sockOpts: { host: string; port: number; rejectUnauthorized?: boolean } = { host, port };
433
+ const socket = this.opts.tls && _tls ? _tls.connect({ ...sockOpts, rejectUnauthorized: this.opts.rejectUnauthorized }) as any : _net.createConnection(sockOpts);
434
+ socket.setTimeout(this.opts.ackTimeout, () => socket.destroy(new Error('TCP timeout')));
435
+ socket.on('connect', () => { socket.setTimeout(0); this._tcpParser = new FrameParser(this.opts.maxFrameSize); this.log.info('TCP connected', { host, port }); this._onConnected(); });
436
+ socket.on('data', (d: Buffer) => { if (!this._tcpParser) return; let frames; try { frames = this._tcpParser.feed(d); } catch { this.log.error('TCP parse error'); socket.destroy(); return; } for (const f of frames) { this._recv++; this._handleTCPFrame(f); } });
437
+ socket.on('close', () => { this._setState('disconnected'); this._fullCleanup(); this.events.get('disconnect')?.(undefined); this._tryReconnect(() => this._connectTCP()); });
438
+ socket.on('error', (e: Error) => { this._lastErr = e; this.events.get('error')?.(e); this.opts.hooks.onError?.({ error: e, context: 'tcp' }); });
439
+ socket.on('drain', () => socket.resume());
440
+ this._tcpSock = socket;
441
+ } catch (e) { this._lastErr = e instanceof Error ? e : new Error(String(e)); this._tryReconnect(() => this._connectTCP()); }
442
+ }
443
+
444
+ private _handleTCPFrame(f: { type: number; event: string; payload: Buffer }) {
445
+ const { type, event, payload } = f;
446
+ if (type === FRAME_PING) { if (this._tcpSock && !this._tcpSock.destroyed) try { this._tcpSock.write(encodePongFrame()); } catch {} return; }
996
447
  if (type === FRAME_PONG) return;
997
-
998
- if (type === FRAME_CONNECT) {
999
- this.id = payload.toString('utf8');
1000
- return;
1001
- }
1002
-
448
+ if (type === FRAME_CONNECT) { this.id = payload.toString('utf8'); return; }
1003
449
  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
- }
450
+ try {
451
+ const raw = JSON.parse(payload.toString('utf8'));
452
+ const data = raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
453
+ const corrId = raw && typeof raw === 'object' && '_correlationId' in raw ? String(raw._correlationId) : undefined;
454
+ const key = corrId || event;
455
+ if (this._acks.has(key)) { const e = this._acks.get(key)!; if (e.timer) clearTimeout(e.timer); this._acks.delete(key); e.handler(data); }
456
+ } catch {}
1013
457
  return;
1014
458
  }
1015
-
1016
459
  if (type === FRAME_JSON) {
1017
460
  try {
1018
461
  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 });
462
+ this.opts.hooks.onMessage?.({ event, data, isBinary: false });
463
+ this.events.get(event)?.(data);
464
+ this._wild?.({ event, data });
1025
465
  } catch {}
1026
466
  return;
1027
467
  }
1028
-
1029
468
  if (type === FRAME_BINARY) {
1030
469
  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 });
470
+ const buf = copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength) as ArrayBuffer;
471
+ this.opts.hooks.onMessage?.({ event, data: buf, isBinary: true });
472
+ this.events.get(event)?.(buf);
473
+ this._wild?.({ event, data: buf, isBinary: true, buffer: buf });
1038
474
  }
1039
475
  }
1040
-
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);
1066
- }
1067
476
  }
1068
477
 
1069
478
  export default StelarClient;