stelar-time-real 3.3.0 → 3.3.2

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/src/index.ts CHANGED
@@ -1,6 +1,4 @@
1
- /**
2
- * @stelar-time-real Server — Dual-protocol: WebSocket (RFC 6455) + binary TCP
3
- */
1
+ /** @stelar-time-real Server — Dual-protocol: WebSocket (RFC 6455) + binary TCP */
4
2
 
5
3
  import { createServer as createHttp, IncomingMessage, Server as HttpServer, ServerResponse } from 'http';
6
4
  import { createServer as createTcp, Server as TcpServer, Socket as NetSocket } from 'net';
@@ -10,7 +8,7 @@ import { createServer as createTls, TlsOptions } from 'tls';
10
8
  import {
11
9
  FrameParser, ParsedFrame, encodeJsonFrame, encodeBinaryFrame, encodePingFrame, encodePongFrame,
12
10
  encodeAckResFrame, encodeConnectFrame, encodeDisconnectFrame, encodeJoinFrame, encodeLeaveFrame,
13
- encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ, FRAME_ACK_RES,
11
+ encodeErrorFrame, FRAME_JSON, FRAME_BINARY, FRAME_PING, FRAME_PONG, FRAME_ACK_REQ,
14
12
  FRAME_JOIN, FRAME_LEAVE, FRAME_CONNECT, ProtocolError, DEFAULT_MAX_FRAME_SIZE,
15
13
  } from './protocol.js';
16
14
 
@@ -19,7 +17,7 @@ import {
19
17
  createWSBinaryFrame, createWSCloseFrame, createWSPingFrame, createWSPongFrame,
20
18
  OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG, WebSocketError,
21
19
  CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION, CLOSE_MESSAGE_TOO_BIG, CLOSE_NORMAL, CLOSE_GOING_AWAY,
22
- DEFAULT_MAX_WS_FRAME_SIZE,
20
+ DEFAULT_MAX_WS_FRAME_SIZE, clientWantsCompression,
23
21
  } from './websocket.js';
24
22
 
25
23
  import { Logger, NULL_LOGGER, type LogLevel } from './logger.js';
@@ -56,6 +54,7 @@ export interface StelarOptions {
56
54
  customRateLimiter?: IRateLimiter; customIPTracker?: IIPTracker;
57
55
  generateClientId?: () => string; eventRateLimits?: EventRateLimits;
58
56
  hooks?: StelarHooks; customHealthHandler?: (req: IncomingMessage, res: ServerResponse, stats: StelarStats) => void;
57
+ compression?: boolean;
59
58
  }
60
59
 
61
60
  export interface StelarClientInfo {
@@ -92,9 +91,35 @@ export interface StelarStats {
92
91
  tcpConnections: number; memoryUsage: NodeJS.MemoryUsage; rateLimiterEntries: number;
93
92
  }
94
93
 
95
- /* ── Internal helpers ── */
94
+ /* ── Internal ── */
96
95
 
97
- interface ClientRecord { info: StelarClientInfo; socket: NetSocket; parser: WSFrameParser | FrameParser; protocol: 'ws' | 'tcp'; }
96
+ interface ClientRecord {
97
+ info: StelarClientInfo; socket: NetSocket; parser: WSFrameParser | FrameParser;
98
+ protocol: 'ws' | 'tcp'; compress: boolean;
99
+ _hbTimer: ReturnType<typeof setInterval> | null;
100
+ _writePaused: boolean; _writeQueue: Buffer[];
101
+ }
102
+
103
+ /** WS binary framing: [4B headerLen BE][header JSON][binary payload] — length-prefixed, not null-delimited */
104
+ function encodeWSBinary(event: string, data: Uint8Array | Buffer): Buffer {
105
+ const hdr = Buffer.from(JSON.stringify({ event, _binary: true }), 'utf8');
106
+ const payload = Buffer.from(data);
107
+ const frame = Buffer.alloc(4 + hdr.length + payload.length);
108
+ frame.writeUInt32BE(hdr.length, 0);
109
+ hdr.copy(frame, 4);
110
+ payload.copy(frame, 4 + hdr.length);
111
+ return frame;
112
+ }
113
+
114
+ function parseWSBinary(payload: Buffer): { event: string; buffer: Buffer } | null {
115
+ if (payload.length < 4) return null;
116
+ const hdrLen = payload.readUInt32BE(0);
117
+ if (hdrLen > payload.length - 4) return null;
118
+ try {
119
+ const hdr = JSON.parse(payload.subarray(4, 4 + hdrLen).toString('utf8'));
120
+ return { event: hdr.event, buffer: payload.subarray(4 + hdrLen) };
121
+ } catch { return null; }
122
+ }
98
123
 
99
124
  class RateLimiter implements IRateLimiter {
100
125
  private limits = new Map<string, { count: number; resetTime: number }>();
@@ -149,13 +174,13 @@ class StelarServer {
149
174
  private hooks: StelarHooks;
150
175
  private evRateLimits = new Map<string, RateLimiter>();
151
176
  private clientRates = new Map<string, RateLimiter>();
177
+ private doCompress: boolean;
152
178
 
153
179
  private clients = new Map<NetSocket, ClientRecord>();
154
180
  private byId = new Map<string, ClientRecord>();
155
181
  private rooms = new Map<string, Set<string>>();
156
182
  private events = new Map<string, StelarEventHandler>();
157
183
  private mw: StelarMiddleware[] = [];
158
- private _hb: ReturnType<typeof setInterval> | null = null;
159
184
  private _rc: ReturnType<typeof setInterval> | null = null;
160
185
  private _wild: StelarWildcardHandler | null = null;
161
186
  private _connH: StelarEventHandler | null = null;
@@ -199,6 +224,7 @@ class StelarServer {
199
224
  this._genId = o.generateClientId || null;
200
225
  this._healthFn = o.customHealthHandler || null;
201
226
  this.hooks = o.hooks || {};
227
+ this.doCompress = o.compression || false;
202
228
  if (o.eventRateLimits) for (const [ev, c] of Object.entries(o.eventRateLimits)) this.evRateLimits.set(ev, new RateLimiter(c.maxPoints, c.windowMs));
203
229
  const rl = o.rateLimit && typeof o.rateLimit === 'object' ? o.rateLimit : {};
204
230
  this.rateLimiter = o.rateLimit === false && !this._crl ? null : this._crl ? null : new RateLimiter(rl.maxPoints || 100, rl.windowMs || 1000);
@@ -219,6 +245,7 @@ class StelarServer {
219
245
  if (o.heartbeatInterval !== undefined) this.hbInterval = o.heartbeatInterval;
220
246
  if (o.heartbeatTimeout !== undefined) this.hbTimeout = o.heartbeatTimeout;
221
247
  if (o.allowedOrigins !== undefined) this.origins = o.allowedOrigins;
248
+ if (o.compression !== undefined) this.doCompress = o.compression;
222
249
  if (o.rateLimit === false) { this.rateLimiter = null; this._crl = null; }
223
250
  else if (o.rateLimit && !this._crl) this.rateLimiter = new RateLimiter(o.rateLimit.maxPoints || 100, o.rateLimit.windowMs || 1000);
224
251
  if (o.customRateLimiter !== undefined) { this._crl = o.customRateLimiter; this.rateLimiter = null; }
@@ -241,7 +268,8 @@ class StelarServer {
241
268
  maxConnections: this.maxConns, maxConnectionsPerIP: this._cit ? -1 : 50,
242
269
  maxRooms: this.maxRooms, maxRoomsPerClient: this.maxRoomsPerClient, maxPayloadSize: this.maxPayload,
243
270
  heartbeatInterval: this.hbInterval, heartbeatTimeout: this.hbTimeout, connectTimeout: this.connTimeout,
244
- shutdownTimeout: this.shutdownMs, hasCustomRateLimiter: this._crl !== null, hasCustomIPTracker: this._cit !== null,
271
+ shutdownTimeout: this.shutdownMs, compression: this.doCompress,
272
+ hasCustomRateLimiter: this._crl !== null, hasCustomIPTracker: this._cit !== null,
245
273
  hasCustomClientIdGenerator: this._genId !== null, hasCustomHealthHandler: this._healthFn !== null,
246
274
  eventRateLimits: Array.from(this.evRateLimits.keys()), hooks: Object.keys(this.hooks), allowedOrigins: this.origins,
247
275
  });
@@ -260,23 +288,30 @@ class StelarServer {
260
288
 
261
289
  broadcast(event: string, data: unknown, excludeId?: string): this {
262
290
  if (this.hooks.onBeforeBroadcast?.({ event, data, excludeId }) === false) return this;
263
- const wsF = createWSTextFrame(JSON.stringify({ event, data }));
291
+ const json = JSON.stringify({ event, data });
292
+ const wsF = createWSTextFrame(json);
293
+ const wsFC = this.doCompress ? createWSTextFrame(json, true) : wsF;
264
294
  const tcpF = encodeJsonFrame(event, data, this.maxFrame);
265
295
  let sent = 0;
266
- this.clients.forEach(r => { if (excludeId && r.info.id === excludeId) return; if (this._write(r, wsF, tcpF)) sent++; });
296
+ this.clients.forEach(r => { if (excludeId && r.info.id === excludeId) return; if (this._write(r, r.compress ? wsFC : wsF, tcpF)) sent++; });
267
297
  this._totalSent += sent;
268
298
  return this;
269
299
  }
270
300
 
271
- broadcastBinary(event: string, buf: ArrayBuffer) { this.clients.forEach(r => this._sendBin(r, event, buf)); }
301
+ broadcastBinary(event: string, buf: ArrayBuffer) {
302
+ const safeCopy = Buffer.from(new Uint8Array(buf));
303
+ this.clients.forEach(r => this._sendBin(r, event, safeCopy));
304
+ }
272
305
 
273
306
  to(room: string, event: string, data: unknown, excludeId?: string): this {
274
307
  const ids = this.rooms.get(room);
275
308
  if (!ids) return this;
276
- const wsF = createWSTextFrame(JSON.stringify({ event, data }));
309
+ const json = JSON.stringify({ event, data });
310
+ const wsF = createWSTextFrame(json);
311
+ const wsFC = this.doCompress ? createWSTextFrame(json, true) : wsF;
277
312
  const tcpF = encodeJsonFrame(event, data, this.maxFrame);
278
313
  let sent = 0;
279
- for (const id of ids) { if (excludeId && id === excludeId) continue; const r = this.byId.get(id); if (r && this._write(r, wsF, tcpF)) sent++; }
314
+ for (const id of ids) { if (excludeId && id === excludeId) continue; const r = this.byId.get(id); if (r && this._write(r, r.compress ? wsFC : wsF, tcpF)) sent++; }
280
315
  this._totalSent += sent;
281
316
  return this;
282
317
  }
@@ -311,36 +346,60 @@ class StelarServer {
311
346
 
312
347
  onShutdown(cb: (sig: string, force: boolean) => void) { this._shutdownCbs.push(cb); return this; }
313
348
 
314
- /* ── Private: send helpers ── */
349
+ /* ── Private: backpressure-aware write ── */
315
350
 
316
- private _sendJson(r: ClientRecord, event: string, data: unknown): boolean {
351
+ private _write(r: ClientRecord, wsF: Buffer, tcpF: Buffer): boolean {
317
352
  if (r.socket.destroyed || r.socket.writableEnded) return false;
353
+ if (r._writePaused) { r._writeQueue.push(r.protocol === 'ws' ? wsF : tcpF); return true; }
318
354
  try {
319
- r.socket.write(r.protocol === 'ws' ? createWSTextFrame(JSON.stringify({ event, data })) : encodeJsonFrame(event, data, this.maxFrame));
355
+ const ok = r.socket.write(r.protocol === 'ws' ? wsF : tcpF);
356
+ if (!ok) r._writePaused = true;
320
357
  r.info.messagesSent++; return true;
321
358
  } catch { return false; }
322
359
  }
323
360
 
324
- private _write(r: ClientRecord, wsF: Buffer, tcpF: Buffer): boolean {
361
+ private _sendJson(r: ClientRecord, event: string, data: unknown): boolean {
325
362
  if (r.socket.destroyed || r.socket.writableEnded) return false;
326
- try { r.socket.write(r.protocol === 'ws' ? wsF : tcpF); r.info.messagesSent++; return true; } catch { return false; }
363
+ try {
364
+ const frame = r.protocol === 'ws' ? createWSTextFrame(JSON.stringify({ event, data }), r.compress) : encodeJsonFrame(event, data, this.maxFrame);
365
+ if (r._writePaused) { r._writeQueue.push(frame); r.info.messagesSent++; return true; }
366
+ const ok = r.socket.write(frame);
367
+ if (!ok) r._writePaused = true;
368
+ r.info.messagesSent++; return true;
369
+ } catch { return false; }
327
370
  }
328
371
 
329
- private _sendBin(r: ClientRecord, event: string, buf: ArrayBuffer): boolean {
372
+ private _sendBin(r: ClientRecord, event: string, buf: Buffer): boolean {
330
373
  if (r.socket.destroyed || r.socket.writableEnded) return false;
331
374
  try {
332
375
  if (r.protocol === 'ws') {
333
- const hdr = Buffer.from(JSON.stringify({ event, _binary: true }), 'utf8');
334
- const combined = Buffer.alloc(hdr.length + 1 + buf.byteLength);
335
- hdr.copy(combined, 0); combined[hdr.length] = 0; combined.set(new Uint8Array(buf), hdr.length + 1);
336
- r.socket.write(createWSBinaryFrame(combined));
376
+ const frame = encodeWSBinary(event, buf);
377
+ if (r._writePaused) { r._writeQueue.push(createWSBinaryFrame(frame)); r.info.messagesSent++; return true; }
378
+ const ok = r.socket.write(createWSBinaryFrame(frame));
379
+ if (!ok) r._writePaused = true;
337
380
  } else {
338
- r.socket.write(encodeBinaryFrame(event, new Uint8Array(buf), this.maxFrame));
381
+ const frame = encodeBinaryFrame(event, buf, this.maxFrame);
382
+ if (r._writePaused) { r._writeQueue.push(frame); r.info.messagesSent++; return true; }
383
+ const ok = r.socket.write(frame);
384
+ if (!ok) r._writePaused = true;
339
385
  }
340
386
  r.info.messagesSent++; return true;
341
387
  } catch { return false; }
342
388
  }
343
389
 
390
+ private _flushQueue(r: ClientRecord) {
391
+ r._writePaused = false;
392
+ while (r._writeQueue.length) {
393
+ const buf = r._writeQueue.shift()!;
394
+ if (!r.socket.destroyed && !r.socket.writableEnded) {
395
+ try {
396
+ const ok = r.socket.write(buf);
397
+ if (!ok) { r._writePaused = true; break; }
398
+ } catch { break; }
399
+ }
400
+ }
401
+ }
402
+
344
403
  private _checkRate(cid: string, event?: string): boolean {
345
404
  const co = this.clientRates.get(cid);
346
405
  if (co) return co.check(cid);
@@ -355,9 +414,23 @@ class StelarServer {
355
414
  return socket.remoteAddress || 'unknown';
356
415
  }
357
416
 
417
+ /* ── Private: per-client heartbeat ── */
418
+
419
+ private _startClientHB(r: ClientRecord) {
420
+ r._hbTimer = setInterval(() => {
421
+ if (r.socket.destroyed) { this._stopClientHB(r); return; }
422
+ const now = Date.now();
423
+ if (now - r.info.lastPing > this.hbTimeout) { r.socket.destroy(); return; }
424
+ try { r.socket.write(r.protocol === 'ws' ? createWSPingFrame() : encodePingFrame()); } catch {}
425
+ }, this.hbInterval);
426
+ r._hbTimer.unref();
427
+ }
428
+
429
+ private _stopClientHB(r: ClientRecord) { if (r._hbTimer) { clearInterval(r._hbTimer); r._hbTimer = null; } }
430
+
358
431
  /* ── Private: client lifecycle ── */
359
432
 
360
- private _register(socket: NetSocket, proto: 'ws' | 'tcp', req: IncomingMessage | null, parser: WSFrameParser | FrameParser): ClientRecord | null {
433
+ private _register(socket: NetSocket, proto: 'ws' | 'tcp', req: IncomingMessage | null, parser: WSFrameParser | FrameParser, compress = false): ClientRecord | null {
361
434
  const ip = this._getIP(socket, req);
362
435
  if (this.clients.size >= this.maxConns) {
363
436
  this.hooks.onMaxConnectionsReached?.({ activeConnections: this.clients.size, max: this.maxConns, ip });
@@ -373,12 +446,13 @@ class StelarServer {
373
446
  }
374
447
  const id = this._genId ? this._genId() : randomUUID();
375
448
  const info: StelarClientInfo = { id, rooms: new Set(), lastPing: Date.now(), protocol: proto, connectedAt: Date.now(), metadata: new Map(), messagesReceived: 0, messagesSent: 0, remoteAddress: ip };
376
- const record: ClientRecord = { info, socket, parser, protocol: proto };
449
+ const record: ClientRecord = { info, socket, parser, protocol: proto, compress, _hbTimer: null, _writePaused: false, _writeQueue: [] };
377
450
  this.clients.set(socket, record); this.byId.set(id, record); tracker.add(ip); this._totalConns++;
378
451
  return record;
379
452
  }
380
453
 
381
454
  private _unregister(r: ClientRecord, ctx: StelarContext) {
455
+ this._stopClientHB(r);
382
456
  this.hooks.onClientDisconnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: r.info.protocol, rooms: new Set(r.info.rooms) });
383
457
  for (const room of r.info.rooms) { const m = this.rooms.get(room); if (m) { m.delete(r.info.id); if (!m.size) this.rooms.delete(room); } }
384
458
  r.info.rooms.clear();
@@ -415,8 +489,8 @@ class StelarServer {
415
489
  const ctx: StelarContext = {
416
490
  id: r.info.id, socket: r.socket, req, clientInfo: r.info,
417
491
  emit: (ev, d) => { if (s._sendJson(r, ev, d)) s._totalSent++; },
418
- send: (rid, d) => { if (s._sendJson(r, rid, { data: d, _isAck: true })) s._totalSent++; },
419
- emitBinary: (ev, buf) => { if (s._sendBin(r, ev, buf)) s._totalSent++; },
492
+ send: (rid, d) => { if (s._sendJson(r, rid, { data: d, _isAck: true, _correlationId: ctx._correlationId })) s._totalSent++; },
493
+ emitBinary: (ev, buf) => { if (s._sendBin(r, ev, Buffer.from(new Uint8Array(buf)))) s._totalSent++; },
420
494
  broadcast: (ev, d) => s.broadcast(ev, d, r.info.id),
421
495
  broadcastBinary: (ev, buf) => s.broadcastBinary(ev, buf),
422
496
  to: (room, ev, d) => s.to(room, ev, d, r.info.id),
@@ -436,7 +510,7 @@ class StelarServer {
436
510
  if (r.protocol === 'ws') {
437
511
  const p: Record<string, unknown> = { event: name, data: res, _isAck: true };
438
512
  if (ctx._correlationId) p._correlationId = ctx._correlationId;
439
- r.socket.write(createWSTextFrame(JSON.stringify(p)));
513
+ r.socket.write(createWSTextFrame(JSON.stringify(p), r.compress));
440
514
  } else {
441
515
  r.socket.write(ctx._correlationId
442
516
  ? encodeAckResFrame(name, { data: res, _correlationId: ctx._correlationId }, s.maxFrame)
@@ -455,7 +529,7 @@ class StelarServer {
455
529
  run(0);
456
530
  }
457
531
 
458
- /* ── Private: event dispatch (shared by WS & TCP) ── */
532
+ /* ── Private: event dispatch ── */
459
533
 
460
534
  private _dispatch(r: ClientRecord, ctx: StelarContext, event: string, data: unknown, correlationId?: string) {
461
535
  if (event === 'pong') { r.info.lastPing = Date.now(); return; }
@@ -467,19 +541,6 @@ class StelarServer {
467
541
  if (this._wild) try { this._wild({ event, data: ectx }); } catch (e) { this.log.error('Wildcard error', { error: String(e) }); }
468
542
  }
469
543
 
470
- /* ── Private: heartbeat ── */
471
-
472
- private _startHeartbeat() {
473
- this._hb = setInterval(() => {
474
- const now = Date.now();
475
- this.clients.forEach(r => {
476
- if (now - r.info.lastPing > this.hbTimeout) { r.socket.destroy(); }
477
- else try { r.socket.write(r.protocol === 'ws' ? createWSPingFrame() : encodePingFrame()); } catch {}
478
- });
479
- }, this.hbInterval);
480
- this._hb?.unref?.();
481
- }
482
-
483
544
  /* ── Private: WS upgrade ── */
484
545
 
485
546
  private _wsUpgrade(req: IncomingMessage, socket: NetSocket, head: Buffer) {
@@ -489,29 +550,31 @@ class StelarServer {
489
550
  if (this.origins && !this.origins.includes(req.headers['origin'] || '')) { socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return; }
490
551
  const key = req.headers['sec-websocket-key'] as string;
491
552
  if (!key || !validateWSKey(key)) { socket.destroy(); return; }
553
+ const clientCompress = this.doCompress && clientWantsCompression(req.headers['sec-websocket-extensions'] as string);
492
554
  try {
493
555
  const extra: Record<string, string> = {};
494
556
  const origin = req.headers['origin'];
495
557
  if (origin && this.origins?.includes(origin)) extra['Access-Control-Allow-Origin'] = origin;
496
- socket.write(buildUpgradeResponse(key, extra));
558
+ socket.write(buildUpgradeResponse(key, extra, clientCompress));
497
559
  } catch { socket.destroy(); return; }
498
560
 
499
561
  const timer = setTimeout(() => { if (!this.clients.has(socket)) socket.destroy(); }, this.connTimeout);
500
562
  timer.unref();
501
563
 
502
- const r = this._register(socket, 'ws', req, new WSFrameParser(this.maxWSFrame));
564
+ const r = this._register(socket, 'ws', req, new WSFrameParser(this.maxWSFrame), clientCompress);
503
565
  if (!r) { clearTimeout(timer); return; }
504
566
  const ctx = this._buildCtx(r, req);
505
567
 
506
568
  this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'ws', metadata: r.info.metadata });
507
569
  this._runMw(ctx, () => { if (this._connH) try { this._connH(ctx); } catch (e) { this.log.error('Connection handler error', { error: String(e) }); } });
508
- this.log.info('WS connected', { clientId: r.info.id, ip: r.info.remoteAddress });
570
+ this.log.info('WS connected', { clientId: r.info.id, ip: r.info.remoteAddress, compressed: clientCompress });
571
+ this._startClientHB(r);
509
572
 
510
573
  if (head.length > 0) this._processWS(r, head, ctx);
511
574
  socket.on('data', (d: Buffer) => { clearTimeout(timer); this._processWS(r, d, ctx); });
512
575
  socket.on('close', () => { clearTimeout(timer); this._unregister(r, ctx); });
513
576
  socket.on('error', (e: Error) => { this.log.warn('WS error', { clientId: r.info.id, error: e.message }); this._handleErr(r, ctx, e); });
514
- socket.on('drain', () => socket.resume());
577
+ socket.on('drain', () => this._flushQueue(r));
515
578
  }
516
579
 
517
580
  private _processWS(r: ClientRecord, data: Buffer, ctx: StelarContext) {
@@ -556,7 +619,7 @@ class StelarServer {
556
619
  if (res !== undefined) {
557
620
  const p: Record<string, unknown> = { event: name, data: res, _isAck: true };
558
621
  if (corrId) p._correlationId = corrId;
559
- try { r.socket.write(createWSTextFrame(JSON.stringify(p))); this._totalSent++; } catch {}
622
+ try { r.socket.write(createWSTextFrame(JSON.stringify(p), r.compress)); this._totalSent++; } catch {}
560
623
  }
561
624
  return;
562
625
  }
@@ -566,21 +629,17 @@ class StelarServer {
566
629
  if (opcode === OP_BINARY) {
567
630
  r.info.messagesReceived++; this._totalRecv++;
568
631
  if (payload.length > this.maxPayload) { this.hooks.onPayloadTooLarge?.({ clientId: r.info.id, size: payload.length, max: this.maxPayload }); return; }
569
- try {
570
- let end = -1; for (let i = 0; i < payload.length; i++) if (payload[i] === 0) { end = i; break; }
571
- if (end === -1) return;
572
- const hdr = JSON.parse(payload.subarray(0, end).toString('utf8'));
573
- const buf = payload.subarray(end + 1);
574
- if (hdr.event && !this._checkRate(r.info.id, hdr.event)) {
575
- this.log.warn('Binary rate limit', { clientId: r.info.id, event: hdr.event });
576
- if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: hdr.event, protocol: 'ws' }) === false) return;
577
- return;
578
- }
579
- const ectx: StelarContext = { ...ctx, data: buf, buffer: buf, isBinary: true, event: hdr.event };
580
- const h = this.events.get(hdr.event);
581
- if (h) try { h(ectx); } catch {}
582
- if (this._wild) try { this._wild({ event: hdr.event, data: ectx }); } catch {}
583
- } catch { this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid binary frame', protocol: 'ws' }); }
632
+ const parsed = parseWSBinary(payload);
633
+ if (!parsed) { this.hooks.onInvalidMessage?.({ clientId: r.info.id, reason: 'Invalid binary frame', protocol: 'ws' }); return; }
634
+ if (parsed.event && !this._checkRate(r.info.id, parsed.event)) {
635
+ this.log.warn('Binary rate limit', { clientId: r.info.id, event: parsed.event });
636
+ if (this.hooks.onRateLimitExceeded?.({ clientId: r.info.id, event: parsed.event, protocol: 'ws' }) === false) return;
637
+ return;
638
+ }
639
+ const ectx: StelarContext = { ...ctx, data: parsed.buffer, buffer: parsed.buffer, isBinary: true, event: parsed.event };
640
+ const h = this.events.get(parsed.event);
641
+ if (h) try { h(ectx); } catch {}
642
+ if (this._wild) try { this._wild({ event: parsed.event, data: ectx }); } catch {}
584
643
  }
585
644
  }
586
645
 
@@ -594,10 +653,11 @@ class StelarServer {
594
653
  this.hooks.onClientConnect?.({ clientId: r.info.id, ip: r.info.remoteAddress, protocol: 'tcp', metadata: r.info.metadata });
595
654
  this._runMw(ctx, () => { if (this._connH) try { this._connH(ctx); } catch (e) { this.log.error('TCP connection handler error', { error: String(e) }); } });
596
655
  this.log.info('TCP connected', { clientId: r.info.id, ip: r.info.remoteAddress });
656
+ this._startClientHB(r);
597
657
  socket.on('data', (d: Buffer) => this._processTCP(r, d, ctx));
598
658
  socket.on('close', () => this._unregister(r, ctx));
599
659
  socket.on('error', (e: Error) => { this.log.warn('TCP error', { clientId: r.info.id, error: e.message }); this._handleErr(r, ctx, e); });
600
- socket.on('drain', () => socket.resume());
660
+ socket.on('drain', () => this._flushQueue(r));
601
661
  }
602
662
 
603
663
  private _processTCP(r: ClientRecord, data: Buffer, ctx: StelarContext) {
@@ -651,17 +711,6 @@ class StelarServer {
651
711
  return;
652
712
  }
653
713
 
654
- if (type === FRAME_ACK_RES) {
655
- try {
656
- const raw = JSON.parse(payload.toString('utf8'));
657
- const data = raw && typeof raw === 'object' && 'data' in raw ? raw.data : raw;
658
- const corrId = raw && typeof raw === 'object' && '_correlationId' in raw ? String(raw._correlationId) : undefined;
659
- const h = this._acks.get(corrId || event);
660
- if (h) try { h({ ...ctx, data }); } catch {}
661
- } catch {}
662
- return;
663
- }
664
-
665
714
  if (type === FRAME_BINARY) {
666
715
  const ectx: StelarContext = { ...ctx, data: payload, buffer: payload, isBinary: true, event };
667
716
  const h = this.events.get(event);
@@ -733,7 +782,6 @@ class StelarServer {
733
782
  this._reqH = (req, res) => this._health(req, res);
734
783
  this._upgH = (req, socket, head) => this._wsUpgrade(req, socket, head);
735
784
  srv.on('request', this._reqH); srv.on('upgrade', this._upgH);
736
- this._startHeartbeat();
737
785
  this._rc = setInterval(() => {
738
786
  if (this._crl) this._crl.cleanup(); else this.rateLimiter?.cleanup();
739
787
  (this._cit || this.ipTracker).cleanup();
@@ -742,7 +790,7 @@ class StelarServer {
742
790
  }, 30000);
743
791
  this._rc?.unref?.();
744
792
  this._setupShutdown();
745
- const p = this.getPort(); this.log.info('Server started', { port: p, namespace: this.ns, tls: !!this.tlsOpts }); cb?.(p); resolve(p);
793
+ const p = this.getPort(); this.log.info('Server started', { port: p, namespace: this.ns, tls: !!this.tlsOpts, compression: this.doCompress }); cb?.(p); resolve(p);
746
794
  };
747
795
  if (this.httpServer) { this._ext.add(this.httpServer); onHttp(this.httpServer); }
748
796
  else {
@@ -775,7 +823,7 @@ class StelarServer {
775
823
  }
776
824
 
777
825
  stop(): this {
778
- if (this._hb) { clearInterval(this._hb); this._hb = null; }
826
+ this.clients.forEach(r => this._stopClientHB(r));
779
827
  if (this._rc) { clearInterval(this._rc); this._rc = null; }
780
828
  this.clients.forEach(r => { if (!r.socket.destroyed) r.socket.destroy(); });
781
829
  this.clients.clear(); this.byId.clear(); this.rooms.clear(); this.clientRates.clear();
package/src/logger.d.ts CHANGED
@@ -13,7 +13,6 @@ export declare class Logger {
13
13
  private color;
14
14
  constructor(o?: LoggerOptions);
15
15
  setLevel(l: LogLevel): this;
16
- private fmt;
17
16
  private w;
18
17
  debug(m: string, meta?: Record<string, unknown>): void;
19
18
  info(m: string, meta?: Record<string, unknown>): void;
@@ -1 +1 @@
1
- {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMtE,MAAM,WAAW,aAAa;IAAG,KAAK,CAAC,EAAE,QAAQ,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAAE;AAE9G,qBAAa,MAAM;IACjB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,EAAE,CAAU;IACpB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,KAAK,CAAU;gBAEX,CAAC,GAAE,aAAkB;IAOjC,QAAQ,CAAC,CAAC,EAAE,QAAQ;IAEpB,OAAO,CAAC,GAAG;IASX,OAAO,CAAC,CAAC;IAOT,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC/C,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9C,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9C,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAChD;AAED,eAAO,MAAM,WAAW,QAAkC,CAAC"}
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMtE,MAAM,WAAW,aAAa;IAAG,KAAK,CAAC,EAAE,QAAQ,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAAE;AAE9G,qBAAa,MAAM;IACjB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,EAAE,CAAU;IACpB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,KAAK,CAAU;gBAEX,CAAC,GAAE,aAAkB;IAOjC,QAAQ,CAAC,CAAC,EAAE,QAAQ;IAEpB,OAAO,CAAC,CAAC;IAYT,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC/C,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9C,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9C,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAChD;AAED,eAAO,MAAM,WAAW,QAAkC,CAAC"}
package/src/logger.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** @stelar-time-real Logger */
2
- const PRIORITY = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
2
+ const P = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
3
3
  const C = { debug: '\x1b[36m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', reset: '\x1b[0m' };
4
4
  const isBrowser = typeof window !== 'undefined' && typeof process === 'undefined';
5
5
  export class Logger {
@@ -10,7 +10,9 @@ export class Logger {
10
10
  this.color = isBrowser ? false : o.colorize !== false;
11
11
  }
12
12
  setLevel(l) { this.level = l; return this; }
13
- fmt(lvl, msg, meta) {
13
+ w(lvl, err, msg, meta) {
14
+ if (P[lvl] < P[this.level])
15
+ return;
14
16
  const p = [];
15
17
  if (this.ts)
16
18
  p.push(new Date().toISOString());
@@ -23,12 +25,7 @@ export class Logger {
23
25
  catch {
24
26
  p.push('[circular]');
25
27
  }
26
- return p.join(' ');
27
- }
28
- w(lvl, err, msg, meta) {
29
- if (PRIORITY[lvl] < PRIORITY[this.level])
30
- return;
31
- const f = this.fmt(lvl, msg, meta);
28
+ const f = p.join(' ');
32
29
  if (isBrowser)
33
30
  console[lvl]?.(f);
34
31
  else
package/src/logger.ts CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
4
4
 
5
- const PRIORITY: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
6
- const C: Record<string, string> = { debug: '\x1b[36m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', reset: '\x1b[0m' };
5
+ const P: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
6
+ const C = { debug: '\x1b[36m', info: '\x1b[32m', warn: '\x1b[33m', error: '\x1b[31m', reset: '\x1b[0m' };
7
7
  const isBrowser = typeof window !== 'undefined' && typeof process === 'undefined';
8
8
 
9
9
  export interface LoggerOptions { level?: LogLevel; timestamp?: boolean; prefix?: string; colorize?: boolean; }
@@ -23,18 +23,14 @@ export class Logger {
23
23
 
24
24
  setLevel(l: LogLevel) { this.level = l; return this; }
25
25
 
26
- private fmt(lvl: string, msg: string, meta?: Record<string, unknown>): string {
26
+ private w(lvl: string, err: boolean, msg: string, meta?: Record<string, unknown>) {
27
+ if (P[lvl as LogLevel] < P[this.level]) return;
27
28
  const p: string[] = [];
28
29
  if (this.ts) p.push(new Date().toISOString());
29
- p.push(this.color ? `${C[lvl] || ''}[${this.pfx}:${lvl}]${C.reset}` : `[${this.pfx}:${lvl}]`);
30
+ p.push(this.color ? `${C[lvl as keyof typeof C] || ''}[${this.pfx}:${lvl}]${C.reset}` : `[${this.pfx}:${lvl}]`);
30
31
  p.push(msg);
31
32
  if (meta && Object.keys(meta).length) try { p.push(JSON.stringify(meta)); } catch { p.push('[circular]'); }
32
- return p.join(' ');
33
- }
34
-
35
- private w(lvl: string, err: boolean, msg: string, meta?: Record<string, unknown>) {
36
- if (PRIORITY[lvl as LogLevel] < PRIORITY[this.level]) return;
37
- const f = this.fmt(lvl, msg, meta);
33
+ const f = p.join(' ');
38
34
  if (isBrowser) (console as any)[lvl]?.(f);
39
35
  else (err ? process.stderr : process.stdout).write(f + '\n');
40
36
  }
package/src/protocol.d.ts CHANGED
@@ -1,7 +1,4 @@
1
- /**
2
- * @stelar-time-real Binary Protocol
3
- * Frame: [4B totalLen BE][1B type][2B eventLen BE][event][payload]
4
- */
1
+ /** @stelar-time-real Binary Protocol — Frame: [4B totalLen BE][1B type][2B eventLen BE][event][payload] */
5
2
  export declare const FRAME_JSON = 1, FRAME_BINARY = 2, FRAME_PING = 3, FRAME_PONG = 4, FRAME_ACK_REQ = 5, FRAME_ACK_RES = 6, FRAME_CONNECT = 7, FRAME_DISCONNECT = 8, FRAME_JOIN = 9, FRAME_LEAVE = 10, FRAME_ERROR = 11;
6
3
  export declare const MAX_EVENT_LENGTH = 256;
7
4
  export declare const DEFAULT_MAX_FRAME_SIZE: number;
@@ -27,11 +24,14 @@ export declare const encodeDisconnectFrame: () => Buffer<ArrayBufferLike>;
27
24
  export declare const encodeJoinFrame: (room: string, max?: number) => Buffer<ArrayBufferLike>;
28
25
  export declare const encodeLeaveFrame: (room: string) => Buffer<ArrayBufferLike>;
29
26
  export declare const encodeErrorFrame: (msg: string) => Buffer<ArrayBufferLike>;
27
+ /** O(1) append streaming parser — avoids Buffer.concat O(n²) on many small chunks */
30
28
  export declare class FrameParser {
31
- private buf;
29
+ private chunks;
30
+ private len;
32
31
  private max;
33
32
  private received;
34
33
  constructor(max?: number);
34
+ private _compact;
35
35
  feed(data: Buffer): ParsedFrame[];
36
36
  reset(): void;
37
37
  getBytesReceived(): number;
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["protocol.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,eAAO,MAAM,UAAU,IAAO,EAAE,YAAY,IAAO,EAAE,UAAU,IAAO,EACpE,UAAU,IAAO,EAAE,aAAa,IAAO,EAAE,aAAa,IAAO,EAC7D,aAAa,IAAO,EAAE,gBAAgB,IAAO,EAAE,UAAU,IAAO,EAChE,WAAW,KAAO,EAAE,WAAW,KAAO,CAAC;AAEzC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,sBAAsB,QAAmB,CAAC;AACvD,eAAO,MAAM,WAAW,IAAI,CAAC;AAE7B,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;CAAE;AAE9E,qBAAa,aAAc,SAAQ,KAAK;IACtC,IAAI,EAAE,MAAM,CAAC;gBACD,OAAO,EAAE,MAAM,EAAE,IAAI,SAAmB;CAKrD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAMrD;AAwBD,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BAC6B,CAAC;AAExG,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,EAAE,MAAM,UAAU,GAAG,MAAM,EAAE,MAAM,MAAM,4BACP,CAAC;AAElF,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BACE,CAAC;AAE9E,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BACE,CAAC;AAE9E,eAAO,MAAM,kBAAkB,GAAI,IAAI,MAAM,4BAA8D,CAAC;AAC5G,eAAO,MAAM,qBAAqB,+BAAqC,CAAC;AACxE,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,4BAAoE,CAAC;AAC/H,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,4BAA0F,CAAC;AACxI,eAAO,MAAM,gBAAgB,GAAI,KAAK,MAAM,4BAA2D,CAAC;AAExG,qBAAa,WAAW;IACtB,OAAO,CAAC,GAAG,CAAmB;IAC9B,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,QAAQ,CAAK;gBAET,GAAG,SAAyB;IAExC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE;IA8BjC,KAAK;IACL,gBAAgB;CACjB"}
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["protocol.ts"],"names":[],"mappings":"AAAA,2GAA2G;AAE3G,eAAO,MAAM,UAAU,IAAO,EAAE,YAAY,IAAO,EAAE,UAAU,IAAO,EACpE,UAAU,IAAO,EAAE,aAAa,IAAO,EAAE,aAAa,IAAO,EAC7D,aAAa,IAAO,EAAE,gBAAgB,IAAO,EAAE,UAAU,IAAO,EAChE,WAAW,KAAO,EAAE,WAAW,KAAO,CAAC;AAEzC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,eAAO,MAAM,sBAAsB,QAAmB,CAAC;AACvD,eAAO,MAAM,WAAW,IAAI,CAAC;AAE7B,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;CAAE;AAE9E,qBAAa,aAAc,SAAQ,KAAK;IACtC,IAAI,EAAE,MAAM,CAAC;gBACD,OAAO,EAAE,MAAM,EAAE,IAAI,SAAmB;CACrD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAMrD;AAoBD,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BAC6B,CAAC;AAExG,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,EAAE,MAAM,UAAU,GAAG,MAAM,EAAE,MAAM,MAAM,4BACP,CAAC;AAElF,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BACE,CAAC;AAC9E,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,4BACE,CAAC;AAC9E,eAAO,MAAM,kBAAkB,GAAI,IAAI,MAAM,4BAA8D,CAAC;AAC5G,eAAO,MAAM,qBAAqB,+BAAqC,CAAC;AACxE,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,4BAAoE,CAAC;AAC/H,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,4BAA0F,CAAC;AACxI,eAAO,MAAM,gBAAgB,GAAI,KAAK,MAAM,4BAA2D,CAAC;AAExG,qFAAqF;AACrF,qBAAa,WAAW;IACtB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,QAAQ,CAAK;gBAET,GAAG,SAAyB;IAExC,OAAO,CAAC,QAAQ;IAOhB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE;IAwBjC,KAAK;IACL,gBAAgB;CACjB"}