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