tickcli-js 0.1.4 → 0.1.6

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/hub.ts CHANGED
@@ -474,6 +474,8 @@ export interface LiveStats {
474
474
  /** over the data cap or a late loop now; rooms slowed by their own budget */
475
475
  pressure: boolean;
476
476
  slowedRooms: number;
477
+ /** namespaces slowed because they are over their own outBytes cap (ScopeCaps) */
478
+ slowedScopes: number;
477
479
  /** the 5 rooms sending the most */
478
480
  top: { players: number; bytesOut: number; sharedBytes: number }[];
479
481
  }
@@ -487,6 +489,15 @@ export interface SnapRoom {
487
489
  }
488
490
  export interface RoomsSnapshot { format: number; at: number; rooms: SnapRoom[] }
489
491
 
492
+ /** One namespace's own share of the server (a host app's per-customer caps, tickcli-js/remote). */
493
+ export interface ScopeCaps {
494
+ /** data going out to its rooms, bytes a second (moving average over all its rooms): over it, all its rooms send half as often (up to 8 times
495
+ * less) until the faster rate would be under 80% of it again. Other namespaces keep their full rate. */
496
+ outBytes?: number;
497
+ /** joins a minute across the namespace (reconnects count), on top of the per-address limit; over it: 1013 busy, the page tries again */
498
+ joinsPerMinute?: number;
499
+ }
500
+
490
501
  export interface HubStats { rooms: number; players: number; online: number; connections: number; pending: number; refused: Record<string, number>; live: LiveStats }
491
502
 
492
503
  export class RoomHub {
@@ -500,6 +511,10 @@ export class RoomHub {
500
511
  private perAddr = new Map<string, number>();
501
512
  private addrHits = new Map<string, { at: number; n: number }>();
502
513
  private joins = new Map<string, number[]>();
514
+ /** per namespace with caps: its slow-down factor and moving average out (bytes a second) */
515
+ private scopeSlow = new Map<string, { f: number; avg: number }>();
516
+ /** per namespace with a joins cap: joins in this minute and the last one (a sliding estimate, no list per join) */
517
+ private scopeJoins = new Map<string, { at: number; n: number; prev: number }>();
503
518
  private refused: Record<string, number> = {};
504
519
  private timer: ReturnType<typeof setInterval> | null = null;
505
520
  private sub = 0;
@@ -531,6 +546,8 @@ export class RoomHub {
531
546
  pressure = false;
532
547
  /** world save leases: "scope|slot|who" -> the room key that may write it */
533
548
  private leases = new Map<string, string>();
549
+ /** namespaces being handed to another process now (exportScope): joins to them are refused with 1012 */
550
+ private moving = new Set<string>();
534
551
 
535
552
  /** shutdown() hands the live rooms to this (a restart writes them to disk; importRooms() in the next process takes them back) */
536
553
  private readonly onSnapshot: ((snap: RoomsSnapshot) => Promise<void> | void) | null;
@@ -541,9 +558,12 @@ export class RoomHub {
541
558
 
542
559
  /** rooms allowed in one namespace when it differs from limits.maxRoomsPerScope (a host app's per-customer cap, tickcli-js/remote) */
543
560
  private readonly roomsPerScope: ((scope: string) => number | undefined) | null;
561
+ /** a namespace's own data and joins caps (tickcli-js/remote: a deployment's caps); undefined = only the server-wide limits */
562
+ private readonly scopeCaps: ((scope: string) => ScopeCaps | undefined) | null;
544
563
 
545
- constructor(opts: { secret: string; now?: () => number; limits?: Partial<Limits>; saves?: SavePort; onSnapshot?: (snap: RoomsSnapshot) => Promise<void> | void; listRooms?: boolean; roomsPerScope?: (scope: string) => number | undefined }) {
564
+ constructor(opts: { secret: string; now?: () => number; limits?: Partial<Limits>; saves?: SavePort; onSnapshot?: (snap: RoomsSnapshot) => Promise<void> | void; listRooms?: boolean; roomsPerScope?: (scope: string) => number | undefined; scopeCaps?: (scope: string) => ScopeCaps | undefined }) {
546
565
  this.roomsPerScope = opts.roomsPerScope ?? null;
566
+ this.scopeCaps = opts.scopeCaps ?? null;
547
567
  this.saves = opts.saves ?? null;
548
568
  this.listRooms = opts.listRooms !== false;
549
569
  this.onSnapshot = opts.onSnapshot ?? null;
@@ -675,12 +695,14 @@ export class RoomHub {
675
695
  private join(s: Session, m: Record<string, unknown>) {
676
696
  const L = this.limits, now = this.now();
677
697
  if (s.room) return this.bad(s, "already in a room");
698
+ if (this.moving.has(s.scope)) return s.end(CLOSE.restart, "moving to another server");
678
699
  const spec = parseSpec(m.spec, L.maxPlayers);
679
700
  if (!spec) return this.bad(s, "bad multiplayer option");
680
701
  const hits = (this.joins.get(s.addr) || []).filter((x) => now - x < 60_000);
681
702
  if (hits.length >= L.joinsPerMinute) { this.refuse("joins"); return s.end(CLOSE.tooFast, "too many joins, wait a minute"); }
682
703
  hits.push(now); this.joins.set(s.addr, hits);
683
704
  if (this.joins.size > 10_000) for (const [k, v] of this.joins) if (!v.length || now - v[v.length - 1] > 60_000) this.joins.delete(k);
705
+ if (!this.scopeJoinAllowed(s.scope, now)) { this.refuse("scope_joins"); return s.end(CLOSE.busy, "this game is busy right now, trying again"); }
684
706
  const who = s.who!;
685
707
  if (who.guest && typeof m.name === "string") { const n = cleanName(m.name); if (n) who.name = n; }
686
708
  const specKey = stable(spec);
@@ -1313,7 +1335,7 @@ export class RoomHub {
1313
1335
  if (!on) continue;
1314
1336
  const h = r.host?.session;
1315
1337
  rooms.push({ _r: r, code: r.code, host: r.host?.name ?? null, players: on, away, max: r.spec.max, started: r.started, match: r.match,
1316
- ping: h?.rtt ?? 0, slowed: r.slowF > 1 || (this.pressure && r.seats.size > 4), full: taken >= r.spec.max, spec: r.specKey, created: r.created });
1338
+ ping: h?.rtt ?? 0, slowed: r.slowF > 1 || (this.pressure && r.seats.size > 4) || this.scopeF(r.scope) > 1, full: taken >= r.spec.max, spec: r.specKey, created: r.created });
1317
1339
  }
1318
1340
  rooms.sort((a, b) => Number(a.full) - Number(b.full) || Number(a.started) - Number(b.started) || (b.players as number) - (a.players as number) || (b.created as number) - (a.created as number));
1319
1341
  if (this.lists.size > 1000) this.lists.clear();
@@ -1345,9 +1367,46 @@ export class RoomHub {
1345
1367
  if (!r.slowTold && r.host?.session) { r.slowTold = true; this.err(r.host.session, "too_much_data"); }
1346
1368
  } else if (r.slowF > 1 && r.outAvg * 2 < L.roomOutBytes * 0.8) { r.slowF /= 2; r.outAvg *= 2; }
1347
1369
  }
1348
- /** How many ticks apart a room sends its state: 1 (50 ms), 2 in rooms over 8 players, times its own budget factor, times 2 under server pressure
1349
- * for rooms over 4 players. */
1350
- private every(r: Room) { return (r.seats.size > 8 ? 2 : 1) * r.slowF * (this.pressure && r.seats.size > 4 ? 2 : 1); }
1370
+ /** How many ticks apart a room sends its state: 1 (50 ms), 2 in rooms over 8 players, times its own budget factor, times its namespace's factor
1371
+ * (over its outBytes cap), times 2 under server pressure for rooms over 4 players. */
1372
+ private every(r: Room) { return (r.seats.size > 8 ? 2 : 1) * r.slowF * this.scopeF(r.scope) * (this.pressure && r.seats.size > 4 ? 2 : 1); }
1373
+ private scopeF(scope: string) { return this.scopeSlow.get(scope)?.f ?? 1; }
1374
+
1375
+ /** Once a second: each namespace with an outBytes cap over its share slows all its rooms (twice as slow per second over, up to 8 times), and
1376
+ * speeds up one step when the faster rate would be under 80% of the cap. A busy customer then costs its own players smoothness, not everyone's. */
1377
+ private scopeBudgets() {
1378
+ if (!this.scopeCaps) return;
1379
+ const out = new Map<string, number>();
1380
+ for (const r of this.rooms.values()) out.set(r.scope, (out.get(r.scope) || 0) + r.outRate);
1381
+ for (const k of this.scopeSlow.keys()) if (!out.has(k)) this.scopeSlow.delete(k);
1382
+ for (const [scope, rate] of out) {
1383
+ let cap: number | undefined;
1384
+ try { cap = this.scopeCaps(scope)?.outBytes; } catch { cap = undefined; }
1385
+ if (!cap || cap <= 0) { this.scopeSlow.delete(scope); continue; }
1386
+ let x = this.scopeSlow.get(scope);
1387
+ if (!x) { if (rate <= cap) continue; x = { f: 1, avg: 0 }; this.scopeSlow.set(scope, x); }
1388
+ x.avg = x.avg ? x.avg * 0.7 + rate * 0.3 : rate;
1389
+ if (x.avg > cap && x.f < 8) { x.f *= 2; x.avg /= 2; this.refuse("scope_budget"); }
1390
+ else if (x.f > 1 && x.avg * 2 < cap * 0.8) { x.f /= 2; x.avg *= 2; }
1391
+ if (x.f === 1 && x.avg <= cap) this.scopeSlow.delete(scope);
1392
+ }
1393
+ }
1394
+
1395
+ /** One more join in a namespace with a joinsPerMinute cap, counted over a sliding minute (this minute plus the share of the last one still in it). */
1396
+ private scopeJoinAllowed(scope: string, now: number) {
1397
+ if (!this.scopeCaps) return true;
1398
+ let cap: number | undefined;
1399
+ try { cap = this.scopeCaps(scope)?.joinsPerMinute; } catch { cap = undefined; }
1400
+ if (!cap || cap <= 0) return true;
1401
+ let w = this.scopeJoins.get(scope);
1402
+ if (!w || now - w.at >= 120_000) { w = { at: now, n: 0, prev: 0 }; this.scopeJoins.set(scope, w); }
1403
+ else if (now - w.at >= 60_000) { w.prev = w.n; w.n = 0; w.at += 60_000; }
1404
+ if (this.scopeJoins.size > 10_000) for (const [k, v] of this.scopeJoins) if (now - v.at >= 120_000) this.scopeJoins.delete(k);
1405
+ const used = w.n + w.prev * Math.max(0, 1 - (now - w.at) / 60_000);
1406
+ if (used >= cap) return false;
1407
+ w.n++;
1408
+ return true;
1409
+ }
1351
1410
 
1352
1411
  /** Once a second: the rates over the last second (server and per room). */
1353
1412
  private meters(now: number) {
@@ -1356,6 +1415,7 @@ export class RoomHub {
1356
1415
  for (const k of ["framesOut", "bytesOut", "msgsIn"] as const) { this.rates[k] = Math.round((this.meter[k] - this.mark[k]) / sec); this.mark[k] = this.meter[k]; }
1357
1416
  this.mark.at = now;
1358
1417
  for (const r of this.rooms.values()) { r.outRate = Math.round((r.outBytes - r.outMark) / sec); r.outMark = r.outBytes; this.budget(r); }
1418
+ this.scopeBudgets();
1359
1419
  // the whole server: over 80% of its data cap, or the event loop running late (p99 over 30 ms for 10 s, from the host process), bigger rooms
1360
1420
  // send at half rate and new rooms wait; back to normal under 60% and a loop on time
1361
1421
  const mbit = (this.rates.bytesOut * 8) / 1e6, cap = this.limits.maxOutMbit;
@@ -1478,11 +1538,50 @@ export class RoomHub {
1478
1538
  if (snap) await this.onSnapshot!(snap);
1479
1539
  }
1480
1540
 
1481
- /** The live rooms as plain JSON: everything a new process needs to carry on (sockets, timers and open actions are not kept). */
1482
- exportRooms(): RoomsSnapshot {
1541
+ /** Blue/green hand-over of one namespace to another process: its pending changes go out, open actions fail ("server restarting"), saves are
1542
+ * written, its rooms are exported, its sockets hear 1012 (a router in front re-joins them on the other process over the same page socket; a
1543
+ * page on its own reconnects) and its rooms close here, giving up their save leases. Joins to it are refused with 1012 meanwhile. Resolves
1544
+ * once every save is in the store, so the other process may take the snapshot (importRooms) and write the same saves. */
1545
+ async exportScope(scope: string): Promise<RoomsSnapshot> {
1546
+ this.moving.add(scope);
1547
+ try {
1548
+ // a profile save waits for its profile to load: let those land (2 s at most) so the write below carries them
1549
+ const loads: Promise<unknown>[] = [];
1550
+ for (const r of this.rooms.values()) if (r.scope === scope) for (const x of r.seats.values()) if (x.profLoad) loads.push(x.profLoad.catch(() => null));
1551
+ if (loads.length) await Promise.race([Promise.all(loads), new Promise((r) => setTimeout(r, 2000).unref?.())]);
1552
+ const mine = [...this.rooms.values()].filter((r) => r.scope === scope);
1553
+ for (const room of mine) {
1554
+ this.flush(room);
1555
+ for (const [aid, a] of room.actions) { a.from.session?.send({ t: "result", id: a.ref, ok: false, error: "server restarting" }); room.actions.delete(aid); }
1556
+ this.saveWorld(room, true);
1557
+ for (const x of room.seats.values()) this.flushProfile(room, x, true);
1558
+ }
1559
+ const snap = this.exportRooms(scope);
1560
+ for (const s of [...this.sessions]) if (s.scope === scope) s.end(CLOSE.restart, "moving to another server");
1561
+ for (const room of mine) this.closeRoom(room);
1562
+ await this.settle();
1563
+ return snap;
1564
+ } finally { this.moving.delete(scope); }
1565
+ }
1566
+
1567
+ /** Namespaces with live rooms here and their size (a router's hand-over list, a dashboard). */
1568
+ scopeList(): { scope: string; rooms: number; players: number; online: number }[] {
1569
+ const by = new Map<string, { scope: string; rooms: number; players: number; online: number }>();
1570
+ for (const r of this.rooms.values()) {
1571
+ let x = by.get(r.scope);
1572
+ if (!x) { x = { scope: r.scope, rooms: 0, players: 0, online: 0 }; by.set(r.scope, x); }
1573
+ x.rooms++;
1574
+ for (const z of r.seats.values()) { x.players++; if (z.session) x.online++; }
1575
+ }
1576
+ return [...by.values()];
1577
+ }
1578
+
1579
+ /** The live rooms (of one namespace when given) as plain JSON: everything a new process needs to carry on (sockets, timers and open actions
1580
+ * are not kept). */
1581
+ exportRooms(scope?: string): RoomsSnapshot {
1483
1582
  const rooms: SnapRoom[] = [];
1484
1583
  for (const r of this.rooms.values()) {
1485
- if (r.seats.size === 0) continue;
1584
+ if (r.seats.size === 0 || (scope !== undefined && r.scope !== scope)) continue;
1486
1585
  rooms.push({
1487
1586
  key: r.key, scope: r.scope, game: r.game, spec: r.spec, code: r.code, priv: r.priv, slot: r.slot, owner: r.owner, world: r.world, lease: r.lease, saveBusy: r.saveBusy,
1488
1587
  worldState: r.worldState === "ready" ? "ready" : "loading", saved: r.saved, wrev: r.wrev, savedWrev: r.savedWrev, noSave: r.noSave, resumed: r.resumed, sid: r.sid,
@@ -1548,14 +1647,15 @@ export class RoomHub {
1548
1647
  }
1549
1648
 
1550
1649
  /** One namespace's use right now: rooms, seats, connected players and bytes out a second (over the last second). For per-customer caps. */
1551
- scopeStats(scope: string): { rooms: number; players: number; online: number; bytesOut: number } {
1650
+ /** `slowed`: how many times less often its rooms send because the namespace is over its own outBytes cap (1 = full rate). */
1651
+ scopeStats(scope: string): { rooms: number; players: number; online: number; bytesOut: number; slowed: number } {
1552
1652
  let rooms = 0, players = 0, online = 0, bytesOut = 0;
1553
1653
  for (const r of this.rooms.values()) {
1554
1654
  if (r.scope !== scope) continue;
1555
1655
  rooms++; bytesOut += r.outRate;
1556
1656
  for (const x of r.seats.values()) { players++; if (x.session) online++; }
1557
1657
  }
1558
- return { rooms, players, online, bytesOut };
1658
+ return { rooms, players, online, bytesOut, slowed: this.scopeF(scope) };
1559
1659
  }
1560
1660
 
1561
1661
  stats(): HubStats {
@@ -1570,6 +1670,7 @@ export class RoomHub {
1570
1670
  ...this.rates, tickMs: { p50: pct(0.5), p99: pct(0.99), max: n ? Math.round(sorted[n - 1] * 100) / 100 : 0 }, slow, started,
1571
1671
  savesOk: this.counts.saves_ok || 0, savesFailed: this.refused.save_failed || 0, loadsFailed: this.refused.load_failed || 0, worldWritesPerMin: this.worldWrites.length,
1572
1672
  loop: this.loop, top, pressure: this.pressure, slowedRooms: [...this.rooms.values()].filter((r) => r.slowF > 1).length,
1673
+ slowedScopes: [...this.scopeSlow.values()].filter((x) => x.f > 1).length,
1573
1674
  };
1574
1675
  return { rooms: this.rooms.size, players, online, connections: this.sessions.size, pending: this.pending, refused: { ...this.refused }, live };
1575
1676
  }
package/src/remote.ts CHANGED
@@ -19,7 +19,7 @@ import crypto from "node:crypto";
19
19
  import type { Duplex } from "node:stream";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { WebSocketServer, type WebSocket } from "ws";
22
- import { CLOSE, RoomHub, addressKey, type Limits, type SavePort } from "./hub.ts";
22
+ import { CLOSE, RoomHub, addressKey, type Limits, type SavePort, type ScopeCaps } from "./hub.ts";
23
23
  import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, hardenServer } from "./node.ts";
24
24
 
25
25
  export interface Deployment {
@@ -36,8 +36,12 @@ export interface Deployment {
36
36
  rooms?: number;
37
37
  /** connected sockets at once */
38
38
  players?: number;
39
- /** data going out to its players, KB a second (over the last second); over it, new sockets wait (busy) and open rooms carry on */
39
+ /** data going out to its players, KB a second: over it, new sockets wait (busy) and all its open rooms send less often (half, down to an
40
+ * eighth) until it is back under 80% of it, so one busy deployment cannot take the server's whole uplink */
40
41
  kbPerSec?: number;
42
+ /** joins a minute across the deployment, reconnects included (on top of the hub's per-address joinsPerMinute); over it: 1013 busy, the page
43
+ * tries again. Leave room for a restart: every connected player joins again within a few seconds. */
44
+ joinsPerMinute?: number;
41
45
  };
42
46
  }
43
47
 
@@ -65,6 +69,8 @@ export interface Remote {
65
69
  scope(id: string): string;
66
70
  /** The rooms cap the hub should use for a namespace (pass to new RoomHub({ roomsPerScope })). */
67
71
  roomsPerScope(scope: string): number | undefined;
72
+ /** The data and joins caps the hub should use for a namespace (pass to new RoomHub({ scopeCaps })). */
73
+ scopeCaps(scope: string): ScopeCaps | undefined;
68
74
  /** connected sockets per deployment id */
69
75
  sockets(id: string): number;
70
76
  }
@@ -175,6 +181,10 @@ export function createRemote(o: RemoteOptions): Remote {
175
181
  scope,
176
182
  sockets: (id) => live.get(id) || 0,
177
183
  roomsPerScope: (sc) => (sc.startsWith("dep:") ? cache.get(sc.slice(4))?.caps?.rooms : undefined),
184
+ scopeCaps: (sc) => {
185
+ const c = sc.startsWith("dep:") ? cache.get(sc.slice(4))?.caps : undefined;
186
+ return c && (c.kbPerSec !== undefined || c.joinsPerMinute !== undefined) ? { outBytes: c.kbPerSec !== undefined ? c.kbPerSec * 1024 : undefined, joinsPerMinute: c.joinsPerMinute } : undefined;
187
+ },
178
188
  revoke(id) { cache.delete(id); seen.delete(id); o.hub.closeScope(scope(id), "this game was turned off by its owner"); },
179
189
  forget(id) { cache.delete(id); seen.delete(id); },
180
190
 
@@ -241,13 +251,15 @@ export interface RemoteServerOptions {
241
251
  address?: RemoteOptions["address"];
242
252
  /** behind a proxy on a private network: players' addresses from X-Forwarded-For (see clientAddress in tickcli-js/node) */
243
253
  trustProxy?: boolean;
254
+ /** close() hands the live rooms to this (write them somewhere; the next process passes them to hub.importRooms): a restart keeps matches */
255
+ onSnapshot?: ConstructorParameters<typeof RoomHub>[0]["onSnapshot"];
244
256
  }
245
257
 
246
258
  /** A complete remote room server: node:http + the hub + createRemote. Add your own routes on `server` before listen(). */
247
259
  export function createRemoteServer(o: RemoteServerOptions) {
248
260
  let remote: Remote | null = null;
249
261
  const saves = typeof o.saves === "string" ? fileSaves(o.saves) : o.saves;
250
- const hub = new RoomHub({ secret: o.secret ?? crypto.randomBytes(32).toString("base64url"), limits: o.limits, saves, roomsPerScope: (sc) => remote?.roomsPerScope(sc) });
262
+ const hub = new RoomHub({ secret: o.secret ?? crypto.randomBytes(32).toString("base64url"), limits: o.limits, saves, onSnapshot: o.onSnapshot, roomsPerScope: (sc) => remote?.roomsPerScope(sc), scopeCaps: (sc) => remote?.scopeCaps(sc) });
251
263
  hub.start();
252
264
  const heartbeat = new Heartbeat();
253
265
  remote = createRemote({ hub, deployment: o.deployment, heartbeat, address: o.address ?? (o.trustProxy ? (req) => clientAddress(req, true) : undefined) });