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