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/index.js
CHANGED
|
@@ -1,867 +1,625 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @stelar-time-real Server
|
|
3
|
-
*
|
|
4
|
-
* Dual-protocol real-time server: WebSocket (RFC 6455) + custom binary TCP.
|
|
5
|
-
* Zero external dependencies — uses only Node.js built-in modules.
|
|
2
|
+
* @stelar-time-real Server — Dual-protocol: WebSocket (RFC 6455) + binary TCP
|
|
6
3
|
*/
|
|
7
|
-
import { createServer as
|
|
8
|
-
import { createServer as
|
|
4
|
+
import { createServer as createHttp } from 'http';
|
|
5
|
+
import { createServer as createTcp } from 'net';
|
|
9
6
|
import { randomUUID } from 'crypto';
|
|
10
|
-
import { createServer as
|
|
7
|
+
import { createServer as createTls } from 'tls';
|
|
11
8
|
import { FrameParser, encodeJsonFrame, encodeBinaryFrame, encodePingFrame, encodePongFrame, encodeAckResFrame, encodeConnectFrame, encodeDisconnectFrame, encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ, FRAME_ACK_RES, FRAME_JOIN, FRAME_LEAVE, FRAME_CONNECT, ProtocolError, DEFAULT_MAX_FRAME_SIZE, } from './protocol.js';
|
|
12
|
-
import { WSFrameParser, buildUpgradeResponse, validateWSKey, createWSTextFrame, createWSBinaryFrame, createWSCloseFrame, createWSPingFrame, createWSPongFrame, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, WebSocketError, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG,
|
|
9
|
+
import { WSFrameParser, buildUpgradeResponse, validateWSKey, createWSTextFrame, createWSBinaryFrame, createWSCloseFrame, createWSPingFrame, createWSPongFrame, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, WebSocketError, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_GOING_AWAY, DEFAULT_MAX_WS_FRAME_SIZE, } from './websocket.js';
|
|
13
10
|
import { Logger, NULL_LOGGER } from './logger.js';
|
|
14
11
|
class RateLimiter {
|
|
15
|
-
constructor(
|
|
12
|
+
constructor(maxPts = 100, winMs = 1000) {
|
|
13
|
+
this.maxPts = maxPts;
|
|
14
|
+
this.winMs = winMs;
|
|
16
15
|
this.limits = new Map();
|
|
17
|
-
this.maxPoints = maxPoints;
|
|
18
|
-
this.windowMs = windowMs;
|
|
19
16
|
}
|
|
20
17
|
check(id, cost = 1) {
|
|
21
18
|
const now = Date.now();
|
|
22
|
-
let
|
|
23
|
-
if (!
|
|
24
|
-
|
|
25
|
-
this.limits.set(id,
|
|
19
|
+
let e = this.limits.get(id);
|
|
20
|
+
if (!e || now >= e.resetTime) {
|
|
21
|
+
e = { count: 0, resetTime: now + this.winMs };
|
|
22
|
+
this.limits.set(id, e);
|
|
26
23
|
}
|
|
27
|
-
if (
|
|
24
|
+
if (e.count + cost > this.maxPts)
|
|
28
25
|
return false;
|
|
29
|
-
|
|
30
|
-
entry.count += cost;
|
|
26
|
+
e.count += cost;
|
|
31
27
|
return true;
|
|
32
28
|
}
|
|
33
|
-
cleanup() {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
reset(id) {
|
|
42
|
-
this.limits.delete(id);
|
|
43
|
-
}
|
|
44
|
-
size() {
|
|
45
|
-
return this.limits.size;
|
|
46
|
-
}
|
|
29
|
+
cleanup() { const now = Date.now(); for (const [id, e] of this.limits)
|
|
30
|
+
if (now >= e.resetTime)
|
|
31
|
+
this.limits.delete(id); }
|
|
32
|
+
reset(id) { this.limits.delete(id); }
|
|
33
|
+
size() { return this.limits.size; }
|
|
47
34
|
}
|
|
48
|
-
class
|
|
49
|
-
constructor(
|
|
50
|
-
this.
|
|
51
|
-
this.
|
|
52
|
-
}
|
|
53
|
-
check(ip) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
remove(ip) {
|
|
61
|
-
const current = this.ipCounts.get(ip) || 0;
|
|
62
|
-
if (current <= 1) {
|
|
63
|
-
this.ipCounts.delete(ip);
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
this.ipCounts.set(ip, current - 1);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
getCount(ip) {
|
|
70
|
-
return this.ipCounts.get(ip) || 0;
|
|
71
|
-
}
|
|
72
|
-
cleanup() {
|
|
73
|
-
for (const [ip, count] of this.ipCounts) {
|
|
74
|
-
if (count <= 0)
|
|
75
|
-
this.ipCounts.delete(ip);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
35
|
+
class IPTracker {
|
|
36
|
+
constructor(max = 50) {
|
|
37
|
+
this.max = max;
|
|
38
|
+
this.m = new Map();
|
|
39
|
+
}
|
|
40
|
+
check(ip) { return (this.m.get(ip) || 0) < this.max; }
|
|
41
|
+
add(ip) { this.m.set(ip, (this.m.get(ip) || 0) + 1); }
|
|
42
|
+
remove(ip) { const c = this.m.get(ip) || 0; c <= 1 ? this.m.delete(ip) : this.m.set(ip, c - 1); }
|
|
43
|
+
getCount(ip) { return this.m.get(ip) || 0; }
|
|
44
|
+
cleanup() { for (const [ip, c] of this.m)
|
|
45
|
+
if (c <= 0)
|
|
46
|
+
this.m.delete(ip); }
|
|
78
47
|
}
|
|
48
|
+
/* ── Server ── */
|
|
79
49
|
class StelarServer {
|
|
80
|
-
constructor(
|
|
50
|
+
constructor(o = {}) {
|
|
81
51
|
this.httpServer = null;
|
|
82
52
|
this.tcpServer = null;
|
|
53
|
+
this.evRateLimits = new Map();
|
|
54
|
+
this.clientRates = new Map();
|
|
83
55
|
this.clients = new Map();
|
|
84
|
-
this.
|
|
85
|
-
this.rooms = new Map();
|
|
56
|
+
this.byId = new Map();
|
|
57
|
+
this.rooms = new Map();
|
|
86
58
|
this.events = new Map();
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
91
|
-
this.
|
|
59
|
+
this.mw = [];
|
|
60
|
+
this._hb = null;
|
|
61
|
+
this._rc = null;
|
|
62
|
+
this._wild = null;
|
|
63
|
+
this._connH = null;
|
|
92
64
|
this._acks = new Map();
|
|
93
|
-
this.
|
|
94
|
-
this.
|
|
95
|
-
this.
|
|
65
|
+
this._ext = new WeakSet();
|
|
66
|
+
this._upgH = null;
|
|
67
|
+
this._reqH = null;
|
|
96
68
|
this._started = false;
|
|
97
69
|
this._startTime = 0;
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
102
|
-
this.
|
|
103
|
-
this.
|
|
104
|
-
this.
|
|
105
|
-
this.
|
|
106
|
-
this.
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
109
|
-
this.
|
|
110
|
-
this.
|
|
111
|
-
this.
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
114
|
-
this.
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
117
|
-
this.
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
this.
|
|
121
|
-
this.
|
|
122
|
-
this.
|
|
123
|
-
this.
|
|
124
|
-
this.
|
|
125
|
-
this.
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
70
|
+
this._shutting = false;
|
|
71
|
+
this._sigH = { int: null, term: null };
|
|
72
|
+
this._totalConns = 0;
|
|
73
|
+
this._totalRecv = 0;
|
|
74
|
+
this._totalSent = 0;
|
|
75
|
+
this._shutdownCbs = [];
|
|
76
|
+
this.port = o.port || 3000;
|
|
77
|
+
this.httpServer = o.server || null;
|
|
78
|
+
this.ns = o.namespace || '/';
|
|
79
|
+
this.hbInterval = o.heartbeatInterval || 30000;
|
|
80
|
+
this.hbTimeout = o.heartbeatTimeout || this.hbInterval * 2;
|
|
81
|
+
this.tcpPort = o.tcpPort !== undefined ? o.tcpPort : false;
|
|
82
|
+
this.maxConns = o.maxConnections || 10000;
|
|
83
|
+
this.maxRooms = o.maxRooms || 10000;
|
|
84
|
+
this.maxRoomsPerClient = o.maxRoomsPerClient || 50;
|
|
85
|
+
this.maxPayload = o.maxPayloadSize || 10 * 1024 * 1024;
|
|
86
|
+
this.maxFrame = o.maxFrameSize || DEFAULT_MAX_FRAME_SIZE;
|
|
87
|
+
this.maxWSFrame = o.maxFrameSize || DEFAULT_MAX_WS_FRAME_SIZE;
|
|
88
|
+
this.connTimeout = o.connectTimeout || 10000;
|
|
89
|
+
this.doGraceful = o.gracefulShutdown !== false;
|
|
90
|
+
this.shutdownMs = o.shutdownTimeout || 10000;
|
|
91
|
+
this.healthPath = o.healthEndpoint !== undefined ? o.healthEndpoint : '/health';
|
|
92
|
+
this.tlsOpts = o.tls;
|
|
93
|
+
this.origins = o.allowedOrigins || null;
|
|
94
|
+
this._crl = o.customRateLimiter || null;
|
|
95
|
+
this._cit = o.customIPTracker || null;
|
|
96
|
+
this._genId = o.generateClientId || null;
|
|
97
|
+
this._healthFn = o.customHealthHandler || null;
|
|
98
|
+
this.hooks = o.hooks || {};
|
|
99
|
+
if (o.eventRateLimits)
|
|
100
|
+
for (const [ev, c] of Object.entries(o.eventRateLimits))
|
|
101
|
+
this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs));
|
|
102
|
+
const rl = o.rateLimit && typeof o.rateLimit === 'object' ? o.rateLimit : {};
|
|
103
|
+
this.rateLimiter = o.rateLimit === false && !this._crl ? null : this._crl ? null : new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
|
|
104
|
+
this.ipTracker = this._cit ? new IPTracker() : new IPTracker(o.maxConnectionsPerIP || 50);
|
|
105
|
+
this.log = o.logger === false ? NULL_LOGGER : o.logger instanceof Logger ? o.logger : new Logger({ level: o.logger || 'info', prefix: 'stelar:server' });
|
|
106
|
+
}
|
|
107
|
+
static of(path, o = {}) { return new StelarServer({ ...o, namespace: path }); }
|
|
108
|
+
/* ── Runtime config ── */
|
|
109
|
+
updateConfig(o) {
|
|
110
|
+
if (o.maxConnections !== undefined)
|
|
111
|
+
this.maxConns = o.maxConnections;
|
|
112
|
+
if (o.maxConnectionsPerIP !== undefined && !this._cit)
|
|
113
|
+
this.ipTracker = new IPTracker(o.maxConnectionsPerIP);
|
|
114
|
+
if (o.maxRooms !== undefined)
|
|
115
|
+
this.maxRooms = o.maxRooms;
|
|
116
|
+
if (o.maxRoomsPerClient !== undefined)
|
|
117
|
+
this.maxRoomsPerClient = o.maxRoomsPerClient;
|
|
118
|
+
if (o.maxPayloadSize !== undefined)
|
|
119
|
+
this.maxPayload = o.maxPayloadSize;
|
|
120
|
+
if (o.heartbeatInterval !== undefined)
|
|
121
|
+
this.hbInterval = o.heartbeatInterval;
|
|
122
|
+
if (o.heartbeatTimeout !== undefined)
|
|
123
|
+
this.hbTimeout = o.heartbeatTimeout;
|
|
124
|
+
if (o.allowedOrigins !== undefined)
|
|
125
|
+
this.origins = o.allowedOrigins;
|
|
126
|
+
if (o.rateLimit === false) {
|
|
136
127
|
this.rateLimiter = null;
|
|
128
|
+
this._crl = null;
|
|
137
129
|
}
|
|
138
|
-
else if (!this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
else {
|
|
130
|
+
else if (o.rateLimit && !this._crl)
|
|
131
|
+
this.rateLimiter = new RateLimiter(o.rateLimit.maxPoints || 100, o.rateLimit.windowMs || 1000);
|
|
132
|
+
if (o.customRateLimiter !== undefined) {
|
|
133
|
+
this._crl = o.customRateLimiter;
|
|
143
134
|
this.rateLimiter = null;
|
|
144
135
|
}
|
|
145
|
-
if (
|
|
146
|
-
this.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
this.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
level: options.logger || 'info',
|
|
160
|
-
prefix: 'stelar:server',
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
static of(path, options = {}) {
|
|
165
|
-
return new StelarServer({ ...options, namespace: path });
|
|
166
|
-
}
|
|
167
|
-
/** Update server configuration at runtime. */
|
|
168
|
-
updateConfig(options) {
|
|
169
|
-
if (options.maxConnections !== undefined)
|
|
170
|
-
this.maxConnections = options.maxConnections;
|
|
171
|
-
if (options.maxConnectionsPerIP !== undefined && !this._customIPTracker) {
|
|
172
|
-
this.ipTracker = new IPConnectionTracker(options.maxConnectionsPerIP);
|
|
173
|
-
}
|
|
174
|
-
if (options.maxRooms !== undefined)
|
|
175
|
-
this.maxRooms = options.maxRooms;
|
|
176
|
-
if (options.maxRoomsPerClient !== undefined)
|
|
177
|
-
this.maxRoomsPerClient = options.maxRoomsPerClient;
|
|
178
|
-
if (options.maxPayloadSize !== undefined)
|
|
179
|
-
this.maxPayloadSize = options.maxPayloadSize;
|
|
180
|
-
if (options.heartbeatInterval !== undefined)
|
|
181
|
-
this.heartbeatInterval = options.heartbeatInterval;
|
|
182
|
-
if (options.heartbeatTimeout !== undefined)
|
|
183
|
-
this.heartbeatTimeout = options.heartbeatTimeout;
|
|
184
|
-
if (options.allowedOrigins !== undefined)
|
|
185
|
-
this.allowedOrigins = options.allowedOrigins;
|
|
186
|
-
if (options.rateLimit === false) {
|
|
187
|
-
this.rateLimiter = null;
|
|
188
|
-
this._customRateLimiter = null;
|
|
189
|
-
}
|
|
190
|
-
else if (options.rateLimit && !this._customRateLimiter) {
|
|
191
|
-
const rl = options.rateLimit;
|
|
192
|
-
this.rateLimiter = new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
|
|
193
|
-
}
|
|
194
|
-
if (options.customRateLimiter !== undefined) {
|
|
195
|
-
this._customRateLimiter = options.customRateLimiter;
|
|
196
|
-
this.rateLimiter = null;
|
|
197
|
-
}
|
|
198
|
-
if (options.customIPTracker !== undefined) {
|
|
199
|
-
this._customIPTracker = options.customIPTracker;
|
|
200
|
-
}
|
|
201
|
-
if (options.generateClientId !== undefined) {
|
|
202
|
-
this._generateClientId = options.generateClientId;
|
|
203
|
-
}
|
|
204
|
-
if (options.customHealthHandler !== undefined) {
|
|
205
|
-
this._customHealthHandler = options.customHealthHandler;
|
|
206
|
-
}
|
|
207
|
-
if (options.hooks !== undefined) {
|
|
208
|
-
this.hooks = { ...this.hooks, ...options.hooks };
|
|
209
|
-
}
|
|
210
|
-
if (options.eventRateLimits !== undefined) {
|
|
211
|
-
this.eventRateLimiters.clear();
|
|
212
|
-
for (const [event, config] of Object.entries(options.eventRateLimits)) {
|
|
213
|
-
this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
this.log.info('Server configuration updated');
|
|
136
|
+
if (o.customIPTracker !== undefined)
|
|
137
|
+
this._cit = o.customIPTracker;
|
|
138
|
+
if (o.generateClientId !== undefined)
|
|
139
|
+
this._genId = o.generateClientId;
|
|
140
|
+
if (o.customHealthHandler !== undefined)
|
|
141
|
+
this._healthFn = o.customHealthHandler;
|
|
142
|
+
if (o.hooks !== undefined)
|
|
143
|
+
this.hooks = { ...this.hooks, ...o.hooks };
|
|
144
|
+
if (o.eventRateLimits !== undefined) {
|
|
145
|
+
this.evRateLimits.clear();
|
|
146
|
+
for (const [ev, c] of Object.entries(o.eventRateLimits))
|
|
147
|
+
this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs));
|
|
148
|
+
}
|
|
149
|
+
this.log.info('Config updated');
|
|
217
150
|
return this;
|
|
218
151
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
/** Remove a per-client rate limit override, falling back to the global limiter. */
|
|
225
|
-
removeClientRateLimit(clientId) {
|
|
226
|
-
this._clientRateOverrides.delete(clientId);
|
|
227
|
-
return this;
|
|
228
|
-
}
|
|
229
|
-
/** Set a per-event rate limit. */
|
|
230
|
-
setEventRateLimit(event, config) {
|
|
231
|
-
this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
|
|
232
|
-
return this;
|
|
233
|
-
}
|
|
234
|
-
/** Remove a per-event rate limit. */
|
|
235
|
-
removeEventRateLimit(event) {
|
|
236
|
-
this.eventRateLimiters.delete(event);
|
|
237
|
-
return this;
|
|
238
|
-
}
|
|
239
|
-
/** Get the current server configuration as a read-only object. */
|
|
152
|
+
setClientRateLimit(id, c) { this.clientRates.set(id, new RateLimiter(c.maxPoints, c.windowMs)); return this; }
|
|
153
|
+
removeClientRateLimit(id) { this.clientRates.delete(id); return this; }
|
|
154
|
+
setEventRateLimit(ev, c) { this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs)); return this; }
|
|
155
|
+
removeEventRateLimit(ev) { this.evRateLimits.delete(ev); return this; }
|
|
240
156
|
getConfig() {
|
|
241
157
|
return Object.freeze({
|
|
242
|
-
maxConnections: this.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
heartbeatTimeout: this.heartbeatTimeout,
|
|
249
|
-
connectTimeout: this.connectTimeout,
|
|
250
|
-
shutdownTimeout: this.shutdownTimeout,
|
|
251
|
-
hasCustomRateLimiter: this._customRateLimiter !== null,
|
|
252
|
-
hasCustomIPTracker: this._customIPTracker !== null,
|
|
253
|
-
hasCustomClientIdGenerator: this._generateClientId !== null,
|
|
254
|
-
hasCustomHealthHandler: this._customHealthHandler !== null,
|
|
255
|
-
eventRateLimits: Array.from(this.eventRateLimiters.keys()),
|
|
256
|
-
hooks: Object.keys(this.hooks),
|
|
257
|
-
allowedOrigins: this.allowedOrigins,
|
|
158
|
+
maxConnections: this.maxConns, maxConnectionsPerIP: this._cit ? -1 : 50,
|
|
159
|
+
maxRooms: this.maxRooms, maxRoomsPerClient: this.maxRoomsPerClient, maxPayloadSize: this.maxPayload,
|
|
160
|
+
heartbeatInterval: this.hbInterval, heartbeatTimeout: this.hbTimeout, connectTimeout: this.connTimeout,
|
|
161
|
+
shutdownTimeout: this.shutdownMs, hasCustomRateLimiter: this._crl !== null, hasCustomIPTracker: this._cit !== null,
|
|
162
|
+
hasCustomClientIdGenerator: this._genId !== null, hasCustomHealthHandler: this._healthFn !== null,
|
|
163
|
+
eventRateLimits: Array.from(this.evRateLimits.keys()), hooks: Object.keys(this.hooks), allowedOrigins: this.origins,
|
|
258
164
|
});
|
|
259
165
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
onAll(handler) {
|
|
269
|
-
this._wildcardHandler = handler;
|
|
270
|
-
return this;
|
|
271
|
-
}
|
|
272
|
-
onConnection(handler) {
|
|
273
|
-
this._connectionHandler = handler;
|
|
274
|
-
return this;
|
|
275
|
-
}
|
|
276
|
-
onDisconnect(handler) {
|
|
277
|
-
this.events.set('disconnect', handler);
|
|
278
|
-
return this;
|
|
279
|
-
}
|
|
280
|
-
onAck(name, handler) {
|
|
281
|
-
this._acks.set(name, handler);
|
|
282
|
-
return this;
|
|
283
|
-
}
|
|
166
|
+
/* ── Event registration ── */
|
|
167
|
+
use(mw) { this.mw.push(mw); return this; }
|
|
168
|
+
on(ev, h) { this.events.set(ev, h); return this; }
|
|
169
|
+
onAll(h) { this._wild = h; return this; }
|
|
170
|
+
onConnection(h) { this._connH = h; return this; }
|
|
171
|
+
onDisconnect(h) { this.events.set('disconnect', h); return this; }
|
|
172
|
+
onAck(name, h) { this._acks.set(name, h); return this; }
|
|
173
|
+
/* ── Messaging ── */
|
|
284
174
|
broadcast(event, data, excludeId) {
|
|
285
|
-
if (this.hooks.onBeforeBroadcast)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
175
|
+
if (this.hooks.onBeforeBroadcast?.({ event, data, excludeId }) === false)
|
|
176
|
+
return this;
|
|
177
|
+
const wsF = createWSTextFrame(JSON.stringify({ event, data }));
|
|
178
|
+
const tcpF = encodeJsonFrame(event, data, this.maxFrame);
|
|
290
179
|
let sent = 0;
|
|
291
|
-
this.clients.forEach(
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
sent++;
|
|
296
|
-
});
|
|
297
|
-
this._totalMessagesSent += sent;
|
|
180
|
+
this.clients.forEach(r => { if (excludeId && r.info.id === excludeId)
|
|
181
|
+
return; if (this._write(r, wsF, tcpF))
|
|
182
|
+
sent++; });
|
|
183
|
+
this._totalSent += sent;
|
|
298
184
|
return this;
|
|
299
185
|
}
|
|
300
|
-
broadcastBinary(event,
|
|
301
|
-
this.clients.forEach((record) => {
|
|
302
|
-
this._sendBinaryRaw(record, event, buffer);
|
|
303
|
-
});
|
|
304
|
-
}
|
|
186
|
+
broadcastBinary(event, buf) { this.clients.forEach(r => this._sendBin(r, event, buf)); }
|
|
305
187
|
to(room, event, data, excludeId) {
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
188
|
+
const ids = this.rooms.get(room);
|
|
189
|
+
if (!ids)
|
|
308
190
|
return this;
|
|
191
|
+
const wsF = createWSTextFrame(JSON.stringify({ event, data }));
|
|
192
|
+
const tcpF = encodeJsonFrame(event, data, this.maxFrame);
|
|
309
193
|
let sent = 0;
|
|
310
|
-
for (const
|
|
311
|
-
if (excludeId &&
|
|
194
|
+
for (const id of ids) {
|
|
195
|
+
if (excludeId && id === excludeId)
|
|
312
196
|
continue;
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
197
|
+
const r = this.byId.get(id);
|
|
198
|
+
if (r && this._write(r, wsF, tcpF))
|
|
315
199
|
sent++;
|
|
316
200
|
}
|
|
317
|
-
this.
|
|
201
|
+
this._totalSent += sent;
|
|
318
202
|
return this;
|
|
319
203
|
}
|
|
320
204
|
toId(id, event, data) {
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
this.
|
|
324
|
-
}
|
|
205
|
+
const r = this.byId.get(id);
|
|
206
|
+
if (r && this._sendJson(r, event, data))
|
|
207
|
+
this._totalSent++;
|
|
325
208
|
return this;
|
|
326
209
|
}
|
|
327
210
|
getClients(room) {
|
|
328
211
|
const list = [];
|
|
329
|
-
this.clients.forEach(
|
|
330
|
-
|
|
331
|
-
list.push({ id: record.info.id, rooms: Array.from(record.info.rooms) });
|
|
332
|
-
}
|
|
333
|
-
});
|
|
212
|
+
this.clients.forEach(r => { if (!room || r.info.rooms.has(room))
|
|
213
|
+
list.push({ id: r.info.id, rooms: [...r.info.rooms] }); });
|
|
334
214
|
return list;
|
|
335
215
|
}
|
|
336
|
-
getRoomMembers(room) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
getRooms() {
|
|
341
|
-
return Array.from(this.rooms.keys());
|
|
342
|
-
}
|
|
343
|
-
getPort() {
|
|
344
|
-
const address = this.httpServer?.address();
|
|
345
|
-
if (address && typeof address === 'object') {
|
|
346
|
-
return address.port;
|
|
347
|
-
}
|
|
348
|
-
return this.port;
|
|
349
|
-
}
|
|
216
|
+
getRoomMembers(room) { return this.rooms.get(room) ? [...this.rooms.get(room)] : []; }
|
|
217
|
+
getRooms() { return [...this.rooms.keys()]; }
|
|
218
|
+
getPort() { const a = this.httpServer?.address(); return a && typeof a === 'object' ? a.port : this.port; }
|
|
350
219
|
getStats() {
|
|
351
|
-
let
|
|
352
|
-
|
|
353
|
-
this.clients.forEach((r) => {
|
|
354
|
-
if (r.protocol === 'ws')
|
|
355
|
-
wsConns++;
|
|
356
|
-
else
|
|
357
|
-
tcpConns++;
|
|
358
|
-
});
|
|
220
|
+
let ws = 0, tcp = 0;
|
|
221
|
+
this.clients.forEach(r => r.protocol === 'ws' ? ws++ : tcp++);
|
|
359
222
|
return {
|
|
360
|
-
totalConnections: this.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
uptime: this._startTime ? Date.now() - this._startTime : 0,
|
|
366
|
-
wsConnections: wsConns,
|
|
367
|
-
tcpConnections: tcpConns,
|
|
368
|
-
memoryUsage: process.memoryUsage(),
|
|
369
|
-
rateLimiterEntries: this._getRateLimiterSize(),
|
|
223
|
+
totalConnections: this._totalConns, activeConnections: this.clients.size,
|
|
224
|
+
totalMessagesReceived: this._totalRecv, totalMessagesSent: this._totalSent,
|
|
225
|
+
totalRooms: this.rooms.size, uptime: this._startTime ? Date.now() - this._startTime : 0,
|
|
226
|
+
wsConnections: ws, tcpConnections: tcp, memoryUsage: process.memoryUsage(),
|
|
227
|
+
rateLimiterEntries: this._crl?.size() ?? this.rateLimiter?.size() ?? 0,
|
|
370
228
|
};
|
|
371
229
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return clientOverride.check(clientId);
|
|
382
|
-
}
|
|
383
|
-
if (event && this.eventRateLimiters.has(event)) {
|
|
384
|
-
const eventLimiter = this.eventRateLimiters.get(event);
|
|
385
|
-
if (!eventLimiter.check(clientId))
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
if (this._customRateLimiter) {
|
|
389
|
-
return this._customRateLimiter.check(clientId);
|
|
230
|
+
onShutdown(cb) { this._shutdownCbs.push(cb); return this; }
|
|
231
|
+
/* ── Private: send helpers ── */
|
|
232
|
+
_sendJson(r, event, data) {
|
|
233
|
+
if (r.socket.destroyed || r.socket.writableEnded)
|
|
234
|
+
return false;
|
|
235
|
+
try {
|
|
236
|
+
r.socket.write(r.protocol === 'ws' ? createWSTextFrame(JSON.stringify({ event, data })) : encodeJsonFrame(event, data, this.maxFrame));
|
|
237
|
+
r.info.messagesSent++;
|
|
238
|
+
return true;
|
|
390
239
|
}
|
|
391
|
-
|
|
392
|
-
return
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
393
242
|
}
|
|
394
|
-
return true;
|
|
395
243
|
}
|
|
396
|
-
|
|
397
|
-
if (
|
|
244
|
+
_write(r, wsF, tcpF) {
|
|
245
|
+
if (r.socket.destroyed || r.socket.writableEnded)
|
|
398
246
|
return false;
|
|
399
247
|
try {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
record.socket.write(createWSTextFrame(json));
|
|
403
|
-
}
|
|
404
|
-
else {
|
|
405
|
-
record.socket.write(encodeJsonFrame(event, data, this.maxFrameSize));
|
|
406
|
-
}
|
|
407
|
-
record.info.messagesSent++;
|
|
248
|
+
r.socket.write(r.protocol === 'ws' ? wsF : tcpF);
|
|
249
|
+
r.info.messagesSent++;
|
|
408
250
|
return true;
|
|
409
251
|
}
|
|
410
|
-
catch
|
|
411
|
-
this.log.error('Send error', { clientId: record.info.id, error: String(err) });
|
|
252
|
+
catch {
|
|
412
253
|
return false;
|
|
413
254
|
}
|
|
414
255
|
}
|
|
415
|
-
|
|
416
|
-
if (
|
|
256
|
+
_sendBin(r, event, buf) {
|
|
257
|
+
if (r.socket.destroyed || r.socket.writableEnded)
|
|
417
258
|
return false;
|
|
418
259
|
try {
|
|
419
|
-
if (
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
combined
|
|
425
|
-
|
|
426
|
-
record.socket.write(createWSBinaryFrame(combined));
|
|
260
|
+
if (r.protocol === 'ws') {
|
|
261
|
+
const hdr = Buffer.from(JSON.stringify({ event, _binary: true }), 'utf8');
|
|
262
|
+
const combined = Buffer.alloc(hdr.length + 1 + buf.byteLength);
|
|
263
|
+
hdr.copy(combined, 0);
|
|
264
|
+
combined[hdr.length] = 0;
|
|
265
|
+
combined.set(new Uint8Array(buf), hdr.length + 1);
|
|
266
|
+
r.socket.write(createWSBinaryFrame(combined));
|
|
427
267
|
}
|
|
428
268
|
else {
|
|
429
|
-
|
|
269
|
+
r.socket.write(encodeBinaryFrame(event, new Uint8Array(buf), this.maxFrame));
|
|
430
270
|
}
|
|
431
|
-
|
|
271
|
+
r.info.messagesSent++;
|
|
432
272
|
return true;
|
|
433
273
|
}
|
|
434
|
-
catch
|
|
435
|
-
this.log.error('Binary send error', { clientId: record.info.id, error: String(err) });
|
|
274
|
+
catch {
|
|
436
275
|
return false;
|
|
437
276
|
}
|
|
438
277
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
278
|
+
_checkRate(cid, event) {
|
|
279
|
+
const co = this.clientRates.get(cid);
|
|
280
|
+
if (co)
|
|
281
|
+
return co.check(cid);
|
|
282
|
+
if (event && this.evRateLimits.has(event) && !this.evRateLimits.get(event).check(cid))
|
|
283
|
+
return false;
|
|
284
|
+
if (this._crl)
|
|
285
|
+
return this._crl.check(cid);
|
|
286
|
+
if (this.rateLimiter)
|
|
287
|
+
return this.rateLimiter.check(cid);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
_getIP(socket, req) {
|
|
291
|
+
if (req) {
|
|
292
|
+
const fwd = req.headers['x-forwarded-for'];
|
|
293
|
+
if (typeof fwd === 'string')
|
|
294
|
+
return fwd.split(',')[0].trim();
|
|
295
|
+
}
|
|
296
|
+
return socket.remoteAddress || 'unknown';
|
|
297
|
+
}
|
|
298
|
+
/* ── Private: client lifecycle ── */
|
|
299
|
+
_register(socket, proto, req, parser) {
|
|
300
|
+
const ip = this._getIP(socket, req);
|
|
301
|
+
if (this.clients.size >= this.maxConns) {
|
|
302
|
+
this.hooks.onMaxConnectionsReached?.({ activeConnections: this.clients.size, max: this.maxConns, ip });
|
|
303
|
+
this.log.warn('Max connections reached', { active: this.clients.size, max: this.maxConns });
|
|
304
|
+
if (proto === 'ws')
|
|
305
|
+
try {
|
|
306
|
+
socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Server full'));
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
socket.destroy();
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const tracker = this._cit || this.ipTracker;
|
|
313
|
+
if (!tracker.check(ip)) {
|
|
314
|
+
this.log.warn('Max connections per IP', { ip });
|
|
315
|
+
if (proto === 'ws')
|
|
316
|
+
try {
|
|
317
|
+
socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Too many connections'));
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
socket.destroy();
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const id = this._genId ? this._genId() : randomUUID();
|
|
324
|
+
const info = { id, rooms: new Set(), lastPing: Date.now(), protocol: proto, connectedAt: Date.now(), metadata: new Map(), messagesReceived: 0, messagesSent: 0, remoteAddress: ip };
|
|
325
|
+
const record = { info, socket, parser, protocol: proto };
|
|
326
|
+
this.clients.set(socket, record);
|
|
327
|
+
this.byId.set(id, record);
|
|
328
|
+
tracker.add(ip);
|
|
329
|
+
this._totalConns++;
|
|
330
|
+
return record;
|
|
331
|
+
}
|
|
332
|
+
_unregister(r, ctx) {
|
|
333
|
+
this.hooks.onClientDisconnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: r.info.protocol, rooms: new Set(r.info.rooms) });
|
|
334
|
+
for (const room of r.info.rooms) {
|
|
335
|
+
const m = this.rooms.get(room);
|
|
336
|
+
if (m) {
|
|
337
|
+
m.delete(r.info.id);
|
|
338
|
+
if (!m.size)
|
|
339
|
+
this.rooms.delete(room);
|
|
449
340
|
}
|
|
450
341
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
342
|
+
r.info.rooms.clear();
|
|
343
|
+
this.byId.delete(r.info.id);
|
|
344
|
+
this.clients.delete(r.socket);
|
|
345
|
+
(this._cit || this.ipTracker).remove(r.info.remoteAddress);
|
|
346
|
+
if (this._crl)
|
|
347
|
+
this._crl.reset(r.info.id);
|
|
348
|
+
else
|
|
349
|
+
this.rateLimiter?.reset(r.info.id);
|
|
350
|
+
this.clientRates.delete(r.info.id);
|
|
351
|
+
const h = this.events.get('disconnect');
|
|
352
|
+
if (h)
|
|
353
|
+
try {
|
|
354
|
+
h({ ...ctx, event: 'disconnect' });
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
this.log.error('Disconnect handler error', { error: String(e) });
|
|
461
358
|
}
|
|
462
|
-
|
|
359
|
+
}
|
|
360
|
+
_joinRoom(r, room) {
|
|
361
|
+
if (this.hooks.onClientJoinRoom?.({ clientId: r.info.id, room, metadata: r.info.metadata }) === false)
|
|
362
|
+
return;
|
|
363
|
+
if (r.info.rooms.size >= this.maxRoomsPerClient) {
|
|
364
|
+
this.hooks.onMaxRoomsPerClientReached?.({ clientId: r.info.id, room, currentRooms: r.info.rooms.size, max: this.maxRoomsPerClient });
|
|
463
365
|
return;
|
|
464
366
|
}
|
|
465
367
|
if (this.rooms.size >= this.maxRooms && !this.rooms.has(room)) {
|
|
466
|
-
|
|
467
|
-
const result = this.hooks.onMaxRoomsReached({
|
|
468
|
-
clientId: record.info.id,
|
|
469
|
-
room,
|
|
470
|
-
totalRooms: this.rooms.size,
|
|
471
|
-
max: this.maxRooms,
|
|
472
|
-
});
|
|
473
|
-
if (result === false)
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
this.log.warn('Server exceeded max rooms', { room, max: this.maxRooms });
|
|
368
|
+
this.hooks.onMaxRoomsReached?.({ clientId: r.info.id, room, totalRooms: this.rooms.size, max: this.maxRooms });
|
|
477
369
|
return;
|
|
478
370
|
}
|
|
479
|
-
|
|
480
|
-
if (!this.rooms.has(room))
|
|
371
|
+
r.info.rooms.add(room);
|
|
372
|
+
if (!this.rooms.has(room))
|
|
481
373
|
this.rooms.set(room, new Set());
|
|
482
|
-
|
|
483
|
-
this.
|
|
484
|
-
this._sendJsonToClient(record, 'joined-room', room);
|
|
374
|
+
this.rooms.get(room).add(r.info.id);
|
|
375
|
+
this._sendJson(r, 'joined-room', room);
|
|
485
376
|
}
|
|
486
|
-
_leaveRoom(
|
|
487
|
-
if (this.hooks.onClientLeaveRoom)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
record.info.rooms.delete(room);
|
|
498
|
-
const members = this.rooms.get(room);
|
|
499
|
-
if (members) {
|
|
500
|
-
members.delete(record.info.id);
|
|
501
|
-
if (members.size === 0) {
|
|
377
|
+
_leaveRoom(r, room) {
|
|
378
|
+
if (this.hooks.onClientLeaveRoom?.({ clientId: r.info.id, room }) === false)
|
|
379
|
+
return;
|
|
380
|
+
r.info.rooms.delete(room);
|
|
381
|
+
const m = this.rooms.get(room);
|
|
382
|
+
if (m) {
|
|
383
|
+
m.delete(r.info.id);
|
|
384
|
+
if (!m.size)
|
|
502
385
|
this.rooms.delete(room);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
this._sendJsonToClient(record, 'left-room', room);
|
|
506
|
-
}
|
|
507
|
-
_removeFromAllRooms(record) {
|
|
508
|
-
for (const room of record.info.rooms) {
|
|
509
|
-
const members = this.rooms.get(room);
|
|
510
|
-
if (members) {
|
|
511
|
-
members.delete(record.info.id);
|
|
512
|
-
if (members.size === 0) {
|
|
513
|
-
this.rooms.delete(room);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
386
|
}
|
|
517
|
-
|
|
387
|
+
this._sendJson(r, 'left-room', room);
|
|
518
388
|
}
|
|
519
|
-
|
|
520
|
-
|
|
389
|
+
/* ── Private: context & middleware ── */
|
|
390
|
+
_buildCtx(r, req) {
|
|
391
|
+
const s = this;
|
|
521
392
|
const ctx = {
|
|
522
|
-
id:
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (result !== undefined) {
|
|
546
|
-
try {
|
|
547
|
-
if (record.protocol === 'ws') {
|
|
548
|
-
record.socket.write(createWSTextFrame(JSON.stringify({ event: ackName, data: result, _isAck: true })));
|
|
549
|
-
}
|
|
550
|
-
else {
|
|
551
|
-
record.socket.write(encodeAckResFrame(ackName, result, self.maxFrameSize));
|
|
552
|
-
}
|
|
553
|
-
self._totalMessagesSent++;
|
|
554
|
-
}
|
|
555
|
-
catch (err) {
|
|
556
|
-
self.log.error('ACK send error', { ackName, error: String(err) });
|
|
557
|
-
}
|
|
558
|
-
}
|
|
393
|
+
id: r.info.id, socket: r.socket, req, clientInfo: r.info,
|
|
394
|
+
emit: (ev, d) => { if (s._sendJson(r, ev, d))
|
|
395
|
+
s._totalSent++; },
|
|
396
|
+
send: (rid, d) => { if (s._sendJson(r, rid, { data: d, _isAck: true }))
|
|
397
|
+
s._totalSent++; },
|
|
398
|
+
emitBinary: (ev, buf) => { if (s._sendBin(r, ev, buf))
|
|
399
|
+
s._totalSent++; },
|
|
400
|
+
broadcast: (ev, d) => s.broadcast(ev, d, r.info.id),
|
|
401
|
+
broadcastBinary: (ev, buf) => s.broadcastBinary(ev, buf),
|
|
402
|
+
to: (room, ev, d) => s.to(room, ev, d, r.info.id),
|
|
403
|
+
toId: (id, ev, d) => s.toId(id, ev, d),
|
|
404
|
+
getClients: (room) => s.getClients(room),
|
|
405
|
+
joinRoom: (room) => s._joinRoom(r, room),
|
|
406
|
+
leaveRoom: (room) => s._leaveRoom(r, room),
|
|
407
|
+
setMetadata: (k, v) => r.info.metadata.set(k, v),
|
|
408
|
+
getMetadata: (k) => r.info.metadata.get(k),
|
|
409
|
+
ack: (name, d) => {
|
|
410
|
+
const h = s._acks.get(name);
|
|
411
|
+
if (!h)
|
|
412
|
+
return;
|
|
413
|
+
let res;
|
|
414
|
+
try {
|
|
415
|
+
res = h({ ...ctx, data: d });
|
|
559
416
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
564
|
-
runMiddlewares(ctx, next) {
|
|
565
|
-
const run = (i) => {
|
|
566
|
-
if (i >= this.middlewares.length)
|
|
567
|
-
return next();
|
|
568
|
-
try {
|
|
569
|
-
this.middlewares[i](ctx, () => run(i + 1));
|
|
570
|
-
}
|
|
571
|
-
catch (err) {
|
|
572
|
-
this.log.error('Middleware error', { error: String(err), clientId: ctx.id });
|
|
573
|
-
ctx.socket.destroy();
|
|
574
|
-
}
|
|
575
|
-
};
|
|
576
|
-
run(0);
|
|
577
|
-
}
|
|
578
|
-
startHeartbeat() {
|
|
579
|
-
this._hbTimer = setInterval(() => {
|
|
580
|
-
const now = Date.now();
|
|
581
|
-
this.clients.forEach((record) => {
|
|
582
|
-
if (now - record.info.lastPing > this.heartbeatTimeout) {
|
|
583
|
-
this.log.info('Client heartbeat timeout', { clientId: record.info.id });
|
|
584
|
-
record.socket.destroy();
|
|
417
|
+
catch (e) {
|
|
418
|
+
s.log.error('ACK handler error', { name, error: String(e) });
|
|
419
|
+
return;
|
|
585
420
|
}
|
|
586
|
-
|
|
421
|
+
if (res !== undefined) {
|
|
587
422
|
try {
|
|
588
|
-
if (
|
|
589
|
-
|
|
423
|
+
if (r.protocol === 'ws') {
|
|
424
|
+
const p = { event: name, data: res, _isAck: true };
|
|
425
|
+
if (ctx._correlationId)
|
|
426
|
+
p._correlationId = ctx._correlationId;
|
|
427
|
+
r.socket.write(createWSTextFrame(JSON.stringify(p)));
|
|
590
428
|
}
|
|
591
429
|
else {
|
|
592
|
-
|
|
430
|
+
r.socket.write(ctx._correlationId
|
|
431
|
+
? encodeAckResFrame(name, { data: res, _correlationId: ctx._correlationId }, s.maxFrame)
|
|
432
|
+
: encodeAckResFrame(name, res, s.maxFrame));
|
|
593
433
|
}
|
|
434
|
+
s._totalSent++;
|
|
594
435
|
}
|
|
595
|
-
catch {
|
|
596
|
-
|
|
436
|
+
catch (e) {
|
|
437
|
+
s.log.error('ACK send error', { name, error: String(e) });
|
|
597
438
|
}
|
|
598
439
|
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
this._hbTimer.unref();
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
_getClientIP(socket, req) {
|
|
606
|
-
if (req) {
|
|
607
|
-
const forwarded = req.headers['x-forwarded-for'];
|
|
608
|
-
if (typeof forwarded === 'string') {
|
|
609
|
-
return forwarded.split(',')[0].trim();
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return socket.remoteAddress || 'unknown';
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
return ctx;
|
|
613
443
|
}
|
|
614
|
-
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
this.hooks.onMaxConnectionsReached({
|
|
619
|
-
activeConnections: this.clients.size,
|
|
620
|
-
max: this.maxConnections,
|
|
621
|
-
ip: clientIP,
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
this.log.warn('Max connections reached, rejecting', { active: this.clients.size, max: this.maxConnections });
|
|
625
|
-
try {
|
|
626
|
-
if (protocol === 'ws') {
|
|
627
|
-
socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Server full'));
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
catch { /* ignore */ }
|
|
631
|
-
socket.destroy();
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
const clientIP = this._getClientIP(socket, req);
|
|
635
|
-
const ipTracker = this._customIPTracker || this.ipTracker;
|
|
636
|
-
if (!ipTracker.check(clientIP)) {
|
|
637
|
-
this.log.warn('Max connections per IP reached, rejecting', { ip: clientIP });
|
|
638
|
-
try {
|
|
639
|
-
if (protocol === 'ws') {
|
|
640
|
-
socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Too many connections from this IP'));
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
catch { /* ignore */ }
|
|
644
|
-
socket.destroy();
|
|
645
|
-
return null;
|
|
444
|
+
_runMw(ctx, next) {
|
|
445
|
+
const run = (i) => { if (i >= this.mw.length)
|
|
446
|
+
return next(); try {
|
|
447
|
+
this.mw[i](ctx, () => run(i + 1));
|
|
646
448
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
lastPing: Date.now(),
|
|
652
|
-
protocol,
|
|
653
|
-
connectedAt: Date.now(),
|
|
654
|
-
metadata: new Map(),
|
|
655
|
-
messagesReceived: 0,
|
|
656
|
-
messagesSent: 0,
|
|
657
|
-
remoteAddress: clientIP,
|
|
658
|
-
};
|
|
659
|
-
const record = { info, socket, parser, protocol };
|
|
660
|
-
this.clients.set(socket, record);
|
|
661
|
-
this.clientsById.set(clientId, record);
|
|
662
|
-
ipTracker.add(clientIP);
|
|
663
|
-
this._totalConnections++;
|
|
664
|
-
return record;
|
|
449
|
+
catch {
|
|
450
|
+
ctx.socket.destroy();
|
|
451
|
+
} };
|
|
452
|
+
run(0);
|
|
665
453
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
protocol: record.info.protocol,
|
|
672
|
-
rooms: new Set(record.info.rooms),
|
|
673
|
-
});
|
|
454
|
+
/* ── Private: event dispatch (shared by WS & TCP) ── */
|
|
455
|
+
_dispatch(r, ctx, event, data, correlationId) {
|
|
456
|
+
if (event === 'pong') {
|
|
457
|
+
r.info.lastPing = Date.now();
|
|
458
|
+
return;
|
|
674
459
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
ipTracker.remove(record.info.remoteAddress);
|
|
680
|
-
if (this._customRateLimiter) {
|
|
681
|
-
this._customRateLimiter.reset(record.info.id);
|
|
460
|
+
if (event === 'join-room') {
|
|
461
|
+
if (data)
|
|
462
|
+
this._joinRoom(r, String(data));
|
|
463
|
+
return;
|
|
682
464
|
}
|
|
683
|
-
|
|
684
|
-
|
|
465
|
+
if (event === 'leave-room') {
|
|
466
|
+
if (data)
|
|
467
|
+
this._leaveRoom(r, String(data));
|
|
468
|
+
return;
|
|
685
469
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
470
|
+
const ectx = { ...ctx, data, event, _correlationId: correlationId };
|
|
471
|
+
const h = this.events.get(event);
|
|
472
|
+
if (h)
|
|
689
473
|
try {
|
|
690
|
-
|
|
474
|
+
h(ectx);
|
|
691
475
|
}
|
|
692
|
-
catch (
|
|
693
|
-
this.log.error('
|
|
476
|
+
catch (e) {
|
|
477
|
+
this.log.error('Event handler error', { event, error: String(e) });
|
|
478
|
+
}
|
|
479
|
+
if (this._wild)
|
|
480
|
+
try {
|
|
481
|
+
this._wild({ event, data: ectx });
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
this.log.error('Wildcard error', { error: String(e) });
|
|
694
485
|
}
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
_checkOrigin(req) {
|
|
698
|
-
if (!this.allowedOrigins)
|
|
699
|
-
return true;
|
|
700
|
-
const origin = req.headers['origin'];
|
|
701
|
-
if (!origin)
|
|
702
|
-
return true;
|
|
703
|
-
return this.allowedOrigins.includes(origin);
|
|
704
486
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
this.
|
|
487
|
+
/* ── Private: heartbeat ── */
|
|
488
|
+
_startHeartbeat() {
|
|
489
|
+
this._hb = setInterval(() => {
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
this.clients.forEach(r => {
|
|
492
|
+
if (now - r.info.lastPing > this.hbTimeout) {
|
|
493
|
+
r.socket.destroy();
|
|
494
|
+
}
|
|
495
|
+
else
|
|
496
|
+
try {
|
|
497
|
+
r.socket.write(r.protocol === 'ws' ? createWSPingFrame() : encodePingFrame());
|
|
498
|
+
}
|
|
499
|
+
catch { }
|
|
500
|
+
});
|
|
501
|
+
}, this.hbInterval);
|
|
502
|
+
this._hb?.unref?.();
|
|
503
|
+
}
|
|
504
|
+
/* ── Private: WS upgrade ── */
|
|
505
|
+
_wsUpgrade(req, socket, head) {
|
|
506
|
+
const path = new URL(req.url || '/', 'http://localhost').pathname;
|
|
507
|
+
const nsPath = this.ns === '/' ? '/' : this.ns;
|
|
508
|
+
if (nsPath !== '/' && path !== nsPath) {
|
|
710
509
|
socket.destroy();
|
|
711
510
|
return;
|
|
712
511
|
}
|
|
713
|
-
if (!this.
|
|
714
|
-
this.log.warn('Rejected WS: origin not allowed', { origin: req.headers['origin'] });
|
|
512
|
+
if (this.origins && !this.origins.includes(req.headers['origin'] || '')) {
|
|
715
513
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
716
514
|
socket.destroy();
|
|
717
515
|
return;
|
|
718
516
|
}
|
|
719
517
|
const key = req.headers['sec-websocket-key'];
|
|
720
518
|
if (!key || !validateWSKey(key)) {
|
|
721
|
-
this.log.warn('Invalid WebSocket key');
|
|
722
519
|
socket.destroy();
|
|
723
520
|
return;
|
|
724
521
|
}
|
|
725
522
|
try {
|
|
726
|
-
const
|
|
523
|
+
const extra = {};
|
|
727
524
|
const origin = req.headers['origin'];
|
|
728
|
-
if (origin && this.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
socket.write(buildUpgradeResponse(key, extraHeaders));
|
|
525
|
+
if (origin && this.origins?.includes(origin))
|
|
526
|
+
extra['Access-Control-Allow-Origin'] = origin;
|
|
527
|
+
socket.write(buildUpgradeResponse(key, extra));
|
|
732
528
|
}
|
|
733
529
|
catch {
|
|
734
530
|
socket.destroy();
|
|
735
531
|
return;
|
|
736
532
|
}
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
connectTimer.unref();
|
|
744
|
-
const record = this._registerClient(socket, 'ws', req, new WSFrameParser(this.maxWSFrameSize));
|
|
745
|
-
if (!record) {
|
|
746
|
-
clearTimeout(connectTimer);
|
|
533
|
+
const timer = setTimeout(() => { if (!this.clients.has(socket))
|
|
534
|
+
socket.destroy(); }, this.connTimeout);
|
|
535
|
+
timer.unref();
|
|
536
|
+
const r = this._register(socket, 'ws', req, new WSFrameParser(this.maxWSFrame));
|
|
537
|
+
if (!r) {
|
|
538
|
+
clearTimeout(timer);
|
|
747
539
|
return;
|
|
748
540
|
}
|
|
749
|
-
const ctx = this._buildCtx(
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
this.
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
});
|
|
768
|
-
this.log.info('WS client connected', { clientId: record.info.id, ip: record.info.remoteAddress });
|
|
769
|
-
if (head.length > 0) {
|
|
770
|
-
this._processWSData(record, head, ctx, req);
|
|
771
|
-
}
|
|
772
|
-
socket.on('data', (data) => {
|
|
773
|
-
clearTimeout(connectTimer);
|
|
774
|
-
this._processWSData(record, data, ctx, req);
|
|
775
|
-
});
|
|
776
|
-
socket.on('close', () => {
|
|
777
|
-
clearTimeout(connectTimer);
|
|
778
|
-
this.log.debug('WS client socket closed', { clientId: record.info.id });
|
|
779
|
-
this._unregisterClient(record, ctx);
|
|
780
|
-
});
|
|
781
|
-
socket.on('error', (err) => {
|
|
782
|
-
this.log.warn('WS client error', { clientId: record.info.id, error: err.message });
|
|
783
|
-
this._handleError(record, ctx, err);
|
|
784
|
-
});
|
|
785
|
-
socket.on('drain', () => {
|
|
786
|
-
socket.resume();
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
_processWSData(record, data, ctx, req) {
|
|
541
|
+
const ctx = this._buildCtx(r, req);
|
|
542
|
+
this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'ws', metadata: r.info.metadata });
|
|
543
|
+
this._runMw(ctx, () => { if (this._connH)
|
|
544
|
+
try {
|
|
545
|
+
this._connH(ctx);
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
this.log.error('Connection handler error', { error: String(e) });
|
|
549
|
+
} });
|
|
550
|
+
this.log.info('WS connected', { clientId: r.info.id, ip: r.info.remoteAddress });
|
|
551
|
+
if (head.length > 0)
|
|
552
|
+
this._processWS(r, head, ctx);
|
|
553
|
+
socket.on('data', (d) => { clearTimeout(timer); this._processWS(r, d, ctx); });
|
|
554
|
+
socket.on('close', () => { clearTimeout(timer); this._unregister(r, ctx); });
|
|
555
|
+
socket.on('error', (e) => { this.log.warn('WS error', { clientId: r.info.id, error: e.message }); this._handleErr(r, ctx, e); });
|
|
556
|
+
socket.on('drain', () => socket.resume());
|
|
557
|
+
}
|
|
558
|
+
_processWS(r, data, ctx) {
|
|
790
559
|
let frames;
|
|
791
560
|
try {
|
|
792
|
-
frames =
|
|
561
|
+
frames = r.parser.feed(data);
|
|
793
562
|
}
|
|
794
|
-
catch (
|
|
795
|
-
if (
|
|
796
|
-
this.log.warn('WS protocol error', {
|
|
563
|
+
catch (e) {
|
|
564
|
+
if (e instanceof WebSocketError) {
|
|
565
|
+
this.log.warn('WS protocol error', { code: e.code, message: e.message });
|
|
797
566
|
try {
|
|
798
|
-
|
|
567
|
+
r.socket.write(createWSCloseFrame(e.code, e.message));
|
|
799
568
|
}
|
|
800
|
-
catch {
|
|
801
|
-
}
|
|
802
|
-
else {
|
|
803
|
-
this.log.error('WS frame parse error', { clientId: record.info.id, error: String(err) });
|
|
569
|
+
catch { }
|
|
804
570
|
}
|
|
805
|
-
|
|
571
|
+
else
|
|
572
|
+
this.log.error('WS parse error', { error: String(e) });
|
|
573
|
+
r.socket.destroy();
|
|
806
574
|
return;
|
|
807
575
|
}
|
|
808
|
-
for (const
|
|
809
|
-
if (
|
|
810
|
-
|
|
811
|
-
this._handleWSFrame(record, frame, ctx, req);
|
|
576
|
+
for (const f of frames) {
|
|
577
|
+
if (!r.socket.destroyed)
|
|
578
|
+
this._handleWSFrame(r, f, ctx);
|
|
812
579
|
}
|
|
813
580
|
}
|
|
814
|
-
_handleWSFrame(
|
|
581
|
+
_handleWSFrame(r, frame, ctx) {
|
|
815
582
|
const { opcode, payload } = frame;
|
|
816
583
|
if (opcode === OP_PING) {
|
|
817
584
|
try {
|
|
818
|
-
|
|
585
|
+
r.socket.write(createWSPongFrame(payload));
|
|
819
586
|
}
|
|
820
|
-
catch {
|
|
587
|
+
catch { }
|
|
821
588
|
return;
|
|
822
589
|
}
|
|
823
590
|
if (opcode === OP_CLOSE) {
|
|
824
591
|
try {
|
|
825
|
-
|
|
592
|
+
r.socket.write(createWSCloseFrame());
|
|
826
593
|
}
|
|
827
|
-
catch {
|
|
828
|
-
|
|
594
|
+
catch { }
|
|
595
|
+
r.socket.end();
|
|
829
596
|
return;
|
|
830
597
|
}
|
|
831
598
|
if (opcode === OP_PONG) {
|
|
832
|
-
|
|
599
|
+
r.info.lastPing = Date.now();
|
|
833
600
|
return;
|
|
834
601
|
}
|
|
835
|
-
if (!this.
|
|
836
|
-
this.log.warn('Rate limit exceeded', { clientId:
|
|
837
|
-
if (this.hooks.onRateLimitExceeded)
|
|
838
|
-
|
|
839
|
-
clientId: record.info.id,
|
|
840
|
-
protocol: 'ws',
|
|
841
|
-
});
|
|
842
|
-
if (result === false)
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
602
|
+
if (!this._checkRate(r.info.id)) {
|
|
603
|
+
this.log.warn('Rate limit exceeded', { clientId: r.info.id });
|
|
604
|
+
if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, protocol: 'ws' }) === false)
|
|
605
|
+
return;
|
|
845
606
|
try {
|
|
846
|
-
|
|
607
|
+
r.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded'));
|
|
847
608
|
}
|
|
848
|
-
catch {
|
|
849
|
-
|
|
609
|
+
catch { }
|
|
610
|
+
r.socket.destroy();
|
|
850
611
|
return;
|
|
851
612
|
}
|
|
852
613
|
if (opcode === OP_TEXT) {
|
|
853
|
-
|
|
854
|
-
this.
|
|
855
|
-
if (payload.length > this.
|
|
856
|
-
|
|
857
|
-
this.hooks.onPayloadTooLarge({ clientId: record.info.id, size: payload.length, max: this.maxPayloadSize });
|
|
858
|
-
}
|
|
859
|
-
this.log.warn('Payload too large', { clientId: record.info.id, size: payload.length });
|
|
614
|
+
r.info.messagesReceived++;
|
|
615
|
+
this._totalRecv++;
|
|
616
|
+
if (payload.length > this.maxPayload) {
|
|
617
|
+
this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, size: payload.length, max: this.maxPayload });
|
|
860
618
|
try {
|
|
861
|
-
|
|
619
|
+
r.socket.write(createWSCloseFrame(CLOSE_MESSAGE_TOO_BIG));
|
|
862
620
|
}
|
|
863
|
-
catch {
|
|
864
|
-
|
|
621
|
+
catch { }
|
|
622
|
+
r.socket.destroy();
|
|
865
623
|
return;
|
|
866
624
|
}
|
|
867
625
|
let msg;
|
|
@@ -869,298 +627,190 @@ class StelarServer {
|
|
|
869
627
|
msg = JSON.parse(payload.toString('utf8'));
|
|
870
628
|
}
|
|
871
629
|
catch {
|
|
872
|
-
|
|
873
|
-
this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid JSON', protocol: 'ws' });
|
|
874
|
-
}
|
|
875
|
-
this.log.warn('Invalid JSON from client', { clientId: record.info.id });
|
|
630
|
+
this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid JSON', protocol: 'ws' });
|
|
876
631
|
return;
|
|
877
632
|
}
|
|
878
|
-
const event = String(msg.event || '');
|
|
879
|
-
const data = msg.data;
|
|
633
|
+
const event = String(msg.event || ''), data = msg.data, corrId = msg._correlationId ? String(msg._correlationId) : undefined;
|
|
880
634
|
if (!event)
|
|
881
635
|
return;
|
|
882
|
-
if (
|
|
883
|
-
this.log.warn('Event rate limit
|
|
884
|
-
if (this.hooks.onRateLimitExceeded)
|
|
885
|
-
|
|
886
|
-
clientId: record.info.id,
|
|
887
|
-
event,
|
|
888
|
-
protocol: 'ws',
|
|
889
|
-
});
|
|
890
|
-
if (result === false)
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
636
|
+
if (!this._checkRate(r.info.id, event)) {
|
|
637
|
+
this.log.warn('Event rate limit', { clientId: r.info.id, event });
|
|
638
|
+
if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event, protocol: 'ws' }) === false)
|
|
639
|
+
return;
|
|
893
640
|
try {
|
|
894
|
-
|
|
641
|
+
r.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded'));
|
|
895
642
|
}
|
|
896
|
-
catch {
|
|
897
|
-
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
if (event === 'pong') {
|
|
901
|
-
record.info.lastPing = Date.now();
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
if (event === 'join-room') {
|
|
905
|
-
const room = String(data);
|
|
906
|
-
if (room)
|
|
907
|
-
this._joinRoom(record, room);
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
if (event === 'leave-room') {
|
|
911
|
-
const room = String(data);
|
|
912
|
-
if (room)
|
|
913
|
-
this._leaveRoom(record, room);
|
|
643
|
+
catch { }
|
|
644
|
+
r.socket.destroy();
|
|
914
645
|
return;
|
|
915
646
|
}
|
|
916
647
|
if (msg._ackName && this._acks.has(String(msg._ackName))) {
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
try {
|
|
920
|
-
const result = ackHandler({ ...ctx, data });
|
|
921
|
-
if (result !== undefined) {
|
|
922
|
-
record.socket.write(createWSTextFrame(JSON.stringify({ event: ackName, data: result, _isAck: true })));
|
|
923
|
-
this._totalMessagesSent++;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
catch (err) {
|
|
927
|
-
this.log.error('ACK handler error', { ackName, error: String(err) });
|
|
928
|
-
}
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
const eventCtx = { ...ctx, data, event };
|
|
932
|
-
const handler = this.events.get(event);
|
|
933
|
-
if (handler) {
|
|
648
|
+
const name = String(msg._ackName), h = this._acks.get(name);
|
|
649
|
+
let res;
|
|
934
650
|
try {
|
|
935
|
-
|
|
651
|
+
res = h({ ...ctx, data, _correlationId: corrId });
|
|
936
652
|
}
|
|
937
|
-
catch (
|
|
938
|
-
this.log.error('
|
|
939
|
-
|
|
940
|
-
}
|
|
941
|
-
if (this._wildcardHandler) {
|
|
942
|
-
try {
|
|
943
|
-
this._wildcardHandler({ event, data: eventCtx });
|
|
653
|
+
catch (e) {
|
|
654
|
+
this.log.error('ACK handler error', { name, error: String(e) });
|
|
655
|
+
return;
|
|
944
656
|
}
|
|
945
|
-
|
|
946
|
-
|
|
657
|
+
if (res !== undefined) {
|
|
658
|
+
const p = { event: name, data: res, _isAck: true };
|
|
659
|
+
if (corrId)
|
|
660
|
+
p._correlationId = corrId;
|
|
661
|
+
try {
|
|
662
|
+
r.socket.write(createWSTextFrame(JSON.stringify(p)));
|
|
663
|
+
this._totalSent++;
|
|
664
|
+
}
|
|
665
|
+
catch { }
|
|
947
666
|
}
|
|
667
|
+
return;
|
|
948
668
|
}
|
|
949
|
-
|
|
669
|
+
this._dispatch(r, ctx, event, data, corrId);
|
|
950
670
|
}
|
|
951
671
|
if (opcode === OP_BINARY) {
|
|
952
|
-
|
|
953
|
-
this.
|
|
954
|
-
if (payload.length > this.
|
|
955
|
-
|
|
956
|
-
this.hooks.onPayloadTooLarge({ clientId: record.info.id, size: payload.length, max: this.maxPayloadSize });
|
|
957
|
-
}
|
|
958
|
-
this.log.warn('Binary payload too large', { clientId: record.info.id, size: payload.length });
|
|
672
|
+
r.info.messagesReceived++;
|
|
673
|
+
this._totalRecv++;
|
|
674
|
+
if (payload.length > this.maxPayload) {
|
|
675
|
+
this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, size: payload.length, max: this.maxPayload });
|
|
959
676
|
return;
|
|
960
677
|
}
|
|
961
678
|
try {
|
|
962
|
-
let
|
|
963
|
-
for (let i = 0; i < payload.length; i++)
|
|
679
|
+
let end = -1;
|
|
680
|
+
for (let i = 0; i < payload.length; i++)
|
|
964
681
|
if (payload[i] === 0) {
|
|
965
|
-
|
|
682
|
+
end = i;
|
|
966
683
|
break;
|
|
967
684
|
}
|
|
968
|
-
|
|
969
|
-
if (headerEnd === -1)
|
|
685
|
+
if (end === -1)
|
|
970
686
|
return;
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
this.
|
|
976
|
-
|
|
977
|
-
const result = this.hooks.onRateLimitExceeded({ clientId: record.info.id, event: header.event, protocol: 'ws' });
|
|
978
|
-
if (result === false)
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
687
|
+
const hdr = JSON.parse(payload.subarray(0, end).toString('utf8'));
|
|
688
|
+
const buf = payload.subarray(end + 1);
|
|
689
|
+
if (hdr.event && !this._checkRate(r.info.id, hdr.event)) {
|
|
690
|
+
this.log.warn('Binary rate limit', { clientId: r.info.id, event: hdr.event });
|
|
691
|
+
if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: hdr.event, protocol: 'ws' }) === false)
|
|
692
|
+
return;
|
|
981
693
|
return;
|
|
982
694
|
}
|
|
983
|
-
const
|
|
984
|
-
const
|
|
985
|
-
if (
|
|
695
|
+
const ectx = { ...ctx, data: buf, buffer: buf, isBinary: true, event: hdr.event };
|
|
696
|
+
const h = this.events.get(hdr.event);
|
|
697
|
+
if (h)
|
|
986
698
|
try {
|
|
987
|
-
|
|
988
|
-
}
|
|
989
|
-
catch (err) {
|
|
990
|
-
this.log.error('Binary handler error', { error: String(err) });
|
|
699
|
+
h(ectx);
|
|
991
700
|
}
|
|
992
|
-
|
|
993
|
-
if (this.
|
|
701
|
+
catch { }
|
|
702
|
+
if (this._wild)
|
|
994
703
|
try {
|
|
995
|
-
this.
|
|
996
|
-
}
|
|
997
|
-
catch (err) {
|
|
998
|
-
this.log.error('Wildcard handler error', { error: String(err) });
|
|
704
|
+
this._wild({ event: hdr.event, data: ectx });
|
|
999
705
|
}
|
|
1000
|
-
|
|
706
|
+
catch { }
|
|
1001
707
|
}
|
|
1002
708
|
catch {
|
|
1003
|
-
|
|
1004
|
-
this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid binary frame', protocol: 'ws' });
|
|
1005
|
-
}
|
|
1006
|
-
this.log.warn('Invalid binary frame from client', { clientId: record.info.id });
|
|
709
|
+
this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid binary frame', protocol: 'ws' });
|
|
1007
710
|
}
|
|
1008
711
|
}
|
|
1009
712
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
713
|
+
/* ── Private: TCP connection ── */
|
|
714
|
+
_tcpConnect(socket) {
|
|
715
|
+
const r = this._register(socket, 'tcp', null, new FrameParser(this.maxFrame));
|
|
716
|
+
if (!r)
|
|
1013
717
|
return;
|
|
1014
|
-
const ctx = this._buildCtx(
|
|
718
|
+
const ctx = this._buildCtx(r, null);
|
|
1015
719
|
try {
|
|
1016
|
-
socket.write(encodeConnectFrame(
|
|
720
|
+
socket.write(encodeConnectFrame(r.info.id));
|
|
1017
721
|
}
|
|
1018
722
|
catch {
|
|
1019
723
|
socket.destroy();
|
|
1020
724
|
return;
|
|
1021
725
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
this.
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
});
|
|
1040
|
-
this.log.info('TCP client connected', { clientId: record.info.id, ip: record.info.remoteAddress });
|
|
1041
|
-
socket.on('data', (data) => {
|
|
1042
|
-
this._processTCPData(record, data, ctx);
|
|
1043
|
-
});
|
|
1044
|
-
socket.on('close', () => {
|
|
1045
|
-
this.log.debug('TCP client socket closed', { clientId: record.info.id });
|
|
1046
|
-
this._unregisterClient(record, ctx);
|
|
1047
|
-
});
|
|
1048
|
-
socket.on('error', (err) => {
|
|
1049
|
-
this.log.warn('TCP client error', { clientId: record.info.id, error: err.message });
|
|
1050
|
-
this._handleError(record, ctx, err);
|
|
1051
|
-
});
|
|
1052
|
-
socket.on('drain', () => {
|
|
1053
|
-
socket.resume();
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
_processTCPData(record, data, ctx) {
|
|
726
|
+
this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'tcp', metadata: r.info.metadata });
|
|
727
|
+
this._runMw(ctx, () => { if (this._connH)
|
|
728
|
+
try {
|
|
729
|
+
this._connH(ctx);
|
|
730
|
+
}
|
|
731
|
+
catch (e) {
|
|
732
|
+
this.log.error('TCP connection handler error', { error: String(e) });
|
|
733
|
+
} });
|
|
734
|
+
this.log.info('TCP connected', { clientId: r.info.id, ip: r.info.remoteAddress });
|
|
735
|
+
socket.on('data', (d) => this._processTCP(r, d, ctx));
|
|
736
|
+
socket.on('close', () => this._unregister(r, ctx));
|
|
737
|
+
socket.on('error', (e) => { this.log.warn('TCP error', { clientId: r.info.id, error: e.message }); this._handleErr(r, ctx, e); });
|
|
738
|
+
socket.on('drain', () => socket.resume());
|
|
739
|
+
}
|
|
740
|
+
_processTCP(r, data, ctx) {
|
|
1057
741
|
let frames;
|
|
1058
742
|
try {
|
|
1059
|
-
frames =
|
|
743
|
+
frames = r.parser.feed(data);
|
|
1060
744
|
}
|
|
1061
|
-
catch (
|
|
1062
|
-
if (
|
|
1063
|
-
this.log.warn('TCP protocol error', {
|
|
745
|
+
catch (e) {
|
|
746
|
+
if (e instanceof ProtocolError) {
|
|
747
|
+
this.log.warn('TCP protocol error', { code: e.code, message: e.message });
|
|
1064
748
|
try {
|
|
1065
|
-
|
|
749
|
+
r.socket.write(encodeErrorFrame(e.message));
|
|
1066
750
|
}
|
|
1067
|
-
catch {
|
|
751
|
+
catch { }
|
|
1068
752
|
}
|
|
1069
|
-
|
|
753
|
+
r.socket.destroy();
|
|
1070
754
|
return;
|
|
1071
755
|
}
|
|
1072
|
-
for (const
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
this._handleTCPFrame(record, frame, ctx);
|
|
756
|
+
for (const f of frames) {
|
|
757
|
+
if (!r.socket.destroyed)
|
|
758
|
+
this._handleTCPFrame(r, f, ctx);
|
|
1076
759
|
}
|
|
1077
760
|
}
|
|
1078
|
-
_handleTCPFrame(
|
|
761
|
+
_handleTCPFrame(r, frame, ctx) {
|
|
1079
762
|
const { type, event, payload } = frame;
|
|
1080
763
|
if (type === FRAME_PING) {
|
|
1081
764
|
try {
|
|
1082
|
-
|
|
765
|
+
r.socket.write(encodePongFrame());
|
|
1083
766
|
}
|
|
1084
|
-
catch {
|
|
1085
|
-
|
|
767
|
+
catch { }
|
|
768
|
+
r.info.lastPing = Date.now();
|
|
1086
769
|
return;
|
|
1087
770
|
}
|
|
1088
771
|
if (type === FRAME_PONG) {
|
|
1089
|
-
|
|
772
|
+
r.info.lastPing = Date.now();
|
|
1090
773
|
return;
|
|
1091
774
|
}
|
|
1092
|
-
if (
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
protocol: 'tcp',
|
|
1099
|
-
});
|
|
1100
|
-
if (result === false)
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
775
|
+
if (type === FRAME_CONNECT)
|
|
776
|
+
return;
|
|
777
|
+
if (!this._checkRate(r.info.id, event)) {
|
|
778
|
+
this.log.warn('TCP rate limit', { clientId: r.info.id, event });
|
|
779
|
+
if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: event || undefined, protocol: 'tcp' }) === false)
|
|
780
|
+
return;
|
|
1103
781
|
try {
|
|
1104
|
-
|
|
782
|
+
r.socket.write(encodeErrorFrame('Rate limit exceeded'));
|
|
1105
783
|
}
|
|
1106
|
-
catch {
|
|
1107
|
-
|
|
784
|
+
catch { }
|
|
785
|
+
r.socket.destroy();
|
|
1108
786
|
return;
|
|
1109
787
|
}
|
|
1110
788
|
if (type === FRAME_JOIN) {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
this._joinRoom(record, room);
|
|
789
|
+
if (payload.toString('utf8'))
|
|
790
|
+
this._joinRoom(r, payload.toString('utf8'));
|
|
1114
791
|
return;
|
|
1115
792
|
}
|
|
1116
793
|
if (type === FRAME_LEAVE) {
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
this._leaveRoom(record, room);
|
|
794
|
+
if (payload.toString('utf8'))
|
|
795
|
+
this._leaveRoom(r, payload.toString('utf8'));
|
|
1120
796
|
return;
|
|
1121
797
|
}
|
|
1122
|
-
if (
|
|
798
|
+
if (payload.length > this.maxPayload) {
|
|
799
|
+
this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, event, size: payload.length, max: this.maxPayload });
|
|
1123
800
|
return;
|
|
1124
801
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
this.hooks.onPayloadTooLarge({ clientId: record.info.id, event, size: payload.length, max: this.maxPayloadSize });
|
|
1128
|
-
}
|
|
1129
|
-
this.log.warn('TCP payload too large', { clientId: record.info.id, size: payload.length });
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
record.info.messagesReceived++;
|
|
1133
|
-
this._totalMessagesReceived++;
|
|
802
|
+
r.info.messagesReceived++;
|
|
803
|
+
this._totalRecv++;
|
|
1134
804
|
if (type === FRAME_JSON) {
|
|
1135
805
|
let data;
|
|
1136
806
|
try {
|
|
1137
807
|
data = JSON.parse(payload.toString('utf8'));
|
|
1138
808
|
}
|
|
1139
809
|
catch {
|
|
1140
|
-
|
|
1141
|
-
this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid JSON', protocol: 'tcp' });
|
|
1142
|
-
}
|
|
1143
|
-
this.log.warn('Invalid TCP JSON', { clientId: record.info.id });
|
|
810
|
+
this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid JSON', protocol: 'tcp' });
|
|
1144
811
|
return;
|
|
1145
812
|
}
|
|
1146
|
-
|
|
1147
|
-
const handler = this.events.get(event);
|
|
1148
|
-
if (handler) {
|
|
1149
|
-
try {
|
|
1150
|
-
handler(eventCtx);
|
|
1151
|
-
}
|
|
1152
|
-
catch (err) {
|
|
1153
|
-
this.log.error('TCP event handler error', { event, error: String(err) });
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
if (this._wildcardHandler) {
|
|
1157
|
-
try {
|
|
1158
|
-
this._wildcardHandler({ event, data: eventCtx });
|
|
1159
|
-
}
|
|
1160
|
-
catch (err) {
|
|
1161
|
-
this.log.error('TCP wildcard handler error', { error: String(err) });
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
813
|
+
this._dispatch(r, ctx, event, data);
|
|
1164
814
|
return;
|
|
1165
815
|
}
|
|
1166
816
|
if (type === FRAME_ACK_REQ) {
|
|
@@ -1168,80 +818,74 @@ class StelarServer {
|
|
|
1168
818
|
try {
|
|
1169
819
|
const parsed = JSON.parse(payload.toString('utf8'));
|
|
1170
820
|
const data = parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed;
|
|
1171
|
-
const
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
this.
|
|
821
|
+
const corrId = parsed && typeof parsed === 'object' && '_correlationId' in parsed ? String(parsed._correlationId) : undefined;
|
|
822
|
+
const h = this._acks.get(event);
|
|
823
|
+
const res = h({ ...ctx, data, _correlationId: corrId });
|
|
824
|
+
if (res !== undefined) {
|
|
825
|
+
r.socket.write(corrId ? encodeAckResFrame(event, { data: res, _correlationId: corrId }, this.maxFrame) : encodeAckResFrame(event, res, this.maxFrame));
|
|
826
|
+
this._totalSent++;
|
|
1176
827
|
}
|
|
1177
828
|
}
|
|
1178
|
-
catch (
|
|
1179
|
-
this.log.error('TCP ACK handler error', { event, error: String(
|
|
829
|
+
catch (e) {
|
|
830
|
+
this.log.error('TCP ACK handler error', { event, error: String(e) });
|
|
1180
831
|
}
|
|
1181
832
|
}
|
|
1182
833
|
return;
|
|
1183
834
|
}
|
|
1184
835
|
if (type === FRAME_ACK_RES) {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
836
|
+
try {
|
|
837
|
+
const raw = JSON.parse(payload.toString('utf8'));
|
|
838
|
+
const data = raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
|
|
839
|
+
const corrId = raw && typeof raw === 'object' && '_correlationId' in raw ? String(raw._correlationId) : undefined;
|
|
840
|
+
const h = this._acks.get(corrId || event);
|
|
841
|
+
if (h)
|
|
842
|
+
try {
|
|
843
|
+
h({ ...ctx, data });
|
|
844
|
+
}
|
|
845
|
+
catch { }
|
|
1192
846
|
}
|
|
847
|
+
catch { }
|
|
1193
848
|
return;
|
|
1194
849
|
}
|
|
1195
850
|
if (type === FRAME_BINARY) {
|
|
1196
|
-
const
|
|
1197
|
-
const
|
|
1198
|
-
if (
|
|
851
|
+
const ectx = { ...ctx, data: payload, buffer: payload, isBinary: true, event };
|
|
852
|
+
const h = this.events.get(event);
|
|
853
|
+
if (h)
|
|
1199
854
|
try {
|
|
1200
|
-
|
|
1201
|
-
}
|
|
1202
|
-
catch (err) {
|
|
1203
|
-
this.log.error('TCP binary handler error', { event, error: String(err) });
|
|
855
|
+
h(ectx);
|
|
1204
856
|
}
|
|
1205
|
-
|
|
1206
|
-
if (this.
|
|
857
|
+
catch { }
|
|
858
|
+
if (this._wild)
|
|
1207
859
|
try {
|
|
1208
|
-
this.
|
|
1209
|
-
}
|
|
1210
|
-
catch (err) {
|
|
1211
|
-
this.log.error('TCP wildcard handler error', { error: String(err) });
|
|
860
|
+
this._wild({ event, data: ectx });
|
|
1212
861
|
}
|
|
1213
|
-
|
|
1214
|
-
return;
|
|
862
|
+
catch { }
|
|
1215
863
|
}
|
|
1216
864
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
865
|
+
_handleErr(r, ctx, err) {
|
|
866
|
+
const h = this.events.get('error');
|
|
867
|
+
if (h)
|
|
1220
868
|
try {
|
|
1221
|
-
|
|
1222
|
-
}
|
|
1223
|
-
catch (handlerErr) {
|
|
1224
|
-
this.log.error('Error handler threw', { error: String(handlerErr) });
|
|
869
|
+
h({ ...ctx, error: err, event: 'error' });
|
|
1225
870
|
}
|
|
1226
|
-
|
|
871
|
+
catch { }
|
|
1227
872
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
873
|
+
/* ── Private: health check ── */
|
|
874
|
+
_health(req, res) {
|
|
875
|
+
if (this._healthFn) {
|
|
1231
876
|
try {
|
|
1232
|
-
this.
|
|
877
|
+
this._healthFn(req, res, this.getStats());
|
|
1233
878
|
}
|
|
1234
|
-
catch
|
|
1235
|
-
this.log.error('Custom health handler error', { error: String(err) });
|
|
879
|
+
catch {
|
|
1236
880
|
if (!res.headersSent) {
|
|
1237
|
-
res.writeHead(500
|
|
1238
|
-
res.end(
|
|
881
|
+
res.writeHead(500);
|
|
882
|
+
res.end('{"status":"error"}');
|
|
1239
883
|
}
|
|
1240
884
|
}
|
|
1241
885
|
return;
|
|
1242
886
|
}
|
|
1243
887
|
const origin = req.headers['origin'];
|
|
1244
|
-
if (origin && (!this.
|
|
888
|
+
if (origin && (!this.origins || this.origins.includes(origin))) {
|
|
1245
889
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1246
890
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
1247
891
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
@@ -1251,267 +895,198 @@ class StelarServer {
|
|
|
1251
895
|
res.end();
|
|
1252
896
|
return;
|
|
1253
897
|
}
|
|
1254
|
-
if (this.
|
|
1255
|
-
const
|
|
898
|
+
if (this.healthPath && req.url === this.healthPath && req.method === 'GET') {
|
|
899
|
+
const s = this.getStats();
|
|
1256
900
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1257
|
-
res.end(JSON.stringify({
|
|
1258
|
-
status: 'ok',
|
|
1259
|
-
...stats,
|
|
1260
|
-
uptimeSeconds: Math.floor(stats.uptime / 1000),
|
|
1261
|
-
memoryMB: Math.round(stats.memoryUsage.heapUsed / 1024 / 1024 * 100) / 100,
|
|
1262
|
-
}));
|
|
901
|
+
res.end(JSON.stringify({ status: 'ok', ...s, uptimeSeconds: Math.floor(s.uptime / 1000), memoryMB: Math.round(s.memoryUsage.heapUsed / 1024 / 1024 * 100) / 100 }));
|
|
1263
902
|
return;
|
|
1264
903
|
}
|
|
1265
904
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
1266
905
|
res.end('Stelar Time Real v3 Server');
|
|
1267
906
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
this.
|
|
1271
|
-
return this;
|
|
1272
|
-
}
|
|
1273
|
-
_emitShutdown(signal, force) {
|
|
1274
|
-
for (const cb of this._shutdownCallbacks) {
|
|
1275
|
-
try {
|
|
1276
|
-
cb(signal, force);
|
|
1277
|
-
}
|
|
1278
|
-
catch { /* ignore */ }
|
|
1279
|
-
}
|
|
1280
|
-
if (this._shutdownCallbacks.length === 0) {
|
|
907
|
+
/* ── Private: graceful shutdown ── */
|
|
908
|
+
_emitShutdown(sig, force) {
|
|
909
|
+
if (!this._shutdownCbs.length) {
|
|
1281
910
|
process.exit(force ? 1 : 0);
|
|
911
|
+
return;
|
|
1282
912
|
}
|
|
913
|
+
for (const cb of this._shutdownCbs)
|
|
914
|
+
try {
|
|
915
|
+
cb(sig, force);
|
|
916
|
+
}
|
|
917
|
+
catch { }
|
|
1283
918
|
}
|
|
1284
|
-
|
|
1285
|
-
if (!this.
|
|
919
|
+
_setupShutdown() {
|
|
920
|
+
if (!this.doGraceful)
|
|
1286
921
|
return;
|
|
1287
|
-
let
|
|
1288
|
-
const shutdown = (
|
|
1289
|
-
if (
|
|
922
|
+
let done = false;
|
|
923
|
+
const shutdown = (sig) => {
|
|
924
|
+
if (done)
|
|
1290
925
|
return;
|
|
1291
|
-
|
|
1292
|
-
this.
|
|
1293
|
-
this.log.info(`Received ${
|
|
926
|
+
done = true;
|
|
927
|
+
this._shutting = true;
|
|
928
|
+
this.log.info(`Received ${sig}, shutting down...`);
|
|
1294
929
|
this.stop();
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
this.
|
|
1298
|
-
this._emitShutdown(signal, false);
|
|
930
|
+
if (!this.clients.size) {
|
|
931
|
+
this.log.info('Shutdown complete');
|
|
932
|
+
this._emitShutdown(sig, false);
|
|
1299
933
|
return;
|
|
1300
934
|
}
|
|
1301
|
-
this.log.info(`Waiting for ${
|
|
1302
|
-
this.clients.forEach(
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
this.
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
record.socket.destroy();
|
|
1319
|
-
}
|
|
1320
|
-
catch { /* ignore */ }
|
|
1321
|
-
});
|
|
1322
|
-
this.clients.clear();
|
|
1323
|
-
this.clientsById.clear();
|
|
1324
|
-
this._emitShutdown(signal, true);
|
|
1325
|
-
}, this.shutdownTimeout);
|
|
1326
|
-
forceTimeout.unref();
|
|
1327
|
-
const checkInterval = setInterval(() => {
|
|
1328
|
-
if (this.clients.size === 0) {
|
|
1329
|
-
clearInterval(checkInterval);
|
|
1330
|
-
clearTimeout(forceTimeout);
|
|
1331
|
-
this.log.info('All connections closed, shutdown complete');
|
|
1332
|
-
this._emitShutdown(signal, false);
|
|
1333
|
-
}
|
|
1334
|
-
}, 100);
|
|
1335
|
-
checkInterval.unref();
|
|
935
|
+
this.log.info(`Waiting for ${this.clients.size} connections (timeout: ${this.shutdownMs}ms)`);
|
|
936
|
+
this.clients.forEach(r => { try {
|
|
937
|
+
r.socket.write(r.protocol === 'ws' ? createWSCloseFrame(CLOSE_GOING_AWAY, 'Shutting down') : encodeDisconnectFrame());
|
|
938
|
+
r.socket.end();
|
|
939
|
+
}
|
|
940
|
+
catch { } });
|
|
941
|
+
const forceT = setTimeout(() => { this.clients.forEach(r => { try {
|
|
942
|
+
r.socket.destroy();
|
|
943
|
+
}
|
|
944
|
+
catch { } }); this.clients.clear(); this.byId.clear(); this._emitShutdown(sig, true); }, this.shutdownMs);
|
|
945
|
+
forceT.unref();
|
|
946
|
+
const check = setInterval(() => { if (!this.clients.size) {
|
|
947
|
+
clearInterval(check);
|
|
948
|
+
clearTimeout(forceT);
|
|
949
|
+
this._emitShutdown(sig, false);
|
|
950
|
+
} }, 100);
|
|
951
|
+
check.unref();
|
|
1336
952
|
};
|
|
1337
|
-
this.
|
|
1338
|
-
this.
|
|
1339
|
-
process.on('SIGINT', this.
|
|
1340
|
-
process.on('SIGTERM', this.
|
|
953
|
+
this._sigH.int = () => shutdown('SIGINT');
|
|
954
|
+
this._sigH.term = () => shutdown('SIGTERM');
|
|
955
|
+
process.on('SIGINT', this._sigH.int);
|
|
956
|
+
process.on('SIGTERM', this._sigH.term);
|
|
1341
957
|
}
|
|
1342
|
-
|
|
1343
|
-
if (this.
|
|
1344
|
-
process.off('SIGINT', this.
|
|
1345
|
-
this.
|
|
958
|
+
_removeSignals() {
|
|
959
|
+
if (this._sigH.int) {
|
|
960
|
+
process.off('SIGINT', this._sigH.int);
|
|
961
|
+
this._sigH.int = null;
|
|
1346
962
|
}
|
|
1347
|
-
if (this.
|
|
1348
|
-
process.off('SIGTERM', this.
|
|
1349
|
-
this.
|
|
963
|
+
if (this._sigH.term) {
|
|
964
|
+
process.off('SIGTERM', this._sigH.term);
|
|
965
|
+
this._sigH.term = null;
|
|
1350
966
|
}
|
|
1351
967
|
}
|
|
1352
|
-
|
|
968
|
+
/* ── Start / Stop ── */
|
|
969
|
+
start(cb) {
|
|
1353
970
|
if (this._started) {
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
return Promise.resolve(port);
|
|
971
|
+
const p = this.getPort();
|
|
972
|
+
cb?.(p);
|
|
973
|
+
return Promise.resolve(p);
|
|
1358
974
|
}
|
|
1359
975
|
this._started = true;
|
|
1360
976
|
this._startTime = Date.now();
|
|
1361
|
-
return new Promise(
|
|
1362
|
-
const
|
|
1363
|
-
this.httpServer =
|
|
1364
|
-
this.
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
this.
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
}
|
|
1380
|
-
const ipTracker = this._customIPTracker || this.ipTracker;
|
|
1381
|
-
ipTracker.cleanup();
|
|
1382
|
-
for (const [clientId, limiter] of this._clientRateOverrides) {
|
|
1383
|
-
limiter.cleanup();
|
|
1384
|
-
if (!this.clientsById.has(clientId)) {
|
|
1385
|
-
this._clientRateOverrides.delete(clientId);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
for (const [, limiter] of this.eventRateLimiters) {
|
|
1389
|
-
limiter.cleanup();
|
|
977
|
+
return new Promise(resolve => {
|
|
978
|
+
const onHttp = (srv) => {
|
|
979
|
+
this.httpServer = srv;
|
|
980
|
+
this._reqH = (req, res) => this._health(req, res);
|
|
981
|
+
this._upgH = (req, socket, head) => this._wsUpgrade(req, socket, head);
|
|
982
|
+
srv.on('request', this._reqH);
|
|
983
|
+
srv.on('upgrade', this._upgH);
|
|
984
|
+
this._startHeartbeat();
|
|
985
|
+
this._rc = setInterval(() => {
|
|
986
|
+
if (this._crl)
|
|
987
|
+
this._crl.cleanup();
|
|
988
|
+
else
|
|
989
|
+
this.rateLimiter?.cleanup();
|
|
990
|
+
(this._cit || this.ipTracker).cleanup();
|
|
991
|
+
for (const [id, l] of this.clientRates) {
|
|
992
|
+
l.cleanup();
|
|
993
|
+
if (!this.byId.has(id))
|
|
994
|
+
this.clientRates.delete(id);
|
|
1390
995
|
}
|
|
996
|
+
for (const [, l] of this.evRateLimits)
|
|
997
|
+
l.cleanup();
|
|
1391
998
|
}, 30000);
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
this.
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
if (callback)
|
|
1399
|
-
callback(finalPort);
|
|
1400
|
-
resolve(finalPort);
|
|
999
|
+
this._rc?.unref?.();
|
|
1000
|
+
this._setupShutdown();
|
|
1001
|
+
const p = this.getPort();
|
|
1002
|
+
this.log.info('Server started', { port: p, namespace: this.ns, tls: !!this.tlsOpts });
|
|
1003
|
+
cb?.(p);
|
|
1004
|
+
resolve(p);
|
|
1401
1005
|
};
|
|
1402
1006
|
if (this.httpServer) {
|
|
1403
|
-
this.
|
|
1404
|
-
|
|
1007
|
+
this._ext.add(this.httpServer);
|
|
1008
|
+
onHttp(this.httpServer);
|
|
1405
1009
|
}
|
|
1406
1010
|
else {
|
|
1407
1011
|
const tryListen = (port) => {
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
}
|
|
1415
|
-
else {
|
|
1416
|
-
this.log.error('HTTP server error', { error: err.message });
|
|
1417
|
-
}
|
|
1418
|
-
});
|
|
1419
|
-
httpServer.listen(port, () => {
|
|
1420
|
-
this.port = port;
|
|
1421
|
-
startHttpServer(httpServer);
|
|
1422
|
-
});
|
|
1012
|
+
const srv = createHttp();
|
|
1013
|
+
srv.on('error', (e) => { if (e.code === 'EADDRINUSE' && port < 65535)
|
|
1014
|
+
tryListen(port + 1);
|
|
1015
|
+
else
|
|
1016
|
+
this.log.error('HTTP error', { error: e.message }); });
|
|
1017
|
+
srv.listen(port, () => { this.port = port; onHttp(srv); });
|
|
1423
1018
|
};
|
|
1424
1019
|
tryListen(this.port);
|
|
1425
1020
|
}
|
|
1426
1021
|
if (this.tcpPort !== false) {
|
|
1427
|
-
const
|
|
1428
|
-
this.
|
|
1022
|
+
const p = typeof this.tcpPort === 'number' ? this.tcpPort : this.port + 1;
|
|
1023
|
+
this._startTCP(p);
|
|
1429
1024
|
}
|
|
1430
1025
|
});
|
|
1431
1026
|
}
|
|
1432
|
-
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1027
|
+
_startTCP(port, attempts = 0) {
|
|
1028
|
+
const handler = (s) => this._tcpConnect(s);
|
|
1029
|
+
const startPlain = (p, a) => {
|
|
1030
|
+
const srv = createTcp(handler);
|
|
1031
|
+
srv.on('error', (e) => { if (e.code === 'EADDRINUSE' && a < 10) {
|
|
1032
|
+
this.tcpServer = null;
|
|
1033
|
+
this._startTCP(p + 1, a + 1);
|
|
1034
|
+
}
|
|
1035
|
+
else
|
|
1036
|
+
this.log.error('TCP error', { error: e.message }); });
|
|
1037
|
+
srv.listen(p, () => { this.tcpServer = srv; this.log.info('TCP started', { port: p }); });
|
|
1038
|
+
};
|
|
1039
|
+
if (this.tlsOpts) {
|
|
1435
1040
|
try {
|
|
1436
|
-
const
|
|
1437
|
-
this.tcpServer =
|
|
1438
|
-
this.tcpServer.on('error', (
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
this.log.error('TLS TCP server error', { error: err.message });
|
|
1446
|
-
}
|
|
1447
|
-
});
|
|
1448
|
-
this.tcpServer.listen(port, () => {
|
|
1449
|
-
this.log.info('TLS TCP server started', { port });
|
|
1450
|
-
});
|
|
1041
|
+
const srv = createTls(this.tlsOpts, handler);
|
|
1042
|
+
this.tcpServer = srv;
|
|
1043
|
+
this.tcpServer.on('error', (e) => { if (e.code === 'EADDRINUSE' && attempts < 10) {
|
|
1044
|
+
this.tcpServer = null;
|
|
1045
|
+
this._startTCP(port + 1, attempts + 1);
|
|
1046
|
+
}
|
|
1047
|
+
else
|
|
1048
|
+
this.log.error('TLS TCP error', { error: e.message }); });
|
|
1049
|
+
this.tcpServer.listen(port, () => this.log.info('TLS TCP started', { port }));
|
|
1451
1050
|
}
|
|
1452
|
-
catch
|
|
1453
|
-
|
|
1454
|
-
this._startPlainTCPServer(port, attempts, tcpHandler);
|
|
1051
|
+
catch {
|
|
1052
|
+
startPlain(port, attempts);
|
|
1455
1053
|
}
|
|
1456
1054
|
}
|
|
1457
|
-
else
|
|
1458
|
-
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
_startPlainTCPServer(port, attempts, tcpHandler) {
|
|
1462
|
-
this.tcpServer = createTcpServer(tcpHandler);
|
|
1463
|
-
this.tcpServer.on('error', (err) => {
|
|
1464
|
-
if (err.code === 'EADDRINUSE' && attempts < 10) {
|
|
1465
|
-
this.log.info(`TCP port ${port} in use, trying ${port + 1}`);
|
|
1466
|
-
this.tcpServer = null;
|
|
1467
|
-
this._startTCPServer(port + 1, attempts + 1);
|
|
1468
|
-
}
|
|
1469
|
-
else {
|
|
1470
|
-
this.log.error('TCP server error', { error: err.message });
|
|
1471
|
-
}
|
|
1472
|
-
});
|
|
1473
|
-
this.tcpServer.listen(port, () => {
|
|
1474
|
-
this.log.info('TCP server started', { port });
|
|
1475
|
-
});
|
|
1055
|
+
else
|
|
1056
|
+
startPlain(port, attempts);
|
|
1476
1057
|
}
|
|
1477
1058
|
stop() {
|
|
1478
|
-
if (this.
|
|
1479
|
-
clearInterval(this.
|
|
1480
|
-
this.
|
|
1059
|
+
if (this._hb) {
|
|
1060
|
+
clearInterval(this._hb);
|
|
1061
|
+
this._hb = null;
|
|
1481
1062
|
}
|
|
1482
|
-
if (this.
|
|
1483
|
-
clearInterval(this.
|
|
1484
|
-
this.
|
|
1063
|
+
if (this._rc) {
|
|
1064
|
+
clearInterval(this._rc);
|
|
1065
|
+
this._rc = null;
|
|
1485
1066
|
}
|
|
1486
|
-
this.clients.forEach(
|
|
1487
|
-
|
|
1488
|
-
record.socket.destroy();
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1067
|
+
this.clients.forEach(r => { if (!r.socket.destroyed)
|
|
1068
|
+
r.socket.destroy(); });
|
|
1491
1069
|
this.clients.clear();
|
|
1492
|
-
this.
|
|
1070
|
+
this.byId.clear();
|
|
1493
1071
|
this.rooms.clear();
|
|
1494
|
-
this.
|
|
1072
|
+
this.clientRates.clear();
|
|
1495
1073
|
if (this.httpServer) {
|
|
1496
|
-
if (this.
|
|
1497
|
-
this.httpServer.off('upgrade', this.
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
if (this.
|
|
1501
|
-
this.httpServer.off('request', this._requestHandler);
|
|
1502
|
-
this._requestHandler = null;
|
|
1503
|
-
}
|
|
1504
|
-
if (!this._externalServers.has(this.httpServer)) {
|
|
1074
|
+
if (this._upgH)
|
|
1075
|
+
this.httpServer.off('upgrade', this._upgH);
|
|
1076
|
+
if (this._reqH)
|
|
1077
|
+
this.httpServer.off('request', this._reqH);
|
|
1078
|
+
if (!this._ext.has(this.httpServer))
|
|
1505
1079
|
this.httpServer.close();
|
|
1506
|
-
}
|
|
1507
1080
|
this.httpServer = null;
|
|
1081
|
+
this._upgH = null;
|
|
1082
|
+
this._reqH = null;
|
|
1508
1083
|
}
|
|
1509
1084
|
if (this.tcpServer) {
|
|
1510
1085
|
this.tcpServer.close();
|
|
1511
1086
|
this.tcpServer = null;
|
|
1512
1087
|
}
|
|
1513
1088
|
this._started = false;
|
|
1514
|
-
this.
|
|
1089
|
+
this._removeSignals();
|
|
1515
1090
|
this.log.info('Server stopped');
|
|
1516
1091
|
return this;
|
|
1517
1092
|
}
|