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