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