stelar-time-real 2.0.4 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1012 -335
- package/package.json +14 -10
- package/src/client.d.ts +115 -8
- package/src/client.d.ts.map +1 -1
- package/src/client.js +851 -102
- package/src/client.ts +915 -103
- package/src/index.d.ts +281 -15
- package/src/index.d.ts.map +1 -1
- package/src/index.js +1382 -142
- package/src/index.ts +1663 -201
- package/src/logger.d.ts +29 -0
- package/src/logger.d.ts.map +1 -0
- package/src/logger.js +98 -0
- package/src/logger.ts +115 -0
- package/src/protocol.d.ts +57 -0
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +193 -0
- package/src/protocol.ts +237 -0
- package/src/websocket.d.ts +67 -0
- package/src/websocket.d.ts.map +1 -0
- package/src/websocket.js +260 -0
- package/src/websocket.ts +316 -0
package/src/index.ts
CHANGED
|
@@ -1,29 +1,264 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer as createHttpServer, IncomingMessage, Server as HttpServer, ServerResponse } from 'http';
|
|
9
|
+
import { createServer as createTcpServer, Server as TcpServer, Socket as NetSocket } from 'net';
|
|
3
10
|
import { randomUUID } from 'crypto';
|
|
11
|
+
import type { TlsOptions } from 'tls';
|
|
12
|
+
import { createServer as createTlsServer } from 'tls';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
FrameParser,
|
|
16
|
+
ParsedFrame,
|
|
17
|
+
encodeJsonFrame,
|
|
18
|
+
encodeBinaryFrame,
|
|
19
|
+
encodePingFrame,
|
|
20
|
+
encodePongFrame,
|
|
21
|
+
encodeAckResFrame,
|
|
22
|
+
encodeConnectFrame,
|
|
23
|
+
encodeDisconnectFrame,
|
|
24
|
+
encodeJoinFrame,
|
|
25
|
+
encodeLeaveFrame,
|
|
26
|
+
encodeErrorFrame,
|
|
27
|
+
FRAME_JSON,
|
|
28
|
+
FRAME_BINARY,
|
|
29
|
+
FRAME_PING,
|
|
30
|
+
FRAME_PONG,
|
|
31
|
+
FRAME_ACK_REQ,
|
|
32
|
+
FRAME_ACK_RES,
|
|
33
|
+
FRAME_JOIN,
|
|
34
|
+
FRAME_LEAVE,
|
|
35
|
+
FRAME_CONNECT,
|
|
36
|
+
ProtocolError,
|
|
37
|
+
DEFAULT_MAX_FRAME_SIZE,
|
|
38
|
+
} from './protocol.js';
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
WSFrameParser,
|
|
42
|
+
WSFrame,
|
|
43
|
+
buildUpgradeResponse,
|
|
44
|
+
validateWSKey,
|
|
45
|
+
createWSTextFrame,
|
|
46
|
+
createWSBinaryFrame,
|
|
47
|
+
createWSCloseFrame,
|
|
48
|
+
createWSPingFrame,
|
|
49
|
+
createWSPongFrame,
|
|
50
|
+
OP_TEXT,
|
|
51
|
+
OP_BINARY,
|
|
52
|
+
OP_CLOSE,
|
|
53
|
+
OP_PING,
|
|
54
|
+
OP_PONG,
|
|
55
|
+
WebSocketError,
|
|
56
|
+
CLOSE_PROTOCOL_ERROR,
|
|
57
|
+
CLOSE_POLICY_VIOLATION,
|
|
58
|
+
CLOSE_MESSAGE_TOO_BIG,
|
|
59
|
+
CLOSE_NORMAL,
|
|
60
|
+
CLOSE_GOING_AWAY,
|
|
61
|
+
DEFAULT_MAX_WS_FRAME_SIZE,
|
|
62
|
+
} from './websocket.js';
|
|
63
|
+
|
|
64
|
+
import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
|
|
65
|
+
|
|
66
|
+
export interface IRateLimiter {
|
|
67
|
+
/** Returns true if the action is allowed */
|
|
68
|
+
check(id: string, cost?: number): boolean;
|
|
69
|
+
/** Reset rate limit for a specific client */
|
|
70
|
+
reset(id: string): void;
|
|
71
|
+
/** Clean up expired entries */
|
|
72
|
+
cleanup(): void;
|
|
73
|
+
/** Number of tracked entries */
|
|
74
|
+
size(): number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IIPTracker {
|
|
78
|
+
/** Returns true if connection from this IP is allowed */
|
|
79
|
+
check(ip: string): boolean;
|
|
80
|
+
/** Register a new connection from this IP */
|
|
81
|
+
add(ip: string): void;
|
|
82
|
+
/** Unregister a connection from this IP */
|
|
83
|
+
remove(ip: string): void;
|
|
84
|
+
/** Get current connection count for this IP */
|
|
85
|
+
getCount(ip: string): number;
|
|
86
|
+
/** Clean up stale entries */
|
|
87
|
+
cleanup(): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StelarHooks {
|
|
91
|
+
/** Called when a client exceeds rate limit. Return false to skip disconnect. */
|
|
92
|
+
onRateLimitExceeded?: (info: { clientId: string; event?: string; protocol: 'ws' | 'tcp' }) => boolean | void;
|
|
93
|
+
/** Called when max connections is reached. */
|
|
94
|
+
onMaxConnectionsReached?: (info: { activeConnections: number; max: number; ip: string }) => void;
|
|
95
|
+
/** Called when global max rooms is reached. Return false to reject room creation. */
|
|
96
|
+
onMaxRoomsReached?: (info: { clientId: string; room: string; totalRooms: number; max: number }) => boolean | void;
|
|
97
|
+
/** Called when per-client max rooms is reached. Return false to reject join. */
|
|
98
|
+
onMaxRoomsPerClientReached?: (info: { clientId: string; room: string; currentRooms: number; max: number }) => boolean | void;
|
|
99
|
+
/** Called when a payload exceeds maxPayloadSize. */
|
|
100
|
+
onPayloadTooLarge?: (info: { clientId: string; event?: string; size: number; max: number }) => void;
|
|
101
|
+
/** Called when a client sends an invalid message. */
|
|
102
|
+
onInvalidMessage?: (info: { clientId: string; reason: string; protocol: 'ws' | 'tcp' }) => void;
|
|
103
|
+
/** Called before a client joins a room. Return false to reject. */
|
|
104
|
+
onClientJoinRoom?: (info: { clientId: string; room: string; metadata: Map<string, unknown> }) => boolean | void;
|
|
105
|
+
/** Called before a client leaves a room. Return false to reject. */
|
|
106
|
+
onClientLeaveRoom?: (info: { clientId: string; room: string }) => boolean | void;
|
|
107
|
+
/** Called before a broadcast. Return false to cancel. */
|
|
108
|
+
onBeforeBroadcast?: (info: { event: string; data: unknown; excludeId?: string }) => boolean | void;
|
|
109
|
+
/** Called when a new client connects. */
|
|
110
|
+
onClientConnect?: (info: { clientId: string; ip: string; protocol: 'ws' | 'tcp'; metadata: Map<string, unknown> }) => void;
|
|
111
|
+
/** Called when a client disconnects. */
|
|
112
|
+
onClientDisconnect?: (info: { clientId: string; ip: string; protocol: 'ws' | 'tcp'; rooms: Set<string> }) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type EventRateLimits = Record<string, { maxPoints: number; windowMs: number }>;
|
|
116
|
+
|
|
117
|
+
interface RateLimitEntry {
|
|
118
|
+
count: number;
|
|
119
|
+
resetTime: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class RateLimiter implements IRateLimiter {
|
|
123
|
+
private limits = new Map<string, RateLimitEntry>();
|
|
124
|
+
private maxPoints: number;
|
|
125
|
+
private windowMs: number;
|
|
126
|
+
|
|
127
|
+
constructor(maxPoints = 100, windowMs = 1000) {
|
|
128
|
+
this.maxPoints = maxPoints;
|
|
129
|
+
this.windowMs = windowMs;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
check(id: string, cost = 1): boolean {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
let entry = this.limits.get(id);
|
|
135
|
+
|
|
136
|
+
if (!entry || now >= entry.resetTime) {
|
|
137
|
+
entry = { count: 0, resetTime: now + this.windowMs };
|
|
138
|
+
this.limits.set(id, entry);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (entry.count + cost > this.maxPoints) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
entry.count += cost;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
cleanup(): void {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const [id, entry] of this.limits) {
|
|
152
|
+
if (now >= entry.resetTime) {
|
|
153
|
+
this.limits.delete(id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
reset(id: string): void {
|
|
159
|
+
this.limits.delete(id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
size(): number {
|
|
163
|
+
return this.limits.size;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class IPConnectionTracker implements IIPTracker {
|
|
168
|
+
private ipCounts = new Map<string, number>();
|
|
169
|
+
private maxPerIP: number;
|
|
170
|
+
|
|
171
|
+
constructor(maxPerIP = 50) {
|
|
172
|
+
this.maxPerIP = maxPerIP;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
check(ip: string): boolean {
|
|
176
|
+
const current = this.ipCounts.get(ip) || 0;
|
|
177
|
+
return current < this.maxPerIP;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
add(ip: string): void {
|
|
181
|
+
this.ipCounts.set(ip, (this.ipCounts.get(ip) || 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
remove(ip: string): void {
|
|
185
|
+
const current = this.ipCounts.get(ip) || 0;
|
|
186
|
+
if (current <= 1) {
|
|
187
|
+
this.ipCounts.delete(ip);
|
|
188
|
+
} else {
|
|
189
|
+
this.ipCounts.set(ip, current - 1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getCount(ip: string): number {
|
|
194
|
+
return this.ipCounts.get(ip) || 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
cleanup(): void {
|
|
198
|
+
for (const [ip, count] of this.ipCounts) {
|
|
199
|
+
if (count <= 0) this.ipCounts.delete(ip);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
4
203
|
|
|
5
204
|
export interface StelarOptions {
|
|
6
205
|
port?: number;
|
|
7
|
-
server?:
|
|
206
|
+
server?: HttpServer;
|
|
8
207
|
namespace?: string;
|
|
9
208
|
heartbeatInterval?: number;
|
|
209
|
+
heartbeatTimeout?: number;
|
|
210
|
+
tcpPort?: number | false;
|
|
211
|
+
maxConnections?: number;
|
|
212
|
+
maxConnectionsPerIP?: number;
|
|
213
|
+
maxRooms?: number;
|
|
214
|
+
maxRoomsPerClient?: number;
|
|
215
|
+
maxEventNameLength?: number;
|
|
216
|
+
maxPayloadSize?: number;
|
|
217
|
+
maxFrameSize?: number;
|
|
218
|
+
rateLimit?: { maxPoints?: number; windowMs?: number } | false;
|
|
219
|
+
connectTimeout?: number;
|
|
220
|
+
gracefulShutdown?: boolean;
|
|
221
|
+
shutdownTimeout?: number;
|
|
222
|
+
healthEndpoint?: string | false;
|
|
223
|
+
logger?: Logger | LogLevel | false;
|
|
224
|
+
tls?: TlsOptions;
|
|
225
|
+
allowedOrigins?: string[];
|
|
226
|
+
/** Custom rate limiter implementation. Replaces the built-in token bucket. */
|
|
227
|
+
customRateLimiter?: IRateLimiter;
|
|
228
|
+
/** Custom IP connection tracker. Replaces the built-in per-IP counter. */
|
|
229
|
+
customIPTracker?: IIPTracker;
|
|
230
|
+
/** Custom function to generate client IDs. Defaults to UUID v4. */
|
|
231
|
+
generateClientId?: () => string;
|
|
232
|
+
/** Per-event rate limits. Each event can have different maxPoints and windowMs. */
|
|
233
|
+
eventRateLimits?: EventRateLimits;
|
|
234
|
+
/** Hook callbacks for server events. */
|
|
235
|
+
hooks?: StelarHooks;
|
|
236
|
+
/** Custom health check handler. Receives (req, res, stats). */
|
|
237
|
+
customHealthHandler?: (req: IncomingMessage, res: ServerResponse, stats: StelarStats) => void;
|
|
10
238
|
}
|
|
11
239
|
|
|
12
240
|
export interface StelarClientInfo {
|
|
13
241
|
id: string;
|
|
14
|
-
|
|
242
|
+
rooms: Set<string>;
|
|
15
243
|
lastPing: number;
|
|
244
|
+
protocol: 'ws' | 'tcp';
|
|
245
|
+
connectedAt: number;
|
|
246
|
+
metadata: Map<string, unknown>;
|
|
247
|
+
messagesReceived: number;
|
|
248
|
+
messagesSent: number;
|
|
249
|
+
remoteAddress: string;
|
|
16
250
|
}
|
|
17
251
|
|
|
18
252
|
export interface StelarContext {
|
|
19
253
|
id: string;
|
|
20
|
-
socket:
|
|
21
|
-
req: IncomingMessage;
|
|
254
|
+
socket: NetSocket;
|
|
255
|
+
req: IncomingMessage | null;
|
|
22
256
|
data?: unknown;
|
|
23
257
|
buffer?: Uint8Array;
|
|
24
258
|
isBinary?: boolean;
|
|
25
259
|
event?: string;
|
|
26
260
|
error?: Error;
|
|
261
|
+
clientInfo: StelarClientInfo;
|
|
27
262
|
emit: (event: string, data: unknown) => void;
|
|
28
263
|
send: (respId: string, data: unknown) => void;
|
|
29
264
|
emitBinary: (event: string, buffer: ArrayBuffer) => void;
|
|
@@ -31,9 +266,11 @@ export interface StelarContext {
|
|
|
31
266
|
broadcastBinary: (event: string, buffer: ArrayBuffer) => void;
|
|
32
267
|
to: (room: string, event: string, data: unknown) => void;
|
|
33
268
|
toId: (id: string, event: string, data: unknown) => void;
|
|
34
|
-
getClients: (room?: string) => { id: string;
|
|
269
|
+
getClients: (room?: string) => { id: string; rooms: string[] }[];
|
|
35
270
|
joinRoom: (room: string) => void;
|
|
36
|
-
leaveRoom: () => void;
|
|
271
|
+
leaveRoom: (room: string) => void;
|
|
272
|
+
setMetadata: (key: string, value: unknown) => void;
|
|
273
|
+
getMetadata: (key: string) => unknown;
|
|
37
274
|
ack: (ackName: string, data: unknown) => void;
|
|
38
275
|
}
|
|
39
276
|
|
|
@@ -42,35 +279,262 @@ export interface StelarMiddleware {
|
|
|
42
279
|
}
|
|
43
280
|
|
|
44
281
|
export type StelarEventHandler = (ctx: StelarContext) => void;
|
|
45
|
-
|
|
46
282
|
export type StelarWildcardHandler = (data: { event: string; data: StelarContext }) => void;
|
|
47
283
|
|
|
284
|
+
export interface StelarStats {
|
|
285
|
+
totalConnections: number;
|
|
286
|
+
activeConnections: number;
|
|
287
|
+
totalMessagesReceived: number;
|
|
288
|
+
totalMessagesSent: number;
|
|
289
|
+
totalRooms: number;
|
|
290
|
+
uptime: number;
|
|
291
|
+
wsConnections: number;
|
|
292
|
+
tcpConnections: number;
|
|
293
|
+
memoryUsage: NodeJS.MemoryUsage;
|
|
294
|
+
rateLimiterEntries: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface ClientRecord {
|
|
298
|
+
info: StelarClientInfo;
|
|
299
|
+
socket: NetSocket;
|
|
300
|
+
parser: WSFrameParser | FrameParser;
|
|
301
|
+
protocol: 'ws' | 'tcp';
|
|
302
|
+
}
|
|
303
|
+
|
|
48
304
|
class StelarServer {
|
|
49
305
|
private port: number;
|
|
50
|
-
private
|
|
306
|
+
private httpServer: HttpServer | null = null;
|
|
307
|
+
private tcpServer: TcpServer | null = null;
|
|
51
308
|
private namespace: string;
|
|
52
|
-
private
|
|
53
|
-
private
|
|
309
|
+
private heartbeatInterval: number;
|
|
310
|
+
private heartbeatTimeout: number;
|
|
311
|
+
private tcpPort: number | false;
|
|
312
|
+
private maxConnections: number;
|
|
313
|
+
private maxRooms: number;
|
|
314
|
+
private maxRoomsPerClient: number;
|
|
315
|
+
private maxPayloadSize: number;
|
|
316
|
+
private maxFrameSize: number;
|
|
317
|
+
private maxWSFrameSize: number;
|
|
318
|
+
private connectTimeout: number;
|
|
319
|
+
private doGracefulShutdown: boolean;
|
|
320
|
+
private shutdownTimeout: number;
|
|
321
|
+
private healthEndpoint: string | false;
|
|
322
|
+
private tlsOptions: TlsOptions | undefined;
|
|
323
|
+
private allowedOrigins: string[] | null;
|
|
324
|
+
|
|
325
|
+
private _customRateLimiter: IRateLimiter | null;
|
|
326
|
+
private _customIPTracker: IIPTracker | null;
|
|
327
|
+
private _generateClientId: (() => string) | null;
|
|
328
|
+
private _customHealthHandler: ((req: IncomingMessage, res: ServerResponse, stats: StelarStats) => void) | null;
|
|
329
|
+
private hooks: StelarHooks;
|
|
330
|
+
private eventRateLimiters: Map<string, RateLimiter>;
|
|
331
|
+
private _clientRateOverrides: Map<string, RateLimiter>;
|
|
332
|
+
|
|
333
|
+
private clients = new Map<NetSocket, ClientRecord>();
|
|
334
|
+
private clientsById = new Map<string, ClientRecord>();
|
|
335
|
+
private rooms = new Map<string, Set<string>>(); // room -> Set of client IDs
|
|
54
336
|
private events: Map<string, StelarEventHandler> = new Map();
|
|
55
337
|
private middlewares: StelarMiddleware[] = [];
|
|
56
|
-
private heartbeatInterval: number;
|
|
57
338
|
private _hbTimer: ReturnType<typeof setInterval> | null = null;
|
|
339
|
+
private _rateCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
58
340
|
private _wildcardHandler: StelarWildcardHandler | null = null;
|
|
59
341
|
private _connectionHandler: StelarEventHandler | null = null;
|
|
60
342
|
private _acks: Map<string, StelarEventHandler> = new Map();
|
|
61
|
-
private _externalServers = new WeakSet<
|
|
343
|
+
private _externalServers = new WeakSet<HttpServer>();
|
|
344
|
+
private _upgradeHandler: ((req: IncomingMessage, socket: NetSocket, head: Buffer) => void) | null = null;
|
|
345
|
+
private _requestHandler: ((req: IncomingMessage, res: ServerResponse) => void) | null = null;
|
|
346
|
+
private _started = false;
|
|
347
|
+
private _startTime = 0;
|
|
348
|
+
private _shuttingDown = false;
|
|
349
|
+
private _sigintHandler: (() => void) | null = null;
|
|
350
|
+
private _sigtermHandler: (() => void) | null = null;
|
|
351
|
+
|
|
352
|
+
private rateLimiter: RateLimiter | null;
|
|
353
|
+
|
|
354
|
+
private ipTracker: IPConnectionTracker;
|
|
355
|
+
|
|
356
|
+
private _totalConnections = 0;
|
|
357
|
+
private _totalMessagesReceived = 0;
|
|
358
|
+
private _totalMessagesSent = 0;
|
|
359
|
+
|
|
360
|
+
private log: Logger;
|
|
62
361
|
|
|
63
362
|
constructor(options: StelarOptions = {}) {
|
|
64
363
|
this.port = options.port || 3000;
|
|
65
|
-
this.
|
|
364
|
+
this.httpServer = options.server || null;
|
|
66
365
|
this.namespace = options.namespace || '/';
|
|
67
366
|
this.heartbeatInterval = options.heartbeatInterval || 30000;
|
|
367
|
+
this.heartbeatTimeout = options.heartbeatTimeout || this.heartbeatInterval * 2;
|
|
368
|
+
this.tcpPort = options.tcpPort !== undefined ? options.tcpPort : false;
|
|
369
|
+
this.maxConnections = options.maxConnections || 10000;
|
|
370
|
+
this.maxRooms = options.maxRooms || 10000;
|
|
371
|
+
this.maxRoomsPerClient = options.maxRoomsPerClient || 50;
|
|
372
|
+
this.maxPayloadSize = options.maxPayloadSize || 10 * 1024 * 1024; // 10 MB
|
|
373
|
+
this.maxFrameSize = options.maxFrameSize || DEFAULT_MAX_FRAME_SIZE;
|
|
374
|
+
this.maxWSFrameSize = options.maxFrameSize || DEFAULT_MAX_WS_FRAME_SIZE;
|
|
375
|
+
this.connectTimeout = options.connectTimeout || 10000;
|
|
376
|
+
this.doGracefulShutdown = options.gracefulShutdown !== false;
|
|
377
|
+
this.shutdownTimeout = options.shutdownTimeout || 10000;
|
|
378
|
+
this.healthEndpoint = options.healthEndpoint !== undefined ? options.healthEndpoint : '/health';
|
|
379
|
+
this.tlsOptions = options.tls;
|
|
380
|
+
this.allowedOrigins = options.allowedOrigins || null;
|
|
381
|
+
|
|
382
|
+
this._customRateLimiter = options.customRateLimiter || null;
|
|
383
|
+
this._customIPTracker = options.customIPTracker || null;
|
|
384
|
+
this._generateClientId = options.generateClientId || null;
|
|
385
|
+
this._customHealthHandler = options.customHealthHandler || null;
|
|
386
|
+
this.hooks = options.hooks || {};
|
|
387
|
+
this.eventRateLimiters = new Map();
|
|
388
|
+
this._clientRateOverrides = new Map();
|
|
389
|
+
|
|
390
|
+
if (options.eventRateLimits) {
|
|
391
|
+
for (const [event, config] of Object.entries(options.eventRateLimits)) {
|
|
392
|
+
this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (options.rateLimit === false && !this._customRateLimiter) {
|
|
397
|
+
this.rateLimiter = null;
|
|
398
|
+
} else if (!this._customRateLimiter) {
|
|
399
|
+
const rl = options.rateLimit || {};
|
|
400
|
+
this.rateLimiter = new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
|
|
401
|
+
} else {
|
|
402
|
+
this.rateLimiter = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!this._customIPTracker) {
|
|
406
|
+
this.ipTracker = new IPConnectionTracker(options.maxConnectionsPerIP || 50);
|
|
407
|
+
} else {
|
|
408
|
+
this.ipTracker = new IPConnectionTracker(50); // unused when custom tracker is set
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (options.logger === false) {
|
|
412
|
+
this.log = NULL_LOGGER;
|
|
413
|
+
} else if (options.logger instanceof Logger) {
|
|
414
|
+
this.log = options.logger;
|
|
415
|
+
} else {
|
|
416
|
+
this.log = new Logger({
|
|
417
|
+
level: (options.logger as LogLevel) || 'info',
|
|
418
|
+
prefix: 'stelar:server',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
68
421
|
}
|
|
69
422
|
|
|
70
423
|
static of(path: string, options: StelarOptions = {}): StelarServer {
|
|
71
424
|
return new StelarServer({ ...options, namespace: path });
|
|
72
425
|
}
|
|
73
426
|
|
|
427
|
+
/** Update server configuration at runtime. */
|
|
428
|
+
updateConfig(options: Partial<StelarOptions>): this {
|
|
429
|
+
if (options.maxConnections !== undefined) this.maxConnections = options.maxConnections;
|
|
430
|
+
if (options.maxConnectionsPerIP !== undefined && !this._customIPTracker) {
|
|
431
|
+
this.ipTracker = new IPConnectionTracker(options.maxConnectionsPerIP);
|
|
432
|
+
}
|
|
433
|
+
if (options.maxRooms !== undefined) this.maxRooms = options.maxRooms;
|
|
434
|
+
if (options.maxRoomsPerClient !== undefined) this.maxRoomsPerClient = options.maxRoomsPerClient;
|
|
435
|
+
if (options.maxPayloadSize !== undefined) this.maxPayloadSize = options.maxPayloadSize;
|
|
436
|
+
if (options.heartbeatInterval !== undefined) this.heartbeatInterval = options.heartbeatInterval;
|
|
437
|
+
if (options.heartbeatTimeout !== undefined) this.heartbeatTimeout = options.heartbeatTimeout;
|
|
438
|
+
if (options.allowedOrigins !== undefined) this.allowedOrigins = options.allowedOrigins;
|
|
439
|
+
|
|
440
|
+
if (options.rateLimit === false) {
|
|
441
|
+
this.rateLimiter = null;
|
|
442
|
+
this._customRateLimiter = null;
|
|
443
|
+
} else if (options.rateLimit && !this._customRateLimiter) {
|
|
444
|
+
const rl = options.rateLimit;
|
|
445
|
+
this.rateLimiter = new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (options.customRateLimiter !== undefined) {
|
|
449
|
+
this._customRateLimiter = options.customRateLimiter;
|
|
450
|
+
this.rateLimiter = null;
|
|
451
|
+
}
|
|
452
|
+
if (options.customIPTracker !== undefined) {
|
|
453
|
+
this._customIPTracker = options.customIPTracker;
|
|
454
|
+
}
|
|
455
|
+
if (options.generateClientId !== undefined) {
|
|
456
|
+
this._generateClientId = options.generateClientId;
|
|
457
|
+
}
|
|
458
|
+
if (options.customHealthHandler !== undefined) {
|
|
459
|
+
this._customHealthHandler = options.customHealthHandler;
|
|
460
|
+
}
|
|
461
|
+
if (options.hooks !== undefined) {
|
|
462
|
+
this.hooks = { ...this.hooks, ...options.hooks };
|
|
463
|
+
}
|
|
464
|
+
if (options.eventRateLimits !== undefined) {
|
|
465
|
+
this.eventRateLimiters.clear();
|
|
466
|
+
for (const [event, config] of Object.entries(options.eventRateLimits)) {
|
|
467
|
+
this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.log.info('Server configuration updated');
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Set a per-client rate limit override. */
|
|
476
|
+
setClientRateLimit(clientId: string, config: { maxPoints: number; windowMs: number }): this {
|
|
477
|
+
this._clientRateOverrides.set(clientId, new RateLimiter(config.maxPoints, config.windowMs));
|
|
478
|
+
return this;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Remove a per-client rate limit override, falling back to the global limiter. */
|
|
482
|
+
removeClientRateLimit(clientId: string): this {
|
|
483
|
+
this._clientRateOverrides.delete(clientId);
|
|
484
|
+
return this;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Set a per-event rate limit. */
|
|
488
|
+
setEventRateLimit(event: string, config: { maxPoints: number; windowMs: number }): this {
|
|
489
|
+
this.eventRateLimiters.set(event, new RateLimiter(config.maxPoints, config.windowMs));
|
|
490
|
+
return this;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Remove a per-event rate limit. */
|
|
494
|
+
removeEventRateLimit(event: string): this {
|
|
495
|
+
this.eventRateLimiters.delete(event);
|
|
496
|
+
return this;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Get the current server configuration as a read-only object. */
|
|
500
|
+
getConfig(): Readonly<{
|
|
501
|
+
maxConnections: number;
|
|
502
|
+
maxConnectionsPerIP: number;
|
|
503
|
+
maxRooms: number;
|
|
504
|
+
maxRoomsPerClient: number;
|
|
505
|
+
maxPayloadSize: number;
|
|
506
|
+
heartbeatInterval: number;
|
|
507
|
+
heartbeatTimeout: number;
|
|
508
|
+
connectTimeout: number;
|
|
509
|
+
shutdownTimeout: number;
|
|
510
|
+
hasCustomRateLimiter: boolean;
|
|
511
|
+
hasCustomIPTracker: boolean;
|
|
512
|
+
hasCustomClientIdGenerator: boolean;
|
|
513
|
+
hasCustomHealthHandler: boolean;
|
|
514
|
+
eventRateLimits: string[];
|
|
515
|
+
hooks: string[];
|
|
516
|
+
allowedOrigins: string[] | null;
|
|
517
|
+
}> {
|
|
518
|
+
return Object.freeze({
|
|
519
|
+
maxConnections: this.maxConnections,
|
|
520
|
+
maxConnectionsPerIP: this._customIPTracker ? -1 : (this.ipTracker as any).maxPerIP || 50,
|
|
521
|
+
maxRooms: this.maxRooms,
|
|
522
|
+
maxRoomsPerClient: this.maxRoomsPerClient,
|
|
523
|
+
maxPayloadSize: this.maxPayloadSize,
|
|
524
|
+
heartbeatInterval: this.heartbeatInterval,
|
|
525
|
+
heartbeatTimeout: this.heartbeatTimeout,
|
|
526
|
+
connectTimeout: this.connectTimeout,
|
|
527
|
+
shutdownTimeout: this.shutdownTimeout,
|
|
528
|
+
hasCustomRateLimiter: this._customRateLimiter !== null,
|
|
529
|
+
hasCustomIPTracker: this._customIPTracker !== null,
|
|
530
|
+
hasCustomClientIdGenerator: this._generateClientId !== null,
|
|
531
|
+
hasCustomHealthHandler: this._customHealthHandler !== null,
|
|
532
|
+
eventRateLimits: Array.from(this.eventRateLimiters.keys()),
|
|
533
|
+
hooks: Object.keys(this.hooks),
|
|
534
|
+
allowedOrigins: this.allowedOrigins,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
74
538
|
use(middleware: StelarMiddleware): this {
|
|
75
539
|
this.middlewares.push(middleware);
|
|
76
540
|
return this;
|
|
@@ -91,269 +555,1267 @@ class StelarServer {
|
|
|
91
555
|
return this;
|
|
92
556
|
}
|
|
93
557
|
|
|
558
|
+
onDisconnect(handler: StelarEventHandler): this {
|
|
559
|
+
this.events.set('disconnect', handler);
|
|
560
|
+
return this;
|
|
561
|
+
}
|
|
562
|
+
|
|
94
563
|
onAck(name: string, handler: StelarEventHandler): this {
|
|
95
564
|
this._acks.set(name, handler);
|
|
96
565
|
return this;
|
|
97
566
|
}
|
|
98
567
|
|
|
99
|
-
broadcast(event: string, data: unknown): this {
|
|
100
|
-
this.
|
|
101
|
-
|
|
568
|
+
broadcast(event: string, data: unknown, excludeId?: string): this {
|
|
569
|
+
if (this.hooks.onBeforeBroadcast) {
|
|
570
|
+
const result = this.hooks.onBeforeBroadcast({ event, data, excludeId });
|
|
571
|
+
if (result === false) return this;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let sent = 0;
|
|
575
|
+
this.clients.forEach((record) => {
|
|
576
|
+
if (excludeId && record.info.id === excludeId) return;
|
|
577
|
+
if (this._sendJsonToClient(record, event, data)) sent++;
|
|
102
578
|
});
|
|
579
|
+
this._totalMessagesSent += sent;
|
|
103
580
|
return this;
|
|
104
581
|
}
|
|
105
582
|
|
|
106
583
|
broadcastBinary(event: string, buffer: ArrayBuffer): void {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const combined = new Uint8Array(headerBytes.length + 1 + buffer.byteLength);
|
|
110
|
-
combined.set(headerBytes, 0);
|
|
111
|
-
combined[headerBytes.length] = 0;
|
|
112
|
-
combined.set(new Uint8Array(buffer), headerBytes.length + 1);
|
|
113
|
-
|
|
114
|
-
this.clients.forEach((_, client) => {
|
|
115
|
-
client.send(combined);
|
|
584
|
+
this.clients.forEach((record) => {
|
|
585
|
+
this._sendBinaryRaw(record, event, buffer);
|
|
116
586
|
});
|
|
117
587
|
}
|
|
118
588
|
|
|
119
|
-
to(room: string, event: string, data: unknown): this {
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
589
|
+
to(room: string, event: string, data: unknown, excludeId?: string): this {
|
|
590
|
+
const memberIds = this.rooms.get(room);
|
|
591
|
+
if (!memberIds) return this;
|
|
592
|
+
|
|
593
|
+
let sent = 0;
|
|
594
|
+
for (const clientId of memberIds) {
|
|
595
|
+
if (excludeId && clientId === excludeId) continue;
|
|
596
|
+
const record = this.clientsById.get(clientId);
|
|
597
|
+
if (record && this._sendJsonToClient(record, event, data)) sent++;
|
|
598
|
+
}
|
|
599
|
+
this._totalMessagesSent += sent;
|
|
125
600
|
return this;
|
|
126
601
|
}
|
|
127
602
|
|
|
128
603
|
toId(id: string, event: string, data: unknown): this {
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
});
|
|
604
|
+
const record = this.clientsById.get(id);
|
|
605
|
+
if (record && this._sendJsonToClient(record, event, data)) {
|
|
606
|
+
this._totalMessagesSent++;
|
|
607
|
+
}
|
|
134
608
|
return this;
|
|
135
609
|
}
|
|
136
610
|
|
|
137
|
-
getClients(room?: string): { id: string;
|
|
138
|
-
const list: { id: string;
|
|
139
|
-
this.clients.forEach((
|
|
140
|
-
if (!room || info.room
|
|
611
|
+
getClients(room?: string): { id: string; rooms: string[] }[] {
|
|
612
|
+
const list: { id: string; rooms: string[] }[] = [];
|
|
613
|
+
this.clients.forEach((record) => {
|
|
614
|
+
if (!room || record.info.rooms.has(room)) {
|
|
615
|
+
list.push({ id: record.info.id, rooms: Array.from(record.info.rooms) });
|
|
616
|
+
}
|
|
141
617
|
});
|
|
142
618
|
return list;
|
|
143
619
|
}
|
|
144
620
|
|
|
621
|
+
getRoomMembers(room: string): string[] {
|
|
622
|
+
const members = this.rooms.get(room);
|
|
623
|
+
return members ? Array.from(members) : [];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
getRooms(): string[] {
|
|
627
|
+
return Array.from(this.rooms.keys());
|
|
628
|
+
}
|
|
629
|
+
|
|
145
630
|
getPort(): number {
|
|
146
|
-
const address = this.
|
|
631
|
+
const address = this.httpServer?.address();
|
|
147
632
|
if (address && typeof address === 'object') {
|
|
148
633
|
return address.port;
|
|
149
634
|
}
|
|
150
635
|
return this.port;
|
|
151
636
|
}
|
|
152
637
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
638
|
+
getStats(): StelarStats {
|
|
639
|
+
let wsConns = 0;
|
|
640
|
+
let tcpConns = 0;
|
|
641
|
+
this.clients.forEach((r) => {
|
|
642
|
+
if (r.protocol === 'ws') wsConns++;
|
|
643
|
+
else tcpConns++;
|
|
644
|
+
});
|
|
645
|
+
return {
|
|
646
|
+
totalConnections: this._totalConnections,
|
|
647
|
+
activeConnections: this.clients.size,
|
|
648
|
+
totalMessagesReceived: this._totalMessagesReceived,
|
|
649
|
+
totalMessagesSent: this._totalMessagesSent,
|
|
650
|
+
totalRooms: this.rooms.size,
|
|
651
|
+
uptime: this._startTime ? Date.now() - this._startTime : 0,
|
|
652
|
+
wsConnections: wsConns,
|
|
653
|
+
tcpConnections: tcpConns,
|
|
654
|
+
memoryUsage: process.memoryUsage(),
|
|
655
|
+
rateLimiterEntries: this._getRateLimiterSize(),
|
|
157
656
|
};
|
|
158
|
-
run(0);
|
|
159
657
|
}
|
|
160
658
|
|
|
161
|
-
private
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
if (info.lastPing && Date.now() - info.lastPing > this.heartbeatInterval * 2) {
|
|
165
|
-
client.close();
|
|
166
|
-
this.clients.delete(client);
|
|
167
|
-
} else {
|
|
168
|
-
client.send(JSON.stringify({ event: 'ping', data: Date.now() }));
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
}, this.heartbeatInterval);
|
|
659
|
+
private _getRateLimiterSize(): number {
|
|
660
|
+
if (this._customRateLimiter) return this._customRateLimiter.size();
|
|
661
|
+
return this.rateLimiter?.size() || 0;
|
|
172
662
|
}
|
|
173
663
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
664
|
+
/** Check rate limit. Priority: per-client override > event-specific > custom/global. */
|
|
665
|
+
private _checkRateLimit(clientId: string, event?: string): boolean {
|
|
666
|
+
const clientOverride = this._clientRateOverrides.get(clientId);
|
|
667
|
+
if (clientOverride) {
|
|
668
|
+
return clientOverride.check(clientId);
|
|
669
|
+
}
|
|
177
670
|
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
return;
|
|
671
|
+
if (event && this.eventRateLimiters.has(event)) {
|
|
672
|
+
const eventLimiter = this.eventRateLimiters.get(event)!;
|
|
673
|
+
if (!eventLimiter.check(clientId)) return false;
|
|
181
674
|
}
|
|
182
675
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
676
|
+
if (this._customRateLimiter) {
|
|
677
|
+
return this._customRateLimiter.check(clientId);
|
|
678
|
+
}
|
|
679
|
+
if (this.rateLimiter) {
|
|
680
|
+
return this.rateLimiter.check(clientId);
|
|
681
|
+
}
|
|
186
682
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
getClients: (room) => this.getClients(room),
|
|
199
|
-
joinRoom: (room) => {
|
|
200
|
-
clientInfo.room = room;
|
|
201
|
-
client.send(JSON.stringify({ event: 'joined-room', data: room }));
|
|
202
|
-
},
|
|
203
|
-
leaveRoom: () => {
|
|
204
|
-
clientInfo.room = null;
|
|
205
|
-
},
|
|
206
|
-
ack: (ackName, data) => {
|
|
207
|
-
const ackHandler = this._acks.get(ackName);
|
|
208
|
-
if (ackHandler) {
|
|
209
|
-
const result = ackHandler({ ...ctx, data });
|
|
210
|
-
if (result !== undefined) {
|
|
211
|
-
client.send(JSON.stringify({ event: ackName, data: result, _isAck: true }));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private _sendJsonToClient(record: ClientRecord, event: string, data: unknown): boolean {
|
|
687
|
+
if (record.socket.destroyed || record.socket.writableEnded) return false;
|
|
688
|
+
try {
|
|
689
|
+
if (record.protocol === 'ws') {
|
|
690
|
+
const json = JSON.stringify({ event, data });
|
|
691
|
+
record.socket.write(createWSTextFrame(json));
|
|
692
|
+
} else {
|
|
693
|
+
record.socket.write(encodeJsonFrame(event, data, this.maxFrameSize));
|
|
214
694
|
}
|
|
215
|
-
|
|
695
|
+
record.info.messagesSent++;
|
|
696
|
+
return true;
|
|
697
|
+
} catch (err) {
|
|
698
|
+
this.log.error('Send error', { clientId: record.info.id, error: String(err) });
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
216
702
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
703
|
+
private _sendBinaryRaw(record: ClientRecord, event: string, buffer: ArrayBuffer): boolean {
|
|
704
|
+
if (record.socket.destroyed || record.socket.writableEnded) return false;
|
|
705
|
+
try {
|
|
706
|
+
if (record.protocol === 'ws') {
|
|
707
|
+
const header = JSON.stringify({ event, _binary: true });
|
|
708
|
+
const headerBytes = Buffer.from(header, 'utf8');
|
|
709
|
+
const combined = Buffer.alloc(headerBytes.length + 1 + buffer.byteLength);
|
|
710
|
+
headerBytes.copy(combined, 0);
|
|
711
|
+
combined[headerBytes.length] = 0;
|
|
712
|
+
combined.set(new Uint8Array(buffer), headerBytes.length + 1);
|
|
713
|
+
record.socket.write(createWSBinaryFrame(combined));
|
|
714
|
+
} else {
|
|
715
|
+
record.socket.write(encodeBinaryFrame(event, new Uint8Array(buffer), this.maxFrameSize));
|
|
220
716
|
}
|
|
221
|
-
|
|
717
|
+
record.info.messagesSent++;
|
|
718
|
+
return true;
|
|
719
|
+
} catch (err) {
|
|
720
|
+
this.log.error('Binary send error', { clientId: record.info.id, error: String(err) });
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
222
724
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
725
|
+
private _joinRoom(record: ClientRecord, room: string): void {
|
|
726
|
+
if (this.hooks.onClientJoinRoom) {
|
|
727
|
+
const result = this.hooks.onClientJoinRoom({
|
|
728
|
+
clientId: record.info.id,
|
|
729
|
+
room,
|
|
730
|
+
metadata: record.info.metadata,
|
|
731
|
+
});
|
|
732
|
+
if (result === false) {
|
|
733
|
+
this.log.info('Room join rejected by hook', { clientId: record.info.id, room });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (record.info.rooms.size >= this.maxRoomsPerClient) {
|
|
739
|
+
if (this.hooks.onMaxRoomsPerClientReached) {
|
|
740
|
+
const result = this.hooks.onMaxRoomsPerClientReached({
|
|
741
|
+
clientId: record.info.id,
|
|
742
|
+
room,
|
|
743
|
+
currentRooms: record.info.rooms.size,
|
|
744
|
+
max: this.maxRoomsPerClient,
|
|
745
|
+
});
|
|
746
|
+
if (result === false) return;
|
|
747
|
+
}
|
|
748
|
+
this.log.warn('Client exceeded max rooms', { clientId: record.info.id, room, max: this.maxRoomsPerClient });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (this.rooms.size >= this.maxRooms && !this.rooms.has(room)) {
|
|
752
|
+
if (this.hooks.onMaxRoomsReached) {
|
|
753
|
+
const result = this.hooks.onMaxRoomsReached({
|
|
754
|
+
clientId: record.info.id,
|
|
755
|
+
room,
|
|
756
|
+
totalRooms: this.rooms.size,
|
|
757
|
+
max: this.maxRooms,
|
|
758
|
+
});
|
|
759
|
+
if (result === false) return;
|
|
760
|
+
}
|
|
761
|
+
this.log.warn('Server exceeded max rooms', { room, max: this.maxRooms });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
record.info.rooms.add(room);
|
|
235
765
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
766
|
+
if (!this.rooms.has(room)) {
|
|
767
|
+
this.rooms.set(room, new Set());
|
|
768
|
+
}
|
|
769
|
+
this.rooms.get(room)!.add(record.info.id);
|
|
239
770
|
|
|
240
|
-
|
|
771
|
+
this._sendJsonToClient(record, 'joined-room', room);
|
|
772
|
+
}
|
|
241
773
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
774
|
+
private _leaveRoom(record: ClientRecord, room: string): void {
|
|
775
|
+
if (this.hooks.onClientLeaveRoom) {
|
|
776
|
+
const result = this.hooks.onClientLeaveRoom({
|
|
777
|
+
clientId: record.info.id,
|
|
778
|
+
room,
|
|
779
|
+
});
|
|
780
|
+
if (result === false) {
|
|
781
|
+
this.log.info('Room leave rejected by hook', { clientId: record.info.id, room });
|
|
249
782
|
return;
|
|
250
783
|
}
|
|
784
|
+
}
|
|
251
785
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
786
|
+
record.info.rooms.delete(room);
|
|
787
|
+
const members = this.rooms.get(room);
|
|
788
|
+
if (members) {
|
|
789
|
+
members.delete(record.info.id);
|
|
790
|
+
if (members.size === 0) {
|
|
791
|
+
this.rooms.delete(room);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
this._sendJsonToClient(record, 'left-room', room);
|
|
795
|
+
}
|
|
255
796
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
797
|
+
private _removeFromAllRooms(record: ClientRecord): void {
|
|
798
|
+
for (const room of record.info.rooms) {
|
|
799
|
+
const members = this.rooms.get(room);
|
|
800
|
+
if (members) {
|
|
801
|
+
members.delete(record.info.id);
|
|
802
|
+
if (members.size === 0) {
|
|
803
|
+
this.rooms.delete(room);
|
|
259
804
|
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
record.info.rooms.clear();
|
|
808
|
+
}
|
|
260
809
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
810
|
+
private _buildCtx(record: ClientRecord, req: IncomingMessage | null): StelarContext {
|
|
811
|
+
const self = this;
|
|
812
|
+
const ctx: StelarContext = {
|
|
813
|
+
id: record.info.id,
|
|
814
|
+
socket: record.socket,
|
|
815
|
+
req,
|
|
816
|
+
clientInfo: record.info,
|
|
817
|
+
emit: (evt, d) => { if (self._sendJsonToClient(record, evt, d)) self._totalMessagesSent++; },
|
|
818
|
+
send: (respId, d) => { if (self._sendJsonToClient(record, respId, { data: d, _isAck: true })) self._totalMessagesSent++; },
|
|
819
|
+
emitBinary: (evt, buf) => { if (self._sendBinaryRaw(record, evt, buf)) self._totalMessagesSent++; },
|
|
820
|
+
broadcast: (evt, d) => self.broadcast(evt, d, record.info.id),
|
|
821
|
+
broadcastBinary: (evt, buf) => self.broadcastBinary(evt, buf),
|
|
822
|
+
to: (room, evt, d) => self.to(room, evt, d, record.info.id),
|
|
823
|
+
toId: (id, evt, d) => self.toId(id, evt, d),
|
|
824
|
+
getClients: (room) => self.getClients(room),
|
|
825
|
+
joinRoom: (room) => self._joinRoom(record, room),
|
|
826
|
+
leaveRoom: (room) => self._leaveRoom(record, room),
|
|
827
|
+
setMetadata: (key, value) => record.info.metadata.set(key, value),
|
|
828
|
+
getMetadata: (key) => record.info.metadata.get(key),
|
|
829
|
+
ack: (ackName, d) => {
|
|
830
|
+
const ackHandler = self._acks.get(ackName);
|
|
831
|
+
if (ackHandler) {
|
|
832
|
+
const result = ackHandler({ ...ctx, data: d });
|
|
833
|
+
if (result !== undefined) {
|
|
834
|
+
try {
|
|
835
|
+
if (record.protocol === 'ws') {
|
|
836
|
+
record.socket.write(createWSTextFrame(JSON.stringify({ event: ackName, data: result, _isAck: true })));
|
|
837
|
+
} else {
|
|
838
|
+
record.socket.write(encodeAckResFrame(ackName, result, self.maxFrameSize));
|
|
839
|
+
}
|
|
840
|
+
self._totalMessagesSent++;
|
|
841
|
+
} catch (err) {
|
|
842
|
+
self.log.error('ACK send error', { ackName, error: String(err) });
|
|
843
|
+
}
|
|
265
844
|
}
|
|
266
845
|
}
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
return ctx;
|
|
849
|
+
}
|
|
267
850
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
851
|
+
private runMiddlewares(ctx: StelarContext, next: () => void): void {
|
|
852
|
+
const run = (i: number): void => {
|
|
853
|
+
if (i >= this.middlewares.length) return next();
|
|
854
|
+
try {
|
|
855
|
+
this.middlewares[i](ctx, () => run(i + 1));
|
|
856
|
+
} catch (err) {
|
|
857
|
+
this.log.error('Middleware error', { error: String(err), clientId: ctx.id });
|
|
858
|
+
ctx.socket.destroy();
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
run(0);
|
|
862
|
+
}
|
|
272
863
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
864
|
+
private startHeartbeat(): void {
|
|
865
|
+
this._hbTimer = setInterval(() => {
|
|
866
|
+
const now = Date.now();
|
|
867
|
+
this.clients.forEach((record) => {
|
|
868
|
+
if (now - record.info.lastPing > this.heartbeatTimeout) {
|
|
869
|
+
this.log.info('Client heartbeat timeout', { clientId: record.info.id });
|
|
870
|
+
record.socket.destroy();
|
|
871
|
+
} else {
|
|
872
|
+
try {
|
|
873
|
+
if (record.protocol === 'ws') {
|
|
874
|
+
record.socket.write(createWSPingFrame());
|
|
875
|
+
} else {
|
|
876
|
+
record.socket.write(encodePingFrame());
|
|
877
|
+
}
|
|
878
|
+
} catch {
|
|
879
|
+
// socket may have closed
|
|
278
880
|
}
|
|
279
|
-
return;
|
|
280
881
|
}
|
|
882
|
+
});
|
|
883
|
+
}, this.heartbeatInterval);
|
|
884
|
+
if (this._hbTimer && typeof this._hbTimer === 'object' && 'unref' in this._hbTimer) {
|
|
885
|
+
this._hbTimer.unref();
|
|
886
|
+
}
|
|
887
|
+
}
|
|
281
888
|
|
|
282
|
-
|
|
889
|
+
private _getClientIP(socket: NetSocket, req: IncomingMessage | null): string {
|
|
890
|
+
if (req) {
|
|
891
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
892
|
+
if (typeof forwarded === 'string') {
|
|
893
|
+
return forwarded.split(',')[0].trim();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return socket.remoteAddress || 'unknown';
|
|
897
|
+
}
|
|
283
898
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
899
|
+
private _registerClient(socket: NetSocket, protocol: 'ws' | 'tcp', req: IncomingMessage | null, parser: WSFrameParser | FrameParser): ClientRecord | null {
|
|
900
|
+
if (this.clients.size >= this.maxConnections) {
|
|
901
|
+
const clientIP = this._getClientIP(socket, req);
|
|
902
|
+
if (this.hooks.onMaxConnectionsReached) {
|
|
903
|
+
this.hooks.onMaxConnectionsReached({
|
|
904
|
+
activeConnections: this.clients.size,
|
|
905
|
+
max: this.maxConnections,
|
|
906
|
+
ip: clientIP,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
this.log.warn('Max connections reached, rejecting', { active: this.clients.size, max: this.maxConnections });
|
|
910
|
+
try {
|
|
911
|
+
if (protocol === 'ws') {
|
|
912
|
+
socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Server full'));
|
|
289
913
|
}
|
|
290
|
-
} catch {}
|
|
291
|
-
|
|
914
|
+
} catch { /* ignore */ }
|
|
915
|
+
socket.destroy();
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
292
918
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
919
|
+
const clientIP = this._getClientIP(socket, req);
|
|
920
|
+
const ipTracker = this._customIPTracker || this.ipTracker;
|
|
921
|
+
if (!ipTracker.check(clientIP)) {
|
|
922
|
+
this.log.warn('Max connections per IP reached, rejecting', { ip: clientIP });
|
|
923
|
+
try {
|
|
924
|
+
if (protocol === 'ws') {
|
|
925
|
+
socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Too many connections from this IP'));
|
|
926
|
+
}
|
|
927
|
+
} catch { /* ignore */ }
|
|
928
|
+
socket.destroy();
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
301
931
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
932
|
+
const clientId = this._generateClientId ? this._generateClientId() : randomUUID();
|
|
933
|
+
const info: StelarClientInfo = {
|
|
934
|
+
id: clientId,
|
|
935
|
+
rooms: new Set(),
|
|
936
|
+
lastPing: Date.now(),
|
|
937
|
+
protocol,
|
|
938
|
+
connectedAt: Date.now(),
|
|
939
|
+
metadata: new Map(),
|
|
940
|
+
messagesReceived: 0,
|
|
941
|
+
messagesSent: 0,
|
|
942
|
+
remoteAddress: clientIP,
|
|
943
|
+
};
|
|
944
|
+
const record: ClientRecord = { info, socket, parser, protocol };
|
|
945
|
+
this.clients.set(socket, record);
|
|
946
|
+
this.clientsById.set(clientId, record);
|
|
947
|
+
ipTracker.add(clientIP);
|
|
948
|
+
this._totalConnections++;
|
|
949
|
+
|
|
950
|
+
return record;
|
|
308
951
|
}
|
|
309
952
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
953
|
+
private _unregisterClient(record: ClientRecord, ctx: StelarContext): void {
|
|
954
|
+
if (this.hooks.onClientDisconnect) {
|
|
955
|
+
this.hooks.onClientDisconnect({
|
|
956
|
+
clientId: record.info.id,
|
|
957
|
+
ip: record.info.remoteAddress,
|
|
958
|
+
protocol: record.info.protocol,
|
|
959
|
+
rooms: new Set(record.info.rooms),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
317
962
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
};
|
|
963
|
+
this._removeFromAllRooms(record);
|
|
964
|
+
this.clientsById.delete(record.info.id);
|
|
965
|
+
this.clients.delete(record.socket);
|
|
322
966
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
startServer(this.server);
|
|
326
|
-
} else {
|
|
327
|
-
const tryListen = (port: number): void => {
|
|
328
|
-
this.server = createServer((_, res) => {
|
|
329
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
330
|
-
res.end('Stelar Time Real Server');
|
|
331
|
-
});
|
|
967
|
+
const ipTracker = this._customIPTracker || this.ipTracker;
|
|
968
|
+
ipTracker.remove(record.info.remoteAddress);
|
|
332
969
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
970
|
+
if (this._customRateLimiter) {
|
|
971
|
+
this._customRateLimiter.reset(record.info.id);
|
|
972
|
+
} else if (this.rateLimiter) {
|
|
973
|
+
this.rateLimiter.reset(record.info.id);
|
|
974
|
+
}
|
|
975
|
+
this._clientRateOverrides.delete(record.info.id);
|
|
338
976
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
977
|
+
if (this.events.has('disconnect')) {
|
|
978
|
+
const handler = this.events.get('disconnect')!;
|
|
979
|
+
try {
|
|
980
|
+
handler({ ...ctx, event: 'disconnect' });
|
|
981
|
+
} catch (err) {
|
|
982
|
+
this.log.error('Disconnect handler error', { error: String(err) });
|
|
345
983
|
}
|
|
346
|
-
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private _checkOrigin(req: IncomingMessage): boolean {
|
|
988
|
+
if (!this.allowedOrigins) return true;
|
|
989
|
+
const origin = req.headers['origin'];
|
|
990
|
+
if (!origin) return true;
|
|
991
|
+
return this.allowedOrigins.includes(origin);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private handleWSUpgrade(req: IncomingMessage, socket: NetSocket, head: Buffer): void {
|
|
995
|
+
const urlPath = new URL(req.url || '/', 'http://localhost').pathname;
|
|
996
|
+
const nsPath = this.namespace === '/' ? '/' : this.namespace;
|
|
997
|
+
if (nsPath !== '/' && urlPath !== nsPath) {
|
|
998
|
+
this.log.debug('Rejected WS: wrong namespace', { path: urlPath, expected: nsPath });
|
|
999
|
+
socket.destroy();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (!this._checkOrigin(req)) {
|
|
1004
|
+
this.log.warn('Rejected WS: origin not allowed', { origin: req.headers['origin'] });
|
|
1005
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
1006
|
+
socket.destroy();
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const key = req.headers['sec-websocket-key'] as string;
|
|
1011
|
+
if (!key || !validateWSKey(key)) {
|
|
1012
|
+
this.log.warn('Invalid WebSocket key');
|
|
1013
|
+
socket.destroy();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
const extraHeaders: Record<string, string> = {};
|
|
1019
|
+
const origin = req.headers['origin'];
|
|
1020
|
+
if (origin && this.allowedOrigins && this.allowedOrigins.includes(origin)) {
|
|
1021
|
+
extraHeaders['Access-Control-Allow-Origin'] = origin;
|
|
1022
|
+
}
|
|
1023
|
+
socket.write(buildUpgradeResponse(key, extraHeaders));
|
|
1024
|
+
} catch {
|
|
1025
|
+
socket.destroy();
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const connectTimer = setTimeout(() => {
|
|
1030
|
+
if (!this.clients.has(socket)) {
|
|
1031
|
+
this.log.warn('WS connect timeout');
|
|
1032
|
+
socket.destroy();
|
|
1033
|
+
}
|
|
1034
|
+
}, this.connectTimeout);
|
|
1035
|
+
connectTimer.unref();
|
|
1036
|
+
|
|
1037
|
+
const record = this._registerClient(socket, 'ws', req, new WSFrameParser(this.maxWSFrameSize));
|
|
1038
|
+
if (!record) {
|
|
1039
|
+
clearTimeout(connectTimer);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const ctx = this._buildCtx(record, req);
|
|
1044
|
+
|
|
1045
|
+
if (this.hooks.onClientConnect) {
|
|
1046
|
+
this.hooks.onClientConnect({
|
|
1047
|
+
clientId: record.info.id,
|
|
1048
|
+
ip: record.info.remoteAddress,
|
|
1049
|
+
protocol: 'ws',
|
|
1050
|
+
metadata: record.info.metadata,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
this.runMiddlewares(ctx, () => {
|
|
1055
|
+
if (this._connectionHandler) {
|
|
1056
|
+
try {
|
|
1057
|
+
this._connectionHandler(ctx);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
this.log.error('Connection handler error', { error: String(err) });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
this.log.info('WS client connected', { clientId: record.info.id, ip: record.info.remoteAddress });
|
|
1065
|
+
|
|
1066
|
+
if (head.length > 0) {
|
|
1067
|
+
this._processWSData(record, head, ctx, req);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
socket.on('data', (data: Buffer) => {
|
|
1071
|
+
clearTimeout(connectTimer);
|
|
1072
|
+
this._processWSData(record, data, ctx, req);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
socket.on('close', () => {
|
|
1076
|
+
clearTimeout(connectTimer);
|
|
1077
|
+
this.log.debug('WS client socket closed', { clientId: record.info.id });
|
|
1078
|
+
this._unregisterClient(record, ctx);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
socket.on('error', (err: Error) => {
|
|
1082
|
+
this.log.warn('WS client error', { clientId: record.info.id, error: err.message });
|
|
1083
|
+
this._handleError(record, ctx, err);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
socket.on('drain', () => {
|
|
1087
|
+
socket.resume();
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private _processWSData(record: ClientRecord, data: Buffer, ctx: StelarContext, req: IncomingMessage | null): void {
|
|
1092
|
+
let frames: WSFrame[];
|
|
1093
|
+
try {
|
|
1094
|
+
frames = (record.parser as WSFrameParser).feed(data);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
if (err instanceof WebSocketError) {
|
|
1097
|
+
this.log.warn('WS protocol error', { clientId: record.info.id, code: err.code, message: err.message });
|
|
1098
|
+
try {
|
|
1099
|
+
record.socket.write(createWSCloseFrame(err.code, err.message));
|
|
1100
|
+
} catch { /* ignore */ }
|
|
1101
|
+
} else {
|
|
1102
|
+
this.log.error('WS frame parse error', { clientId: record.info.id, error: String(err) });
|
|
1103
|
+
}
|
|
1104
|
+
record.socket.destroy();
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
for (const frame of frames) {
|
|
1109
|
+
if (record.socket.destroyed) break;
|
|
1110
|
+
this._handleWSFrame(record, frame, ctx, req);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private _handleWSFrame(record: ClientRecord, frame: WSFrame, ctx: StelarContext, _req: IncomingMessage | null): void {
|
|
1115
|
+
const { opcode, payload } = frame;
|
|
1116
|
+
|
|
1117
|
+
if (opcode === OP_PING) {
|
|
1118
|
+
try { record.socket.write(createWSPongFrame(payload)); } catch { /* ignore */ }
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (opcode === OP_CLOSE) {
|
|
1123
|
+
try { record.socket.write(createWSCloseFrame(CLOSE_NORMAL)); } catch { /* ignore */ }
|
|
1124
|
+
record.socket.end();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (opcode === OP_PONG) {
|
|
1129
|
+
record.info.lastPing = Date.now();
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (!this._checkRateLimit(record.info.id)) {
|
|
1134
|
+
this.log.warn('Rate limit exceeded', { clientId: record.info.id });
|
|
1135
|
+
|
|
1136
|
+
if (this.hooks.onRateLimitExceeded) {
|
|
1137
|
+
const result = this.hooks.onRateLimitExceeded({
|
|
1138
|
+
clientId: record.info.id,
|
|
1139
|
+
protocol: 'ws',
|
|
1140
|
+
});
|
|
1141
|
+
if (result === false) return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
try {
|
|
1145
|
+
record.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded'));
|
|
1146
|
+
} catch { /* ignore */ }
|
|
1147
|
+
record.socket.destroy();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (opcode === OP_TEXT) {
|
|
1152
|
+
record.info.messagesReceived++;
|
|
1153
|
+
this._totalMessagesReceived++;
|
|
1154
|
+
|
|
1155
|
+
if (payload.length > this.maxPayloadSize) {
|
|
1156
|
+
if (this.hooks.onPayloadTooLarge) {
|
|
1157
|
+
this.hooks.onPayloadTooLarge({ clientId: record.info.id, size: payload.length, max: this.maxPayloadSize });
|
|
1158
|
+
}
|
|
1159
|
+
this.log.warn('Payload too large', { clientId: record.info.id, size: payload.length });
|
|
1160
|
+
try {
|
|
1161
|
+
record.socket.write(createWSCloseFrame(CLOSE_MESSAGE_TOO_BIG, 'Payload too large'));
|
|
1162
|
+
} catch { /* ignore */ }
|
|
1163
|
+
record.socket.destroy();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
let msg: Record<string, unknown>;
|
|
1168
|
+
try {
|
|
1169
|
+
msg = JSON.parse(payload.toString('utf8'));
|
|
1170
|
+
} catch {
|
|
1171
|
+
if (this.hooks.onInvalidMessage) {
|
|
1172
|
+
this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid JSON', protocol: 'ws' });
|
|
1173
|
+
}
|
|
1174
|
+
this.log.warn('Invalid JSON from client', { clientId: record.info.id });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const event = String(msg.event || '');
|
|
1179
|
+
const data = msg.data;
|
|
1180
|
+
|
|
1181
|
+
if (!event) return;
|
|
1182
|
+
|
|
1183
|
+
if (event && !this._checkRateLimit(record.info.id, event)) {
|
|
1184
|
+
this.log.warn('Event rate limit exceeded', { clientId: record.info.id, event });
|
|
1185
|
+
|
|
1186
|
+
if (this.hooks.onRateLimitExceeded) {
|
|
1187
|
+
const result = this.hooks.onRateLimitExceeded({
|
|
1188
|
+
clientId: record.info.id,
|
|
1189
|
+
event,
|
|
1190
|
+
protocol: 'ws',
|
|
1191
|
+
});
|
|
1192
|
+
if (result === false) return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
try {
|
|
1196
|
+
record.socket.write(createWSCloseFrame(CLOSE_POLICY_VIOLATION, 'Rate limit exceeded'));
|
|
1197
|
+
} catch { /* ignore */ }
|
|
1198
|
+
record.socket.destroy();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (event === 'pong') {
|
|
1203
|
+
record.info.lastPing = Date.now();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (event === 'join-room') {
|
|
1208
|
+
const room = String(data);
|
|
1209
|
+
if (room) this._joinRoom(record, room);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (event === 'leave-room') {
|
|
1214
|
+
const room = String(data);
|
|
1215
|
+
if (room) this._leaveRoom(record, room);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (msg._ackName && this._acks.has(String(msg._ackName))) {
|
|
1220
|
+
const ackName = String(msg._ackName);
|
|
1221
|
+
const ackHandler = this._acks.get(ackName)!;
|
|
1222
|
+
try {
|
|
1223
|
+
const result = ackHandler({ ...ctx, data });
|
|
1224
|
+
if (result !== undefined) {
|
|
1225
|
+
record.socket.write(createWSTextFrame(JSON.stringify({ event: ackName, data: result, _isAck: true })));
|
|
1226
|
+
this._totalMessagesSent++;
|
|
1227
|
+
}
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
this.log.error('ACK handler error', { ackName, error: String(err) });
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const eventCtx: StelarContext = { ...ctx, data, event };
|
|
1235
|
+
const handler = this.events.get(event);
|
|
1236
|
+
if (handler) {
|
|
1237
|
+
try {
|
|
1238
|
+
handler(eventCtx);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
this.log.error('Event handler error', { event, error: String(err) });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (this._wildcardHandler) {
|
|
1244
|
+
try {
|
|
1245
|
+
this._wildcardHandler({ event, data: eventCtx });
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
this.log.error('Wildcard handler error', { event, error: String(err) });
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (opcode === OP_BINARY) {
|
|
1254
|
+
record.info.messagesReceived++;
|
|
1255
|
+
this._totalMessagesReceived++;
|
|
1256
|
+
|
|
1257
|
+
if (payload.length > this.maxPayloadSize) {
|
|
1258
|
+
if (this.hooks.onPayloadTooLarge) {
|
|
1259
|
+
this.hooks.onPayloadTooLarge({ clientId: record.info.id, size: payload.length, max: this.maxPayloadSize });
|
|
1260
|
+
}
|
|
1261
|
+
this.log.warn('Binary payload too large', { clientId: record.info.id, size: payload.length });
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
let headerEnd = -1;
|
|
1267
|
+
for (let i = 0; i < payload.length; i++) {
|
|
1268
|
+
if (payload[i] === 0) { headerEnd = i; break; }
|
|
1269
|
+
}
|
|
1270
|
+
if (headerEnd === -1) return;
|
|
1271
|
+
|
|
1272
|
+
const headerStr = payload.subarray(0, headerEnd).toString('utf8');
|
|
1273
|
+
const header = JSON.parse(headerStr);
|
|
1274
|
+
const buffer = payload.subarray(headerEnd + 1);
|
|
1275
|
+
|
|
1276
|
+
if (header.event && !this._checkRateLimit(record.info.id, header.event)) {
|
|
1277
|
+
this.log.warn('Binary event rate limit exceeded', { clientId: record.info.id, event: header.event });
|
|
1278
|
+
if (this.hooks.onRateLimitExceeded) {
|
|
1279
|
+
const result = this.hooks.onRateLimitExceeded({ clientId: record.info.id, event: header.event, protocol: 'ws' });
|
|
1280
|
+
if (result === false) return;
|
|
1281
|
+
}
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const eventCtx: StelarContext = { ...ctx, data: buffer, buffer, isBinary: true, event: header.event };
|
|
1286
|
+
const handler = this.events.get(header.event);
|
|
1287
|
+
if (handler) {
|
|
1288
|
+
try { handler(eventCtx); } catch (err) { this.log.error('Binary handler error', { error: String(err) }); }
|
|
1289
|
+
}
|
|
1290
|
+
if (this._wildcardHandler) {
|
|
1291
|
+
try { this._wildcardHandler({ event: header.event, data: eventCtx }); } catch (err) { this.log.error('Wildcard handler error', { error: String(err) }); }
|
|
1292
|
+
}
|
|
1293
|
+
} catch {
|
|
1294
|
+
if (this.hooks.onInvalidMessage) {
|
|
1295
|
+
this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid binary frame', protocol: 'ws' });
|
|
1296
|
+
}
|
|
1297
|
+
this.log.warn('Invalid binary frame from client', { clientId: record.info.id });
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
private handleTCPConnection(socket: NetSocket): void {
|
|
1303
|
+
const record = this._registerClient(socket, 'tcp', null, new FrameParser(this.maxFrameSize));
|
|
1304
|
+
if (!record) return;
|
|
1305
|
+
|
|
1306
|
+
const ctx = this._buildCtx(record, null);
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
socket.write(encodeConnectFrame(record.info.id));
|
|
1310
|
+
} catch {
|
|
1311
|
+
socket.destroy();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (this.hooks.onClientConnect) {
|
|
1316
|
+
this.hooks.onClientConnect({
|
|
1317
|
+
clientId: record.info.id,
|
|
1318
|
+
ip: record.info.remoteAddress,
|
|
1319
|
+
protocol: 'tcp',
|
|
1320
|
+
metadata: record.info.metadata,
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
this.runMiddlewares(ctx, () => {
|
|
1325
|
+
if (this._connectionHandler) {
|
|
1326
|
+
try { this._connectionHandler(ctx); } catch (err) { this.log.error('TCP connection handler error', { error: String(err) }); }
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
this.log.info('TCP client connected', { clientId: record.info.id, ip: record.info.remoteAddress });
|
|
1331
|
+
|
|
1332
|
+
socket.on('data', (data: Buffer) => {
|
|
1333
|
+
this._processTCPData(record, data, ctx);
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
socket.on('close', () => {
|
|
1337
|
+
this.log.debug('TCP client socket closed', { clientId: record.info.id });
|
|
1338
|
+
this._unregisterClient(record, ctx);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
socket.on('error', (err: Error) => {
|
|
1342
|
+
this.log.warn('TCP client error', { clientId: record.info.id, error: err.message });
|
|
1343
|
+
this._handleError(record, ctx, err);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
socket.on('drain', () => {
|
|
1347
|
+
socket.resume();
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
private _processTCPData(record: ClientRecord, data: Buffer, ctx: StelarContext): void {
|
|
1352
|
+
let frames: ParsedFrame[];
|
|
1353
|
+
try {
|
|
1354
|
+
frames = (record.parser as FrameParser).feed(data);
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
if (err instanceof ProtocolError) {
|
|
1357
|
+
this.log.warn('TCP protocol error', { clientId: record.info.id, code: err.code, message: err.message });
|
|
1358
|
+
try {
|
|
1359
|
+
record.socket.write(encodeErrorFrame(err.message));
|
|
1360
|
+
} catch { /* ignore */ }
|
|
1361
|
+
}
|
|
1362
|
+
record.socket.destroy();
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
for (const frame of frames) {
|
|
1367
|
+
if (record.socket.destroyed) break;
|
|
1368
|
+
this._handleTCPFrame(record, frame, ctx);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
private _handleTCPFrame(record: ClientRecord, frame: ParsedFrame, ctx: StelarContext): void {
|
|
1373
|
+
const { type, event, payload } = frame;
|
|
1374
|
+
|
|
1375
|
+
if (type === FRAME_PING) {
|
|
1376
|
+
try { record.socket.write(encodePongFrame()); } catch { /* ignore */ }
|
|
1377
|
+
record.info.lastPing = Date.now();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (type === FRAME_PONG) {
|
|
1382
|
+
record.info.lastPing = Date.now();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (!this._checkRateLimit(record.info.id, event)) {
|
|
1387
|
+
this.log.warn('TCP rate limit exceeded', { clientId: record.info.id, event });
|
|
1388
|
+
|
|
1389
|
+
if (this.hooks.onRateLimitExceeded) {
|
|
1390
|
+
const result = this.hooks.onRateLimitExceeded({
|
|
1391
|
+
clientId: record.info.id,
|
|
1392
|
+
event: event || undefined,
|
|
1393
|
+
protocol: 'tcp',
|
|
1394
|
+
});
|
|
1395
|
+
if (result === false) return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
try { record.socket.write(encodeErrorFrame('Rate limit exceeded')); } catch { /* ignore */ }
|
|
1399
|
+
record.socket.destroy();
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (type === FRAME_JOIN) {
|
|
1404
|
+
const room = payload.toString('utf8');
|
|
1405
|
+
if (room) this._joinRoom(record, room);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (type === FRAME_LEAVE) {
|
|
1410
|
+
const room = payload.toString('utf8');
|
|
1411
|
+
if (room) this._leaveRoom(record, room);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (type === FRAME_CONNECT) {
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (payload.length > this.maxPayloadSize) {
|
|
1420
|
+
if (this.hooks.onPayloadTooLarge) {
|
|
1421
|
+
this.hooks.onPayloadTooLarge({ clientId: record.info.id, event, size: payload.length, max: this.maxPayloadSize });
|
|
1422
|
+
}
|
|
1423
|
+
this.log.warn('TCP payload too large', { clientId: record.info.id, size: payload.length });
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
record.info.messagesReceived++;
|
|
1428
|
+
this._totalMessagesReceived++;
|
|
1429
|
+
|
|
1430
|
+
if (type === FRAME_JSON) {
|
|
1431
|
+
let data: unknown;
|
|
1432
|
+
try {
|
|
1433
|
+
data = JSON.parse(payload.toString('utf8'));
|
|
1434
|
+
} catch {
|
|
1435
|
+
if (this.hooks.onInvalidMessage) {
|
|
1436
|
+
this.hooks.onInvalidMessage({ clientId: record.info.id, reason: 'Invalid JSON', protocol: 'tcp' });
|
|
1437
|
+
}
|
|
1438
|
+
this.log.warn('Invalid TCP JSON', { clientId: record.info.id });
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const eventCtx: StelarContext = { ...ctx, data, event };
|
|
1443
|
+
const handler = this.events.get(event);
|
|
1444
|
+
if (handler) {
|
|
1445
|
+
try { handler(eventCtx); } catch (err) { this.log.error('TCP event handler error', { event, error: String(err) }); }
|
|
1446
|
+
}
|
|
1447
|
+
if (this._wildcardHandler) {
|
|
1448
|
+
try { this._wildcardHandler({ event, data: eventCtx }); } catch (err) { this.log.error('TCP wildcard handler error', { error: String(err) }); }
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (type === FRAME_ACK_REQ) {
|
|
1454
|
+
if (this._acks.has(event)) {
|
|
1455
|
+
try {
|
|
1456
|
+
const parsed = JSON.parse(payload.toString('utf8'));
|
|
1457
|
+
const data = parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed;
|
|
1458
|
+
const ackHandler = this._acks.get(event)!;
|
|
1459
|
+
const result = ackHandler({ ...ctx, data });
|
|
1460
|
+
if (result !== undefined) {
|
|
1461
|
+
record.socket.write(encodeAckResFrame(event, result, this.maxFrameSize));
|
|
1462
|
+
this._totalMessagesSent++;
|
|
1463
|
+
}
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
this.log.error('TCP ACK handler error', { event, error: String(err) });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (type === FRAME_ACK_RES) {
|
|
1472
|
+
if (this._acks.has(event)) {
|
|
1473
|
+
try {
|
|
1474
|
+
const data = JSON.parse(payload.toString('utf8'));
|
|
1475
|
+
const ackHandler = this._acks.get(event)!;
|
|
1476
|
+
ackHandler({ ...ctx, data });
|
|
1477
|
+
} catch { /* ignore */ }
|
|
1478
|
+
}
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (type === FRAME_BINARY) {
|
|
1483
|
+
const eventCtx: StelarContext = { ...ctx, data: payload, buffer: payload, isBinary: true, event };
|
|
1484
|
+
const handler = this.events.get(event);
|
|
1485
|
+
if (handler) {
|
|
1486
|
+
try { handler(eventCtx); } catch (err) { this.log.error('TCP binary handler error', { event, error: String(err) }); }
|
|
1487
|
+
}
|
|
1488
|
+
if (this._wildcardHandler) {
|
|
1489
|
+
try { this._wildcardHandler({ event, data: eventCtx }); } catch (err) { this.log.error('TCP wildcard handler error', { error: String(err) }); }
|
|
1490
|
+
}
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
private _handleError(record: ClientRecord, ctx: StelarContext, err: Error): void {
|
|
1496
|
+
if (this.events.has('error')) {
|
|
1497
|
+
const handler = this.events.get('error')!;
|
|
1498
|
+
try {
|
|
1499
|
+
handler({ ...ctx, error: err, event: 'error' });
|
|
1500
|
+
} catch (handlerErr) {
|
|
1501
|
+
this.log.error('Error handler threw', { error: String(handlerErr) });
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
private _handleHealthCheck(req: IncomingMessage, res: ServerResponse): void {
|
|
1507
|
+
if (this._customHealthHandler) {
|
|
1508
|
+
const stats = this.getStats();
|
|
1509
|
+
try {
|
|
1510
|
+
this._customHealthHandler(req, res, stats);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
this.log.error('Custom health handler error', { error: String(err) });
|
|
1513
|
+
if (!res.headersSent) {
|
|
1514
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1515
|
+
res.end(JSON.stringify({ status: 'error', message: 'Health check handler failed' }));
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const origin = req.headers['origin'];
|
|
1522
|
+
if (origin && (!this.allowedOrigins || this.allowedOrigins.includes(origin))) {
|
|
1523
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1524
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
1525
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (req.method === 'OPTIONS') {
|
|
1529
|
+
res.writeHead(204);
|
|
1530
|
+
res.end();
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (this.healthEndpoint && req.url === this.healthEndpoint && req.method === 'GET') {
|
|
1535
|
+
const stats = this.getStats();
|
|
1536
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1537
|
+
res.end(JSON.stringify({
|
|
1538
|
+
status: 'ok',
|
|
1539
|
+
...stats,
|
|
1540
|
+
uptimeSeconds: Math.floor(stats.uptime / 1000),
|
|
1541
|
+
memoryMB: Math.round(stats.memoryUsage.heapUsed / 1024 / 1024 * 100) / 100,
|
|
1542
|
+
}));
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
1547
|
+
res.end('Stelar Time Real v3 Server');
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
private _shutdownCallbacks: Array<(signal: string, force: boolean) => void> = [];
|
|
1551
|
+
|
|
1552
|
+
/** Register a callback for when graceful shutdown completes. */
|
|
1553
|
+
onShutdown(callback: (signal: string, force: boolean) => void): this {
|
|
1554
|
+
this._shutdownCallbacks.push(callback);
|
|
1555
|
+
return this;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
private _emitShutdown(signal: string, force: boolean): void {
|
|
1559
|
+
for (const cb of this._shutdownCallbacks) {
|
|
1560
|
+
try { cb(signal, force); } catch { /* ignore */ }
|
|
1561
|
+
}
|
|
1562
|
+
if (this._shutdownCallbacks.length === 0) {
|
|
1563
|
+
process.exit(force ? 1 : 0);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
private _setupGracefulShutdown(): void {
|
|
1568
|
+
if (!this.doGracefulShutdown) return;
|
|
1569
|
+
|
|
1570
|
+
let isShuttingDown = false;
|
|
1571
|
+
|
|
1572
|
+
const shutdown = (signal: string) => {
|
|
1573
|
+
if (isShuttingDown) return;
|
|
1574
|
+
isShuttingDown = true;
|
|
1575
|
+
this._shuttingDown = true;
|
|
1576
|
+
|
|
1577
|
+
this.log.info(`Received ${signal}, shutting down gracefully...`);
|
|
1578
|
+
|
|
1579
|
+
this.stop();
|
|
1580
|
+
|
|
1581
|
+
const clientCount = this.clients.size;
|
|
1582
|
+
if (clientCount === 0) {
|
|
1583
|
+
this.log.info('No active connections, shutdown complete');
|
|
1584
|
+
this._emitShutdown(signal, false);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
this.log.info(`Waiting for ${clientCount} connections to close (timeout: ${this.shutdownTimeout}ms)`);
|
|
1589
|
+
|
|
1590
|
+
this.clients.forEach((record) => {
|
|
1591
|
+
try {
|
|
1592
|
+
if (record.protocol === 'ws') {
|
|
1593
|
+
record.socket.write(createWSCloseFrame(CLOSE_GOING_AWAY, 'Server shutting down'));
|
|
1594
|
+
} else {
|
|
1595
|
+
record.socket.write(encodeDisconnectFrame());
|
|
1596
|
+
}
|
|
1597
|
+
record.socket.end();
|
|
1598
|
+
} catch { /* ignore */ }
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
const forceTimeout = setTimeout(() => {
|
|
1602
|
+
this.log.warn('Shutdown timeout reached, force closing remaining connections');
|
|
1603
|
+
this.clients.forEach((record) => {
|
|
1604
|
+
try { record.socket.destroy(); } catch { /* ignore */ }
|
|
1605
|
+
});
|
|
1606
|
+
this.clients.clear();
|
|
1607
|
+
this.clientsById.clear();
|
|
1608
|
+
this._emitShutdown(signal, true);
|
|
1609
|
+
}, this.shutdownTimeout);
|
|
1610
|
+
forceTimeout.unref();
|
|
1611
|
+
|
|
1612
|
+
const checkInterval = setInterval(() => {
|
|
1613
|
+
if (this.clients.size === 0) {
|
|
1614
|
+
clearInterval(checkInterval);
|
|
1615
|
+
clearTimeout(forceTimeout);
|
|
1616
|
+
this.log.info('All connections closed, shutdown complete');
|
|
1617
|
+
this._emitShutdown(signal, false);
|
|
1618
|
+
}
|
|
1619
|
+
}, 100);
|
|
1620
|
+
checkInterval.unref();
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
this._sigintHandler = () => shutdown('SIGINT');
|
|
1624
|
+
this._sigtermHandler = () => shutdown('SIGTERM');
|
|
1625
|
+
process.on('SIGINT', this._sigintHandler);
|
|
1626
|
+
process.on('SIGTERM', this._sigtermHandler);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
private _removeSignalHandlers(): void {
|
|
1630
|
+
if (this._sigintHandler) {
|
|
1631
|
+
process.off('SIGINT', this._sigintHandler);
|
|
1632
|
+
this._sigintHandler = null;
|
|
1633
|
+
}
|
|
1634
|
+
if (this._sigtermHandler) {
|
|
1635
|
+
process.off('SIGTERM', this._sigtermHandler);
|
|
1636
|
+
this._sigtermHandler = null;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
start(callback?: (port: number) => void): Promise<number> {
|
|
1641
|
+
if (this._started) {
|
|
1642
|
+
const port = this.getPort();
|
|
1643
|
+
if (callback) callback(port);
|
|
1644
|
+
return Promise.resolve(port);
|
|
1645
|
+
}
|
|
1646
|
+
this._started = true;
|
|
1647
|
+
this._startTime = Date.now();
|
|
1648
|
+
|
|
1649
|
+
return new Promise((resolve) => {
|
|
1650
|
+
const startHttpServer = (httpServer: HttpServer): void => {
|
|
1651
|
+
this.httpServer = httpServer;
|
|
1652
|
+
|
|
1653
|
+
this._requestHandler = (req: IncomingMessage, res: ServerResponse) => {
|
|
1654
|
+
this._handleHealthCheck(req, res);
|
|
1655
|
+
};
|
|
1656
|
+
httpServer.on('request', this._requestHandler);
|
|
1657
|
+
|
|
1658
|
+
this._upgradeHandler = (req: IncomingMessage, socket: NetSocket, head: Buffer) => {
|
|
1659
|
+
this.handleWSUpgrade(req, socket, head);
|
|
1660
|
+
};
|
|
1661
|
+
httpServer.on('upgrade', this._upgradeHandler);
|
|
1662
|
+
|
|
1663
|
+
this.startHeartbeat();
|
|
1664
|
+
|
|
1665
|
+
this._rateCleanupTimer = setInterval(() => {
|
|
1666
|
+
if (this._customRateLimiter) {
|
|
1667
|
+
this._customRateLimiter.cleanup();
|
|
1668
|
+
} else if (this.rateLimiter) {
|
|
1669
|
+
this.rateLimiter.cleanup();
|
|
1670
|
+
}
|
|
1671
|
+
const ipTracker = this._customIPTracker || this.ipTracker;
|
|
1672
|
+
ipTracker.cleanup();
|
|
1673
|
+
for (const [clientId, limiter] of this._clientRateOverrides) {
|
|
1674
|
+
limiter.cleanup();
|
|
1675
|
+
if (!this.clientsById.has(clientId)) {
|
|
1676
|
+
this._clientRateOverrides.delete(clientId);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
for (const [, limiter] of this.eventRateLimiters) {
|
|
1680
|
+
limiter.cleanup();
|
|
1681
|
+
}
|
|
1682
|
+
}, 30000);
|
|
1683
|
+
if (this._rateCleanupTimer && typeof this._rateCleanupTimer === 'object' && 'unref' in this._rateCleanupTimer) {
|
|
1684
|
+
this._rateCleanupTimer.unref();
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
this._setupGracefulShutdown();
|
|
1688
|
+
|
|
1689
|
+
const finalPort = this.getPort();
|
|
1690
|
+
this.log.info('Server started', { port: finalPort, namespace: this.namespace, tls: !!this.tlsOptions });
|
|
1691
|
+
if (callback) callback(finalPort);
|
|
1692
|
+
resolve(finalPort);
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
if (this.httpServer) {
|
|
1696
|
+
this._externalServers.add(this.httpServer);
|
|
1697
|
+
startHttpServer(this.httpServer);
|
|
1698
|
+
} else {
|
|
1699
|
+
const tryListen = (port: number): void => {
|
|
1700
|
+
const httpServer = this.tlsOptions
|
|
1701
|
+
? createHttpServer()
|
|
1702
|
+
: createHttpServer();
|
|
1703
|
+
|
|
1704
|
+
httpServer.on('error', (err: NodeJS.ErrnoException) => {
|
|
1705
|
+
if (err.code === 'EADDRINUSE' && port < 65535) {
|
|
1706
|
+
tryListen(port + 1);
|
|
1707
|
+
} else {
|
|
1708
|
+
this.log.error('HTTP server error', { error: err.message });
|
|
1709
|
+
}
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
httpServer.listen(port, () => {
|
|
1713
|
+
this.port = port;
|
|
1714
|
+
startHttpServer(httpServer);
|
|
1715
|
+
});
|
|
1716
|
+
};
|
|
1717
|
+
tryListen(this.port);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (this.tcpPort !== false) {
|
|
1721
|
+
const tcpPortNum = typeof this.tcpPort === 'number' ? this.tcpPort : this.port + 1;
|
|
1722
|
+
this._startTCPServer(tcpPortNum);
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
private _startTCPServer(port: number, attempts = 0): void {
|
|
1728
|
+
const tcpHandler = (socket: NetSocket) => this.handleTCPConnection(socket);
|
|
1729
|
+
|
|
1730
|
+
if (this.tlsOptions) {
|
|
1731
|
+
try {
|
|
1732
|
+
const tlsServer = createTlsServer(this.tlsOptions, tcpHandler);
|
|
1733
|
+
this.tcpServer = tlsServer as unknown as TcpServer;
|
|
1734
|
+
|
|
1735
|
+
this.tcpServer.on('error', (err: NodeJS.ErrnoException) => {
|
|
1736
|
+
if (err.code === 'EADDRINUSE' && attempts < 10) {
|
|
1737
|
+
this.log.info(`TLS TCP port ${port} in use, trying ${port + 1}`);
|
|
1738
|
+
this.tcpServer = null;
|
|
1739
|
+
this._startTCPServer(port + 1, attempts + 1);
|
|
1740
|
+
} else {
|
|
1741
|
+
this.log.error('TLS TCP server error', { error: err.message });
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
this.tcpServer.listen(port, () => {
|
|
1746
|
+
this.log.info('TLS TCP server started', { port });
|
|
1747
|
+
});
|
|
1748
|
+
} catch (err) {
|
|
1749
|
+
this.log.error('Failed to create TLS TCP server', { error: String(err) });
|
|
1750
|
+
this._startPlainTCPServer(port, attempts, tcpHandler);
|
|
1751
|
+
}
|
|
1752
|
+
} else {
|
|
1753
|
+
this._startPlainTCPServer(port, attempts, tcpHandler);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
private _startPlainTCPServer(port: number, attempts: number, tcpHandler: (socket: NetSocket) => void): void {
|
|
1758
|
+
this.tcpServer = createTcpServer(tcpHandler);
|
|
1759
|
+
|
|
1760
|
+
this.tcpServer.on('error', (err: NodeJS.ErrnoException) => {
|
|
1761
|
+
if (err.code === 'EADDRINUSE' && attempts < 10) {
|
|
1762
|
+
this.log.info(`TCP port ${port} in use, trying ${port + 1}`);
|
|
1763
|
+
this.tcpServer = null;
|
|
1764
|
+
this._startTCPServer(port + 1, attempts + 1);
|
|
1765
|
+
} else {
|
|
1766
|
+
this.log.error('TCP server error', { error: err.message });
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
this.tcpServer.listen(port, () => {
|
|
1771
|
+
this.log.info('TCP server started', { port });
|
|
1772
|
+
});
|
|
347
1773
|
}
|
|
348
1774
|
|
|
349
1775
|
stop(): this {
|
|
350
|
-
if (this._hbTimer) clearInterval(this._hbTimer);
|
|
351
|
-
if (this.
|
|
352
|
-
|
|
1776
|
+
if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null; }
|
|
1777
|
+
if (this._rateCleanupTimer) { clearInterval(this._rateCleanupTimer); this._rateCleanupTimer = null; }
|
|
1778
|
+
|
|
1779
|
+
this.clients.forEach((record) => {
|
|
1780
|
+
if (!record.socket.destroyed) {
|
|
1781
|
+
record.socket.destroy();
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
this.clients.clear();
|
|
1785
|
+
this.clientsById.clear();
|
|
1786
|
+
this.rooms.clear();
|
|
1787
|
+
this._clientRateOverrides.clear();
|
|
1788
|
+
|
|
1789
|
+
if (this.httpServer) {
|
|
1790
|
+
if (this._upgradeHandler) {
|
|
1791
|
+
this.httpServer.off('upgrade', this._upgradeHandler);
|
|
1792
|
+
this._upgradeHandler = null;
|
|
1793
|
+
}
|
|
1794
|
+
if (this._requestHandler) {
|
|
1795
|
+
this.httpServer.off('request', this._requestHandler);
|
|
1796
|
+
this._requestHandler = null;
|
|
1797
|
+
}
|
|
1798
|
+
if (!this._externalServers.has(this.httpServer)) {
|
|
1799
|
+
this.httpServer.close();
|
|
1800
|
+
}
|
|
1801
|
+
this.httpServer = null;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
if (this.tcpServer) {
|
|
1805
|
+
this.tcpServer.close();
|
|
1806
|
+
this.tcpServer = null;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
this._started = false;
|
|
1810
|
+
this._removeSignalHandlers();
|
|
1811
|
+
this.log.info('Server stopped');
|
|
353
1812
|
return this;
|
|
354
1813
|
}
|
|
355
1814
|
}
|
|
356
1815
|
|
|
357
1816
|
export default StelarServer;
|
|
358
1817
|
export { StelarServer };
|
|
359
|
-
export { default as StelarClient } from './client.js';
|
|
1818
|
+
export { default as StelarClient } from './client.js';
|
|
1819
|
+
export { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
|
|
1820
|
+
export { ProtocolError, validateEventName, DEFAULT_MAX_FRAME_SIZE, MAX_EVENT_LENGTH, HEADER_SIZE } from './protocol.js';
|
|
1821
|
+
export { WebSocketError, DEFAULT_MAX_WS_FRAME_SIZE, CLOSE_NORMAL, CLOSE_GOING_AWAY, CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_INVALID_PAYLOAD, CLOSE_UNSUPPORTED } from './websocket.js';
|