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