@irtio/cli 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/api.d.ts +52 -0
  2. package/dist/api.js +15 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/{static-deploy-BP3MDXCP.js → chunk-3HQMVCYA.js} +89 -48
  5. package/dist/chunk-DKWG7MGO.js +93 -0
  6. package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
  7. package/dist/{chunk-I37DLT7K.js → chunk-RNAH5T4W.js} +14 -3
  8. package/dist/chunk-RQSJZWQC.js +452 -0
  9. package/dist/{chunk-NVUKSP5U.js → chunk-UPHQM6NZ.js} +12 -1
  10. package/dist/chunk-ZD4ND6X6.js +31 -0
  11. package/dist/chunk-ZK5JLUD4.js +94 -0
  12. package/dist/credentials.d.ts +61 -0
  13. package/dist/credentials.js +20 -0
  14. package/dist/delete-project-VENS2B44.js +118 -0
  15. package/dist/deploy.d.ts +149 -0
  16. package/dist/deploy.js +567 -0
  17. package/dist/{dev-7UZZGE4U.js → dev-QM26ONKS.js} +3095 -294
  18. package/dist/index.js +113 -28
  19. package/dist/init.d.ts +2 -1
  20. package/dist/init.js +42 -1
  21. package/dist/{keys-KEKO3EJ6.js → keys-JHLMEGRA.js} +49 -18
  22. package/dist/leaderboard-SYPSBPS3.js +352 -0
  23. package/dist/{login-OV2EFNTJ.js → login-2M73HBZT.js} +25 -1
  24. package/dist/{logs-5OUDPAIX.js → logs-2W7CPZO5.js} +42 -17
  25. package/dist/{migrate-UD245ULI.js → migrate-T3DZJREY.js} +44 -19
  26. package/dist/ratings-VG32WFDG.js +297 -0
  27. package/dist/{rollback-CLQVYFHW.js → rollback-SO74MVZV.js} +41 -17
  28. package/dist/{rooms-OJ3JLYHD.js → rooms-VI33P4RA.js} +73 -20
  29. package/dist/simulate.d.ts +215 -3
  30. package/dist/simulate.js +865 -64
  31. package/dist/static-deploy-KOWFKWZA.js +19 -0
  32. package/dist/status-HF3ZEKB7.js +219 -0
  33. package/dist/usage-4G23QXCH.js +213 -0
  34. package/dist/{whoami-S73O6KJF.js → whoami-KTMTQNHM.js} +21 -2
  35. package/package.json +24 -7
  36. package/dist/chunk-BPE452KF.js +0 -180
  37. package/dist/chunk-D7CDJRFF.js +0 -24
  38. package/dist/deploy-YVCVDVMS.js +0 -396
@@ -1,26 +1,48 @@
1
+ import {
2
+ profileTick
3
+ } from "./chunk-ZK5JLUD4.js";
1
4
  import {
2
5
  clientImportsRoom
3
6
  } from "./chunk-TQU6345E.js";
4
- import {
5
- BundleError,
6
- bundleRoom
7
- } from "./chunk-KRQUAEN2.js";
8
7
  import {
9
8
  DEFAULT_SAVE_RETAIN,
10
9
  DiskStore,
10
+ IRT_IDENTITY_ISSUER,
11
11
  KV_ERRORS,
12
+ LEADERBOARD_ERRORS,
12
13
  PLAYER_ISSUER_RE,
13
14
  PRE_MIGRATION_SAVE_ID,
15
+ RATING_ERRORS,
14
16
  listSaves,
15
17
  mintSaveId,
18
+ parseIdentityKeys,
16
19
  pruneSaves,
17
- saveKey
18
- } from "./chunk-BPE452KF.js";
20
+ ratingPlayerProblem,
21
+ ratingQueueProblem,
22
+ ratingResultsProblem,
23
+ ratingValueProblem,
24
+ saveKey,
25
+ verifyAssertion
26
+ } from "./chunk-RQSJZWQC.js";
27
+ import {
28
+ readProjectConfig
29
+ } from "./chunk-DKWG7MGO.js";
30
+ import {
31
+ BundleError,
32
+ bundleRoom
33
+ } from "./chunk-OTSFRVJN.js";
34
+ import {
35
+ HelpRequested,
36
+ helpFor,
37
+ helpRequested
38
+ } from "./chunk-ZD4ND6X6.js";
39
+ import "./chunk-RNAH5T4W.js";
40
+ import "./chunk-UPHQM6NZ.js";
19
41
 
20
42
  // src/dev.ts
21
43
  import { existsSync } from "fs";
22
44
  import { watch } from "fs";
23
- import { mkdir, readFile } from "fs/promises";
45
+ import { mkdir } from "fs/promises";
24
46
  import { createRequire } from "module";
25
47
  import * as path from "path";
26
48
  import { fileURLToPath, pathToFileURL as pathToFileURL3 } from "url";
@@ -40,9 +62,61 @@ var DEFAULT_LIMITS = {
40
62
  relayIdleMs: 3e4,
41
63
  relayReconnectGraceMs: 3e4,
42
64
  relayMaxClients: 64,
65
+ relayMaxClientsFree: 16,
66
+ freeMaxAwakeRooms: 10,
43
67
  saveRetain: DEFAULT_SAVE_RETAIN
44
68
  };
45
69
 
70
+ // ../supervisor/src/codes.ts
71
+ import { randomInt } from "crypto";
72
+ import {
73
+ CODE_ALPHABET,
74
+ DEFAULT_ROOM_TYPE,
75
+ ROOM_ID_RE,
76
+ formatRoomId,
77
+ parseRoomId
78
+ } from "@irtio/protocol";
79
+ var TENANT_ROOM_ID = "__tenant";
80
+ var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
81
+ function pick(n) {
82
+ let out = "";
83
+ for (let i = 0; i < n; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
84
+ return out;
85
+ }
86
+ function newRoomCode(taken) {
87
+ for (let len = 4; len <= 8; len++) {
88
+ for (let attempt = 0; attempt < 64; attempt++) {
89
+ const code = pick(len);
90
+ if (!taken(code)) return code;
91
+ }
92
+ }
93
+ throw new Error("newRoomCode: exhausted the room code space");
94
+ }
95
+ function newClientId(taken) {
96
+ for (let attempt = 0; attempt < 128; attempt++) {
97
+ const id = `c${pick(10)}`;
98
+ if (!taken(id)) return id;
99
+ }
100
+ throw new Error("newClientId: could not allocate a unique client id");
101
+ }
102
+ function looksLikeRoomCode(id) {
103
+ if (!CODE_SHAPE_RE.test(id)) return false;
104
+ for (const ch of id.toUpperCase()) {
105
+ if (!CODE_ALPHABET.includes(ch)) return false;
106
+ }
107
+ return true;
108
+ }
109
+ function normalizeRoomId(raw) {
110
+ const parsed = parseRoomId(raw);
111
+ if (!parsed) return void 0;
112
+ if (parsed.id === TENANT_ROOM_ID) return void 0;
113
+ const id = looksLikeRoomCode(parsed.id) ? parsed.id.toUpperCase() : parsed.id;
114
+ return formatRoomId(parsed.type, id);
115
+ }
116
+ function roomTypeOf(roomId) {
117
+ return parseRoomId(roomId)?.type ?? DEFAULT_ROOM_TYPE;
118
+ }
119
+
46
120
  // ../supervisor/src/resilience.ts
47
121
  var SNAPSHOT_FLUSH_MAX_RETRIES = 3;
48
122
  var SNAPSHOT_FLUSH_RETRY_DELAYS_MS = [100, 200, 400];
@@ -84,27 +158,38 @@ async function putSnapshotWithRetry(store, key, bytes, metrics, log) {
84
158
  // ../supervisor/src/server.ts
85
159
  import { execFile } from "child_process";
86
160
  import { randomBytes, timingSafeEqual as timingSafeEqual2 } from "crypto";
161
+ import { readdirSync } from "fs";
87
162
  import { mkdtemp, writeFile } from "fs/promises";
88
163
  import { createServer } from "http";
89
164
  import { tmpdir } from "os";
90
165
  import * as nodePath from "path";
91
166
  import { pathToFileURL as pathToFileURL2 } from "url";
92
167
  import {
93
- ErrorCode as ErrorCode2,
94
- FrameType as FrameType2,
168
+ BUS_MAILBOX_PREFIX as BUS_MAILBOX_PREFIX2,
169
+ BUS_OUTBOX as BUS_OUTBOX2,
170
+ CLOSE_EGRESS_WALL,
171
+ DEFAULT_ROOM_TYPE as DEFAULT_ROOM_TYPE3,
172
+ ErrorCode as ErrorCode3,
173
+ FrameType as FrameType3,
95
174
  PROTOCOL_VERSION,
175
+ ROOM_TYPE_RE,
96
176
  decodeFrame,
97
177
  decodeHello,
98
- decodeMsg,
99
178
  decodePing,
179
+ decodeVoiceMessage,
100
180
  encodeErrorPayload as encodeErrorPayload2,
101
- encodeFrame as encodeFrame2,
102
- encodeMsg,
181
+ encodeFrame as encodeFrame3,
182
+ encodeMsg as encodeMsg2,
103
183
  encodePong,
184
+ encodeVoiceMessage,
104
185
  encodeWelcome,
105
186
  formatError,
187
+ isMailboxAlarm,
188
+ isOutboxAlarm,
106
189
  isRelayHash8,
190
+ isVoiceMsg,
107
191
  relaySchema as relaySchema2,
192
+ schemaPayload,
108
193
  withBuiltins
109
194
  } from "@irtio/protocol";
110
195
  import { inspectState } from "@irtio/runtime";
@@ -131,6 +216,14 @@ var AlarmSet = class {
131
216
  /** `name` -> wall-clock ms the alarm is due at. Wall clock, not monotonic: it has to mean the
132
217
  * same thing to a control plane on another machine and across a tenant restart. */
133
218
  due = /* @__PURE__ */ new Map();
219
+ /**
220
+ * D59: `name` -> the opaque payload string that entry carries, for the names that have one.
221
+ *
222
+ * A separate map rather than a field on a richer value type, so that every alarm which is not a
223
+ * bus mailbox entry is stored exactly as it was: an alarm is a name and a time, and D26's whole
224
+ * surface stays that. Only mailbox entries (`BUS_MAILBOX_PREFIX`) ever put anything here.
225
+ */
226
+ payloads = /* @__PURE__ */ new Map();
134
227
  timer;
135
228
  /** Called when one or more alarms come due, with the names in deterministic (name) order. */
136
229
  deliver;
@@ -149,13 +242,34 @@ var AlarmSet = class {
149
242
  this.deliver = void 0;
150
243
  this.clearTimer();
151
244
  }
152
- /** `atMs === undefined` cancels. Arming a name that is already armed replaces its due time. */
153
- set(name, atMs) {
154
- if (atMs === void 0) this.due.delete(name);
155
- else this.due.set(name, atMs);
245
+ /**
246
+ * `atMs === undefined` cancels. Arming a name that is already armed replaces its due time.
247
+ *
248
+ * D59: `payload` attaches an opaque string to the entry, which is how a bus mailbox entry rides
249
+ * this machinery. **Replace-by-name is why mailbox names are minted unique**: two sends of the
250
+ * same payload to the same room have to deliver twice, and a stable name would have the second
251
+ * quietly overwrite the first.
252
+ */
253
+ set(name, atMs, payload) {
254
+ if (atMs === void 0) {
255
+ this.due.delete(name);
256
+ this.payloads.delete(name);
257
+ } else {
258
+ this.due.set(name, atMs);
259
+ if (payload === void 0) this.payloads.delete(name);
260
+ else this.payloads.set(name, payload);
261
+ }
156
262
  this.rearm();
157
263
  this.onChange?.();
158
264
  }
265
+ /** D59: the payload armed with `name`, or `undefined` for an ordinary alarm. */
266
+ payloadOf(name) {
267
+ return this.payloads.get(name);
268
+ }
269
+ /** D59: how many armed entries carry a payload, i.e. the depth of this room's mailbox. */
270
+ get mailboxDepth() {
271
+ return this.payloads.size;
272
+ }
159
273
  get size() {
160
274
  return this.due.size;
161
275
  }
@@ -164,12 +278,19 @@ var AlarmSet = class {
164
278
  /** Replaces the armed set wholesale — how a room start re-arms from the persisted sidecar. */
165
279
  load(entries) {
166
280
  this.due.clear();
167
- for (const e of entries) this.due.set(e.name, e.dueAt);
281
+ this.payloads.clear();
282
+ for (const e of entries) {
283
+ this.due.set(e.name, e.dueAt);
284
+ if (e.payload !== void 0) this.payloads.set(e.name, e.payload);
285
+ }
168
286
  this.rearm();
169
287
  }
170
288
  /** Snapshot for `/admin/rooms` and for tests. */
171
289
  entries() {
172
- return [...this.due.entries()].map(([name, dueAt]) => ({ name, dueAt })).sort((a, b) => a.dueAt - b.dueAt || (a.name < b.name ? -1 : 1));
290
+ return [...this.due.entries()].map(([name, dueAt]) => {
291
+ const payload = this.payloads.get(name);
292
+ return { name, dueAt, ...payload !== void 0 ? { payload } : {} };
293
+ }).sort((a, b) => a.dueAt - b.dueAt || (a.name < b.name ? -1 : 1));
173
294
  }
174
295
  /** The earliest due time, or `undefined` when nothing is armed. What control is told. */
175
296
  get earliestDueAt() {
@@ -188,6 +309,7 @@ var AlarmSet = class {
188
309
  if (names.length === 0) return names;
189
310
  for (const name of names) this.due.delete(name);
190
311
  this.deliver?.(names);
312
+ for (const name of names) if (!this.due.has(name)) this.payloads.delete(name);
191
313
  this.rearm();
192
314
  this.onChange?.();
193
315
  return names;
@@ -248,11 +370,64 @@ function decodeAlarms(bytes) {
248
370
  const e = entry;
249
371
  if (typeof e.name !== "string" || e.name === "") continue;
250
372
  if (typeof e.dueAt !== "number" || !Number.isFinite(e.dueAt)) continue;
251
- out.push({ name: e.name, dueAt: e.dueAt });
373
+ const payload = e.payload;
374
+ out.push({
375
+ name: e.name,
376
+ dueAt: e.dueAt,
377
+ ...typeof payload === "string" ? { payload } : {}
378
+ });
252
379
  }
253
380
  return out;
254
381
  }
255
382
 
383
+ // ../supervisor/src/relay-host.ts
384
+ import {
385
+ ErrorCode,
386
+ FrameType,
387
+ decodeMsg,
388
+ encodeFrame,
389
+ encodeMsg
390
+ } from "@irtio/protocol";
391
+ function relayJoin(room, peer) {
392
+ const relay = room.relayRoom;
393
+ if (!relay.has(peer.clientId) && relay.size >= room.maxClients) {
394
+ return { ok: false, code: "E_ROOM_FULL" };
395
+ }
396
+ const delta = relay.add(peer.clientId, peer.role, peer.name);
397
+ return { ok: true, tick: relay.tick, snapshot: relay.snapshot(), delta };
398
+ }
399
+ function relayForward(room, peer, type, payload) {
400
+ if (type !== FrameType.MSG) {
401
+ return { ok: false, reason: "relay rooms accept MSG only (no room code is deployed)" };
402
+ }
403
+ let target;
404
+ let body;
405
+ try {
406
+ const msg = decodeMsg(payload);
407
+ target = msg.target;
408
+ body = msg.payload;
409
+ } catch {
410
+ return { ok: false, reason: "malformed MSG" };
411
+ }
412
+ if (target.kind === "server") {
413
+ return { ok: false, reason: "relay rooms have no server to address" };
414
+ }
415
+ if (target.kind === "voice") return { ok: true, dropped: true };
416
+ const out = encodeFrame(
417
+ FrameType.MSG,
418
+ encodeMsg({ target: { kind: "client", clientId: peer.clientId }, payload: body })
419
+ );
420
+ for (const other of room.peers()) {
421
+ if (other === peer || other.state !== "joined") continue;
422
+ if (target.kind === "client" && other.clientId !== target.clientId) continue;
423
+ if (target.kind === "role" && other.role !== target.role) continue;
424
+ const sent = other.send(out);
425
+ if (sent > 0) room.countEgress(sent);
426
+ }
427
+ room.touched(room.relayRoom.advance());
428
+ return { ok: true };
429
+ }
430
+
256
431
  // ../supervisor/src/auth.ts
257
432
  import { createHmac, timingSafeEqual } from "crypto";
258
433
  import { isLocalhostOrigin, originAllowed } from "@irtio/protocol";
@@ -324,6 +499,13 @@ function verifyJwt(issuers, token, projectId, now = Date.now()) {
324
499
  if (typeof claims.iss !== "string" || !PLAYER_ISSUER_RE.test(claims.iss)) {
325
500
  return malformed("iss is required: 1-64 of a-z 0-9 . _ - (never a colon)");
326
501
  }
502
+ if (claims.iss === IRT_IDENTITY_ISSUER) {
503
+ return {
504
+ ok: false,
505
+ code: "E_TOKEN_BAD_ISSUER",
506
+ reason: `${IRT_IDENTITY_ISSUER} is reserved for platform identities`
507
+ };
508
+ }
327
509
  if (typeof claims.aud !== "string") {
328
510
  return malformed("aud (the project id) is required");
329
511
  }
@@ -377,7 +559,7 @@ function mac(secret, payload) {
377
559
  return createHmac("sha256", secret).update(payload).digest("base64url");
378
560
  }
379
561
  function signResume(secret, p) {
380
- const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}`;
562
+ const payload = `${p.clientId}|${p.roomId}|${encodeURIComponent(p.role)}|${p.expMs}|${p.iatMs}`;
381
563
  return Buffer.from(`${payload}.${mac(secret, payload)}`, "utf8").toString("base64url");
382
564
  }
383
565
  function verifyResume(secret, token, now = Date.now()) {
@@ -396,55 +578,428 @@ function verifyResume(secret, token, now = Date.now()) {
396
578
  const b = Buffer.from(expected, "utf8");
397
579
  if (a.length !== b.length || !timingSafeEqual(a, b)) return void 0;
398
580
  const parts = payload.split("|");
399
- if (parts.length !== 4) return void 0;
400
- const [clientId, roomId, encodedRole, expText] = parts;
581
+ if (parts.length !== 5) return void 0;
582
+ const [clientId, roomId, encodedRole, expText, iatText] = parts;
401
583
  const expMs = Number(expText);
402
584
  if (!Number.isFinite(expMs) || expMs <= now) return void 0;
585
+ const iatMs = Number(iatText);
586
+ if (!Number.isFinite(iatMs)) return void 0;
403
587
  let role;
404
588
  try {
405
589
  role = decodeURIComponent(encodedRole);
406
590
  } catch {
407
591
  return void 0;
408
592
  }
409
- return { clientId, roomId, role, expMs };
593
+ return { clientId, roomId, role, expMs, iatMs };
410
594
  }
411
595
 
412
- // ../supervisor/src/codes.ts
413
- import { randomInt } from "crypto";
414
- var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
415
- var ROOM_ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
416
- var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
417
- function pick(n) {
418
- let out = "";
419
- for (let i = 0; i < n; i++) out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
420
- return out;
596
+ // ../supervisor/src/bandwidth.ts
597
+ var FREE_BANDWIDTH_CEILING_BPS = 25e6;
598
+ var CARD_BANDWIDTH_CEILING_BPS = 1e8;
599
+ var BANDWIDTH_WINDOW_MS = 1e4;
600
+ var BUCKETS = 10;
601
+ var BUCKET_MS = BANDWIDTH_WINDOW_MS / BUCKETS;
602
+ function bandwidthCeilingBps(plan) {
603
+ return plan === "card" ? CARD_BANDWIDTH_CEILING_BPS : FREE_BANDWIDTH_CEILING_BPS;
421
604
  }
422
- function newRoomCode(taken) {
423
- for (let len = 4; len <= 8; len++) {
424
- for (let attempt = 0; attempt < 64; attempt++) {
425
- const code = pick(len);
426
- if (!taken(code)) return code;
605
+ var BandwidthCeiling = class {
606
+ constructor(now = Date.now) {
607
+ this.now = now;
608
+ }
609
+ now;
610
+ buckets = new Float64Array(BUCKETS);
611
+ total = 0;
612
+ head = 0;
613
+ headAt = 0;
614
+ started = false;
615
+ ceiling = 0;
616
+ engagedNow = false;
617
+ /** Rooms already warned during the current engagement. Cleared when the ceiling disengages. */
618
+ warned = /* @__PURE__ */ new Set();
619
+ /**
620
+ * Sets the plan whose ceiling is enforced. `undefined` — a box control has never spoken to —
621
+ * disables enforcement, which is the documented failure direction.
622
+ */
623
+ setPlan(plan) {
624
+ this.ceiling = plan === void 0 ? 0 : bandwidthCeilingBps(plan);
625
+ if (this.ceiling === 0) this.disengage();
626
+ }
627
+ /** Counts bytes that left the box. Called from the same place the lease is spent. */
628
+ count(bytes) {
629
+ if (!Number.isFinite(bytes) || bytes <= 0) return;
630
+ this.roll();
631
+ this.buckets[this.head] = (this.buckets[this.head] ?? 0) + bytes;
632
+ this.total += bytes;
633
+ }
634
+ /**
635
+ * Whether forwarding must stop right now.
636
+ *
637
+ * Reads the clock as well as the counters, so a project that stops sending recovers as the
638
+ * window empties rather than only when it next sends something.
639
+ */
640
+ get engaged() {
641
+ if (this.ceiling <= 0) return false;
642
+ this.roll();
643
+ const over = this.bitsPerSec > this.ceiling;
644
+ if (over && !this.engagedNow) this.engagedNow = true;
645
+ else if (!over && this.engagedNow) this.disengage();
646
+ return this.engagedNow;
647
+ }
648
+ /** The measured rate over the whole window, in bits per second. */
649
+ get bitsPerSec() {
650
+ this.roll();
651
+ return this.total * 8 / (BANDWIDTH_WINDOW_MS / 1e3);
652
+ }
653
+ /**
654
+ * Whether this room still owes a warning for the current engagement — true exactly once per room
655
+ * per engagement, so a developer learns why their messages vanished without the ceiling turning
656
+ * into a log flood at frame rate.
657
+ */
658
+ takeWarning(roomId) {
659
+ if (!this.engagedNow || this.warned.has(roomId)) return false;
660
+ this.warned.add(roomId);
661
+ return true;
662
+ }
663
+ state() {
664
+ return {
665
+ enforcing: this.ceiling > 0,
666
+ ceilingBps: this.ceiling,
667
+ bitsPerSec: this.bitsPerSec,
668
+ engaged: this.engaged
669
+ };
670
+ }
671
+ disengage() {
672
+ this.engagedNow = false;
673
+ this.warned.clear();
674
+ }
675
+ /** Advances the ring to now, zeroing the buckets that have aged out of the window. */
676
+ roll() {
677
+ const t = this.now();
678
+ if (!this.started) {
679
+ this.started = true;
680
+ this.headAt = t - t % BUCKET_MS;
681
+ return;
427
682
  }
683
+ let steps = Math.floor((t - this.headAt) / BUCKET_MS);
684
+ if (steps <= 0) return;
685
+ if (steps >= BUCKETS) {
686
+ this.buckets.fill(0);
687
+ this.total = 0;
688
+ this.head = 0;
689
+ } else {
690
+ while (steps-- > 0) {
691
+ this.head = (this.head + 1) % BUCKETS;
692
+ this.total -= this.buckets[this.head] ?? 0;
693
+ this.buckets[this.head] = 0;
694
+ }
695
+ if (this.total < 0) this.total = 0;
696
+ }
697
+ this.headAt = t - t % BUCKET_MS;
428
698
  }
429
- throw new Error("newRoomCode: exhausted the room code space");
699
+ };
700
+ function bandwidthWarning(plan) {
701
+ const mbit = Math.round(bandwidthCeilingBps(plan) / 1e6);
702
+ return `sustained data-out is above this project's ${mbit} Mbit/s fair-use ceiling, so some messages are being dropped until the rate falls; ` + (plan === "free" ? "send less per tick, or add a card for the 100 Mbit/s ceiling" : "send less per tick, or ask support to raise the ceiling for this project");
430
703
  }
431
- function newClientId(taken) {
432
- for (let attempt = 0; attempt < 128; attempt++) {
433
- const id = `c${pick(10)}`;
434
- if (!taken(id)) return id;
704
+
705
+ // ../supervisor/src/bus.ts
706
+ import {
707
+ BUS_ERRORS,
708
+ BUS_LIMITS,
709
+ BUS_MAILBOX_PREFIX,
710
+ BUS_OUTBOX,
711
+ BUS_OUTBOX_PREFIX,
712
+ busChannelProblem,
713
+ busPayloadProblem
714
+ } from "@irtio/protocol";
715
+ function newBusBucket() {
716
+ return new TokenBucket(BUS_LIMITS.opsBurst, BUS_LIMITS.opsPerSecond);
717
+ }
718
+ function mintMailboxName(counter) {
719
+ return `${BUS_MAILBOX_PREFIX}${Date.now().toString(36)}.${counter.toString(36)}`;
720
+ }
721
+ function encodeMailboxEntry(entry) {
722
+ return JSON.stringify(entry);
723
+ }
724
+ function decodeMailboxEntry(payload) {
725
+ if (payload === void 0) return void 0;
726
+ let parsed;
727
+ try {
728
+ parsed = JSON.parse(payload);
729
+ } catch {
730
+ return void 0;
435
731
  }
436
- throw new Error("newClientId: could not allocate a unique client id");
732
+ const e = parsed;
733
+ if (!e || typeof e.from !== "string" || typeof e.payload !== "string") return void 0;
734
+ const attempts = typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0;
735
+ return { from: e.from, payload: e.payload, attempts };
437
736
  }
438
- function looksLikeRoomCode(id) {
439
- if (!CODE_SHAPE_RE.test(id)) return false;
440
- for (const ch of id.toUpperCase()) {
441
- if (!CODE_ALPHABET.includes(ch)) return false;
737
+ var BusRegistry = class {
738
+ channels = /* @__PURE__ */ new Map();
739
+ /** roomId -> the channels it is in, so dropping a room is one pass rather than a scan. */
740
+ byRoom = /* @__PURE__ */ new Map();
741
+ /**
742
+ * Adds `roomId` to `channel`. Returns a problem when the channel name is illegal or the room is
743
+ * already holding as many subscriptions as it may.
744
+ */
745
+ subscribe(roomId, channel) {
746
+ const problem = busChannelProblem(channel);
747
+ if (problem) return problem;
748
+ const mine = this.byRoom.get(roomId);
749
+ if (mine !== void 0 && !mine.has(channel) && mine.size >= BUS_LIMITS.subscriptionsPerRoom) {
750
+ return {
751
+ code: BUS_ERRORS.tooManySubscriptions,
752
+ message: `a room may subscribe to at most ${BUS_LIMITS.subscriptionsPerRoom} channels`
753
+ };
754
+ }
755
+ let subs = this.channels.get(channel);
756
+ if (!subs) this.channels.set(channel, subs = /* @__PURE__ */ new Set());
757
+ subs.add(roomId);
758
+ (mine ?? this.byRoom.set(roomId, /* @__PURE__ */ new Set()).get(roomId)).add(channel);
759
+ return void 0;
442
760
  }
443
- return true;
761
+ unsubscribe(roomId, channel) {
762
+ const subs = this.channels.get(channel);
763
+ if (subs) {
764
+ subs.delete(roomId);
765
+ if (subs.size === 0) this.channels.delete(channel);
766
+ }
767
+ const mine = this.byRoom.get(roomId);
768
+ if (mine) {
769
+ mine.delete(channel);
770
+ if (mine.size === 0) this.byRoom.delete(roomId);
771
+ }
772
+ }
773
+ /**
774
+ * Drops every subscription this room holds. Called when a room is closed for good.
775
+ *
776
+ * Deliberately NOT called on hibernation. What makes "publish reaches awake subscribers only"
777
+ * true is the liveness check in the supervisor's fan-out, which posts to a subscriber only when
778
+ * it is `running` with a live worker; keeping the bookkeeping in step as well would mean
779
+ * chasing the eight separate places a worker can go away, and missing one of those would be a
780
+ * silent correctness bug rather than a leak. A hibernated room's stale entry costs a set member
781
+ * and is overwritten when its definition re-subscribes on the next wake.
782
+ */
783
+ unsubscribeAll(roomId) {
784
+ const mine = this.byRoom.get(roomId);
785
+ if (!mine) return;
786
+ for (const channel of mine) {
787
+ const subs = this.channels.get(channel);
788
+ if (!subs) continue;
789
+ subs.delete(roomId);
790
+ if (subs.size === 0) this.channels.delete(channel);
791
+ }
792
+ this.byRoom.delete(roomId);
793
+ }
794
+ /** Every room subscribed to `channel` except `exclude`, which is how a publisher is never told
795
+ * its own message. Sorted, so a fan-out is deterministic and a test can assert an order. */
796
+ subscribers(channel, exclude) {
797
+ const subs = this.channels.get(channel);
798
+ if (!subs) return [];
799
+ return [...subs].filter((id) => id !== exclude).sort();
800
+ }
801
+ /** The channels `roomId` currently holds, for `/admin/rooms` and for tests. */
802
+ subscriptionsOf(roomId) {
803
+ return [...this.byRoom.get(roomId) ?? []].sort();
804
+ }
805
+ };
806
+ function mintOutboxName(counter) {
807
+ return `${BUS_OUTBOX_PREFIX}${Date.now().toString(36)}.${counter.toString(36)}`;
444
808
  }
445
- function normalizeRoomId(raw) {
446
- if (!ROOM_ID_RE.test(raw)) return void 0;
447
- return looksLikeRoomCode(raw) ? raw.toUpperCase() : raw;
809
+ function encodeOutboxEntry(entry) {
810
+ return JSON.stringify(entry);
811
+ }
812
+ function decodeOutboxEntry(payload) {
813
+ if (payload === void 0) return void 0;
814
+ let parsed;
815
+ try {
816
+ parsed = JSON.parse(payload);
817
+ } catch {
818
+ return void 0;
819
+ }
820
+ const e = parsed;
821
+ if (!e || typeof e.to !== "string" || typeof e.from !== "string" || typeof e.payload !== "string" || typeof e.id !== "string") {
822
+ return void 0;
823
+ }
824
+ return {
825
+ to: e.to,
826
+ from: e.from,
827
+ payload: e.payload,
828
+ id: e.id,
829
+ firstAt: typeof e.firstAt === "number" && Number.isFinite(e.firstAt) ? e.firstAt : Date.now(),
830
+ attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0
831
+ };
832
+ }
833
+ function outboxRetryDelayMs(attempts) {
834
+ return Math.min(BUS_OUTBOX.firstRetryMs * 2 ** Math.max(0, attempts), BUS_OUTBOX.maxRetryMs);
835
+ }
836
+
837
+ // ../supervisor/src/caps.ts
838
+ function parseCapState(value) {
839
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
840
+ const raw = value;
841
+ if (typeof raw.project !== "string" || raw.project === "") return void 0;
842
+ if (!Array.isArray(raw.meters)) return void 0;
843
+ const meters = [];
844
+ for (const entry of raw.meters) {
845
+ if (typeof entry !== "object" || entry === null) continue;
846
+ const m = entry;
847
+ if (typeof m.meter !== "string" || m.meter === "") continue;
848
+ if (typeof m.used !== "number" || !Number.isFinite(m.used)) continue;
849
+ const limit = typeof m.limit === "number" && Number.isFinite(m.limit) ? m.limit : null;
850
+ const mode = m.mode === "hard" || m.mode === "soft" ? m.mode : "none";
851
+ const status = m.status === "crossed" || m.status === "warn" ? m.status : "under";
852
+ const lease = typeof m.lease === "number" && Number.isFinite(m.lease) && m.lease >= 0 ? Math.floor(m.lease) : void 0;
853
+ meters.push({
854
+ meter: m.meter,
855
+ used: m.used,
856
+ limit,
857
+ mode,
858
+ status,
859
+ ...lease !== void 0 ? { lease } : {}
860
+ });
861
+ }
862
+ return {
863
+ project: raw.project,
864
+ plan: raw.plan === "card" ? "card" : "free",
865
+ at: typeof raw.at === "number" && Number.isFinite(raw.at) ? raw.at : Date.now(),
866
+ periodStart: typeof raw.periodStart === "string" ? raw.periodStart : "",
867
+ meters
868
+ };
869
+ }
870
+ var CapGate = class {
871
+ state;
872
+ /** Replaces the cached state wholesale. Returns the state it stored, for the admin reply. */
873
+ apply(state) {
874
+ this.state = state;
875
+ return state;
876
+ }
877
+ /** What the box currently believes, for `/admin/caps` reads and for tests. */
878
+ current() {
879
+ return this.state;
880
+ }
881
+ /** The plan the box currently believes this project is on. `undefined` before the first push. */
882
+ plan() {
883
+ return this.state?.plan;
884
+ }
885
+ /** M5 part 3.5: the leased budget for a meter, or `undefined` when it is not leased. */
886
+ leaseFor(meter) {
887
+ return this.state?.meters.find((m) => m.meter === meter)?.lease;
888
+ }
889
+ /** How stale the cached state is, in ms, or `undefined` when there is none. */
890
+ ageMs(nowMs = Date.now()) {
891
+ return this.state === void 0 ? void 0 : nowMs - this.state.at;
892
+ }
893
+ /**
894
+ * The refusal for a meter whose cap is a crossed HARD cap, or `undefined` when the work may
895
+ * proceed.
896
+ *
897
+ * A `soft` crossing returns nothing. That is D48's card-on-file rule in one line: a soft cap
898
+ * alerts and keeps serving, and the alerting happens in control where the mailer is.
899
+ */
900
+ refusal(meter) {
901
+ const state = this.state;
902
+ if (state === void 0) return void 0;
903
+ const found = state.meters.find((m) => m.meter === meter);
904
+ if (!found || found.mode !== "hard" || found.status !== "crossed") return void 0;
905
+ return capRefusalMessage(meter, state.plan);
906
+ }
907
+ };
908
+ function capRefusalMessage(meter, plan) {
909
+ const what = meter === "egress_bytes" ? "data out" : meter === "room_hours" ? "room hours" : meter === "storage_bytes" ? "storage" : meter.replace(/_/g, " ");
910
+ const fix = plan === "free" ? "add a card in the billing page, or wait for the period to reset on the 1st" : "raise or remove the ceiling for this project in the billing page";
911
+ return `this project is at its ${what} cap for the current billing period; ${fix}`;
912
+ }
913
+
914
+ // ../supervisor/src/egress-lease.ts
915
+ var EGRESS_LEASE_CHUNK_BYTES = 5 * 1024 * 1024 * 1024;
916
+ var EgressLease = class {
917
+ remaining = 0;
918
+ spent = 0;
919
+ leasedEver = false;
920
+ period = "";
921
+ trippedNow = false;
922
+ /**
923
+ * Replaces the local budget with a freshly leased one.
924
+ *
925
+ * Wholesale, never additive, and that is the load-bearing choice: `remainingBytes` is computed
926
+ * by control from the project's allowance minus the usage it has actually received, so adding
927
+ * successive leases together would count the same allowance more than once. A new lease is a new
928
+ * statement of what is left, not a top-up.
929
+ *
930
+ * A lease of zero is a real lease and trips the wall. A lease for a different period untrips it,
931
+ * because the month rolled and the project's allowance came back.
932
+ */
933
+ apply(lease) {
934
+ const granted = Number.isFinite(lease.remainingBytes) ? Math.max(0, Math.floor(lease.remainingBytes)) : 0;
935
+ this.remaining = granted;
936
+ this.spent = 0;
937
+ this.period = lease.periodStart;
938
+ this.leasedEver = true;
939
+ this.trippedNow = granted <= 0;
940
+ }
941
+ /**
942
+ * Counts bytes that left the box, and returns `true` when this call is the one that trips the
943
+ * wall — so the caller can close sockets exactly once rather than on every subsequent frame.
944
+ */
945
+ spend(bytes) {
946
+ if (!this.leasedEver) return false;
947
+ if (!Number.isFinite(bytes) || bytes <= 0) return false;
948
+ this.spent += bytes;
949
+ this.remaining -= bytes;
950
+ if (this.remaining > 0 || this.trippedNow) return false;
951
+ this.trippedNow = true;
952
+ return true;
953
+ }
954
+ /** Whether forwarding must stop. False on a box that has never been leased anything. */
955
+ get tripped() {
956
+ return this.leasedEver && this.trippedNow;
957
+ }
958
+ /** Whether control has ever leased this box anything. */
959
+ get leased() {
960
+ return this.leasedEver;
961
+ }
962
+ state() {
963
+ return {
964
+ leased: this.leasedEver,
965
+ remainingBytes: Math.max(0, this.remaining),
966
+ spentBytes: this.spent,
967
+ tripped: this.tripped,
968
+ periodStart: this.period
969
+ };
970
+ }
971
+ };
972
+ function egressWallMessage(plan) {
973
+ const fix = plan === "free" ? "add a card in the billing page, or wait for the period to reset on the 1st" : "raise the monthly overage spend cap for this project in the billing page";
974
+ return `this project has used its data-out allowance for the current billing period, so ${plan === "free" ? "forwarding has stopped" : "forwarding has stopped at the spend cap"}; ${fix}`;
975
+ }
976
+
977
+ // ../supervisor/src/heap-caps.ts
978
+ import {
979
+ DEFAULT_MEMORY_MB,
980
+ NATIVE_RESERVE_FRACTION as PROTOCOL_NATIVE_RESERVE_FRACTION,
981
+ SUPERVISOR_BASELINE_MIB as PROTOCOL_SUPERVISOR_BASELINE_MIB,
982
+ YOUNG_GEN_MB,
983
+ declarationsFit,
984
+ vmSizeFor
985
+ } from "@irtio/protocol";
986
+ var SUPERVISOR_BASELINE_MIB = PROTOCOL_SUPERVISOR_BASELINE_MIB;
987
+ var HEAP_WARN_FRACTION = 0.8;
988
+ function checkDeclaredFit(vmMemMib, declared, baselineMib = SUPERVISOR_BASELINE_MIB) {
989
+ const types = declared.map((d) => ({
990
+ type: d.type,
991
+ ...d.memoryMb !== void 0 ? { memoryMb: d.memoryMb } : {},
992
+ ...d.maxAwake !== void 0 ? { maxAwake: d.maxAwake } : {},
993
+ ...d.physics === true ? { physics: true } : {}
994
+ }));
995
+ const verdict = declarationsFit(vmMemMib, types, { baselineMib });
996
+ return {
997
+ fits: verdict.fits,
998
+ requiredMib: verdict.required.vmMib,
999
+ budgetMib: vmMemMib,
1000
+ undeclared: declared.filter((d) => d.memoryMb === void 0).map((d) => d.type),
1001
+ detail: `${verdict.required.detail}, against a ${vmMemMib} MiB VM`
1002
+ };
448
1003
  }
449
1004
 
450
1005
  // ../supervisor/src/relay.ts
@@ -550,6 +1105,15 @@ var RelayRoom = class _RelayRoom {
550
1105
  return this.snapshot();
551
1106
  }
552
1107
  };
1108
+ function isRelayPresenceSnapshot(bytes) {
1109
+ if (!bytes || bytes.length === 0) return false;
1110
+ try {
1111
+ decodeSnapshot(relaySchema, bytes);
1112
+ return true;
1113
+ } catch {
1114
+ return false;
1115
+ }
1116
+ }
553
1117
 
554
1118
  // ../supervisor/src/hibernate.ts
555
1119
  function hibernateRoom(sup, room) {
@@ -627,9 +1191,9 @@ async function afterSleep(sup, room) {
627
1191
  sup.dropRoomIfIdle(room);
628
1192
  }
629
1193
  async function wakeRoom(sup, room) {
630
- if (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
631
- await room.transition;
632
- return;
1194
+ while (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
1195
+ await room.transition.catch(() => {
1196
+ });
633
1197
  }
634
1198
  if (room.state !== "hibernated") return;
635
1199
  room.state = "waking";
@@ -693,7 +1257,7 @@ async function migrateRoom(sup, room, toVersion) {
693
1257
  const oldKey = sup.storeKey(room);
694
1258
  let chain;
695
1259
  try {
696
- chain = sup.migrationChain(fromVersion, toVersion);
1260
+ chain = sup.migrationChain(fromVersion, toVersion, room.roomType);
697
1261
  } catch (err) {
698
1262
  return failed(err instanceof Error ? err.message : String(err));
699
1263
  }
@@ -769,7 +1333,7 @@ async function migrateRoom(sup, room, toVersion) {
769
1333
  outcome = failed(`migration to v${toVersion} failed; room restarted on v${fromVersion}`);
770
1334
  return;
771
1335
  }
772
- sup.failOutdatedSessions(room, toVersion);
1336
+ const swapped = sup.failOutdatedSessions(room, toVersion, fromVersion);
773
1337
  await sup.rejoinAll(room);
774
1338
  for (const clientId of room.pendingLeaves) {
775
1339
  room.worker?.post({ t: "leave", clientId, reason: "timeout" });
@@ -782,7 +1346,11 @@ async function migrateRoom(sup, room, toVersion) {
782
1346
  const ms = performance.now() - startedAt;
783
1347
  room.metrics.lastWakeMs = ms;
784
1348
  if (ms > room.metrics.maxWakeMs) room.metrics.maxWakeMs = ms;
785
- sup.roomLog(room, "info", `migrated v${fromVersion} \u2192 v${toVersion}, gap ${ms.toFixed(1)} ms`);
1349
+ sup.roomLog(
1350
+ room,
1351
+ "info",
1352
+ `migrated v${fromVersion} \u2192 v${toVersion}, gap ${ms.toFixed(1)} ms` + (swapped > 0 ? `, ${swapped} client(s) kept their socket and swapped schema` : "")
1353
+ );
786
1354
  outcome = { roomId: room.id, outcome: "migrated" };
787
1355
  })();
788
1356
  room.transition = work.catch((err) => {
@@ -864,7 +1432,8 @@ function newRoomMetrics() {
864
1432
  lastWakeMs: 0,
865
1433
  maxWakeMs: 0,
866
1434
  workerElu: 0,
867
- droppedFramesOnRestart: 0
1435
+ droppedFramesOnRestart: 0,
1436
+ npcConnections: 0
868
1437
  };
869
1438
  }
870
1439
  function emptyRoomsByState() {
@@ -888,15 +1457,222 @@ var LogRing = class {
888
1457
  }
889
1458
  };
890
1459
 
1460
+ // ../supervisor/src/npc/loopback.ts
1461
+ var CONNECTING = 0;
1462
+ var OPEN = 1;
1463
+ var CLOSING = 2;
1464
+ var CLOSED = 3;
1465
+ var LoopbackServerSocket = class {
1466
+ CONNECTING = CONNECTING;
1467
+ OPEN = OPEN;
1468
+ CLOSING = CLOSING;
1469
+ CLOSED = CLOSED;
1470
+ readyState = OPEN;
1471
+ /** Always 0: there is no kernel buffer, so an NPC is never a slow consumer. */
1472
+ bufferedAmount = 0;
1473
+ listeners = /* @__PURE__ */ new Map();
1474
+ peer;
1475
+ on(event, listener) {
1476
+ let set = this.listeners.get(event);
1477
+ if (!set) this.listeners.set(event, set = /* @__PURE__ */ new Set());
1478
+ set.add(listener);
1479
+ return this;
1480
+ }
1481
+ once(event, listener) {
1482
+ const wrapper = (...args) => {
1483
+ this.off(event, wrapper);
1484
+ listener(...args);
1485
+ };
1486
+ return this.on(event, wrapper);
1487
+ }
1488
+ off(event, listener) {
1489
+ this.listeners.get(event)?.delete(listener);
1490
+ return this;
1491
+ }
1492
+ emit(event, ...args) {
1493
+ for (const listener of [...this.listeners.get(event) ?? []]) listener(...args);
1494
+ }
1495
+ send(bytes) {
1496
+ if (this.readyState !== OPEN) return;
1497
+ const peer = this.peer;
1498
+ const copy = bytes.slice();
1499
+ setImmediate(() => peer?.onmessage?.(copy));
1500
+ }
1501
+ /** The supervisor pings every session; a loopback peer answers at once so it never times out. */
1502
+ ping() {
1503
+ setImmediate(() => this.emit("pong"));
1504
+ }
1505
+ close(code = 1e3, reason = "") {
1506
+ if (this.readyState === CLOSED || this.readyState === CLOSING) return;
1507
+ this.readyState = CLOSING;
1508
+ const peer = this.peer;
1509
+ setImmediate(() => {
1510
+ this.readyState = CLOSED;
1511
+ peer?.onclose?.({ code, reason });
1512
+ this.emit("close", code, reason);
1513
+ });
1514
+ }
1515
+ terminate() {
1516
+ if (this.readyState === CLOSED) return;
1517
+ this.readyState = CLOSED;
1518
+ const peer = this.peer;
1519
+ setImmediate(() => {
1520
+ peer?.onclose?.({ code: 1006, reason: "terminated" });
1521
+ this.emit("close", 1006, "terminated");
1522
+ });
1523
+ }
1524
+ };
1525
+ var ClientHalf = class {
1526
+ constructor(server) {
1527
+ this.server = server;
1528
+ }
1529
+ server;
1530
+ onopen = null;
1531
+ onmessage = null;
1532
+ onclose = null;
1533
+ onerror = null;
1534
+ open = true;
1535
+ send(bytes) {
1536
+ if (!this.open || this.server.readyState !== OPEN) return;
1537
+ const copy = bytes.slice();
1538
+ setImmediate(() => this.server.emit("message", copy));
1539
+ }
1540
+ close() {
1541
+ if (!this.open) return;
1542
+ this.open = false;
1543
+ const server = this.server;
1544
+ setImmediate(() => {
1545
+ if (server.readyState === CLOSED) return;
1546
+ server.readyState = CLOSED;
1547
+ server.emit("close", 1e3, "npc closed");
1548
+ this.onclose?.({ code: 1e3 });
1549
+ });
1550
+ }
1551
+ };
1552
+ function loopbackPair() {
1553
+ const server = new LoopbackServerSocket();
1554
+ const client = new ClientHalf(server);
1555
+ server.peer = client;
1556
+ setImmediate(() => client.onopen?.());
1557
+ return { server, client };
1558
+ }
1559
+
1560
+ // ../supervisor/src/npc/runtime.ts
1561
+ var NPC_TRACE_LIMIT = 16;
1562
+ var NPC_JOIN_TIMEOUT_MS = 1e4;
1563
+ var NpcManager = class {
1564
+ constructor(host) {
1565
+ this.host = host;
1566
+ }
1567
+ host;
1568
+ live = /* @__PURE__ */ new Map();
1569
+ /** NPC sessions this manager believes are live in a room. Diagnostics and metrics. */
1570
+ countIn(roomId) {
1571
+ let n = 0;
1572
+ for (const entry of this.live.values()) {
1573
+ if (entry.roomId === roomId) n++;
1574
+ }
1575
+ return n;
1576
+ }
1577
+ has(clientId) {
1578
+ return this.live.has(clientId);
1579
+ }
1580
+ spawn(roomId, definition, clientId, config) {
1581
+ if (this.live.has(clientId)) {
1582
+ this.host.log(roomId, "warn", `irtio: npc ${clientId} is already running; spawn ignored`);
1583
+ return;
1584
+ }
1585
+ const npcs = definition.config.npcs;
1586
+ const script = npcs?.[config.brain.script];
1587
+ if (!script) {
1588
+ this.host.log(
1589
+ roomId,
1590
+ "error",
1591
+ `irtio: npc script ${JSON.stringify(config.brain.script)} is not in this deployment's npcs map`
1592
+ );
1593
+ return;
1594
+ }
1595
+ const entry = { roomId, clientId, runner: void 0, stopped: false };
1596
+ this.live.set(clientId, entry);
1597
+ const name = config.name ?? config.brain.script;
1598
+ void import("@irtio/bots").then(
1599
+ async ({ spawnBots }) => spawnBots(1, {
1600
+ schema: definition.schema,
1601
+ room: roomId,
1602
+ url: "loopback:npc",
1603
+ key: this.host.projectId,
1604
+ name,
1605
+ ...config.role !== void 0 ? { role: config.role } : {},
1606
+ ...config.seed !== void 0 ? { seed: config.seed } : {},
1607
+ traceLimit: NPC_TRACE_LIMIT,
1608
+ joinTimeoutMs: NPC_JOIN_TIMEOUT_MS,
1609
+ transport: {
1610
+ connect: () => this.host.openLoopback(roomId, clientId)
1611
+ },
1612
+ script: (bot) => script(bot)
1613
+ })
1614
+ ).then(
1615
+ (runner) => {
1616
+ entry.runner = runner;
1617
+ if (entry.stopped) void this.stopRunner(entry);
1618
+ },
1619
+ (err) => {
1620
+ this.live.delete(clientId);
1621
+ this.host.log(
1622
+ roomId,
1623
+ "error",
1624
+ `irtio: npc ${JSON.stringify(name)} did not join: ${errorText(err)}`
1625
+ );
1626
+ }
1627
+ );
1628
+ }
1629
+ despawn(clientId) {
1630
+ const entry = this.live.get(clientId);
1631
+ if (!entry) return;
1632
+ this.live.delete(clientId);
1633
+ entry.stopped = true;
1634
+ if (entry.runner) void this.stopRunner(entry);
1635
+ }
1636
+ /** Every NPC in a room: the room hibernated, closed, restarted, or the tenant is shutting down. */
1637
+ despawnRoom(roomId) {
1638
+ for (const entry of [...this.live.values()]) {
1639
+ if (entry.roomId === roomId) this.despawn(entry.clientId);
1640
+ }
1641
+ }
1642
+ despawnAll() {
1643
+ for (const entry of [...this.live.values()]) this.despawn(entry.clientId);
1644
+ }
1645
+ async stopRunner(entry) {
1646
+ const runner = entry.runner;
1647
+ entry.runner = void 0;
1648
+ if (!runner) return;
1649
+ try {
1650
+ await runner.stop();
1651
+ } catch (err) {
1652
+ this.host.log(entry.roomId, "warn", `irtio: npc ${entry.clientId} stop: ${errorText(err)}`);
1653
+ }
1654
+ }
1655
+ };
1656
+ function errorText(err) {
1657
+ return err instanceof Error ? err.message : String(err);
1658
+ }
1659
+
891
1660
  // ../supervisor/src/rooms.ts
1661
+ import {
1662
+ DEFAULT_ROOM_TYPE as DEFAULT_ROOM_TYPE2,
1663
+ parseRoomId as parseRoomId2,
1664
+ roomClassFor,
1665
+ roomHoursSourceFor
1666
+ } from "@irtio/protocol";
892
1667
  var WAKE_QUEUE_CAP = 256;
893
- function relayConfig(limits) {
1668
+ var SEEN_PLAYERS_CAP = 512;
1669
+ function relayConfig(limits, plan) {
894
1670
  return {
895
1671
  mode: "event",
896
1672
  tickRate: 0,
897
1673
  idleMs: limits.relayIdleMs,
898
1674
  reconnectGraceMs: limits.relayReconnectGraceMs,
899
- maxClients: limits.relayMaxClients
1675
+ maxClients: plan === "free" ? limits.relayMaxClientsFree : limits.relayMaxClients
900
1676
  };
901
1677
  }
902
1678
  var RoomRecord = class {
@@ -913,6 +1689,14 @@ var RoomRecord = class {
913
1689
  relayRoom;
914
1690
  lastTick = 0;
915
1691
  metrics = newRoomMetrics();
1692
+ /**
1693
+ * D65: the room's bandwidth ledger as of the last `stats` round trip, when this supervisor was
1694
+ * started with profiling on. Deliberately *beside* `metrics` and not in it: the host agent
1695
+ * flattens every numeric field of `RoomMetrics` into a rollup row per poll, and a ledger is
1696
+ * tens of rows that change names with the schema. It is a local development surface, not fleet
1697
+ * telemetry. `undefined` means nobody is profiling, never "no bytes".
1698
+ */
1699
+ profile;
916
1700
  logs = new LogRing();
917
1701
  createdAt = Date.now();
918
1702
  /** Frames that arrived while hibernating/waking, replayed in order after the re-joins. */
@@ -921,8 +1705,39 @@ var RoomRecord = class {
921
1705
  droppedWakeFrames = 0;
922
1706
  /** Sessions whose grace expired while the room had no worker; `leave` is sent on the next wake. */
923
1707
  pendingLeaves = /* @__PURE__ */ new Set();
1708
+ /**
1709
+ * Bug #49: client ids the worker sent a frame for that this room has no session under, each
1710
+ * logged once. Cleared with the room; a room that keeps naming unknown clients is one warning
1711
+ * per id, not one per frame.
1712
+ */
1713
+ unknownSendWarned = /* @__PURE__ */ new Set();
924
1714
  /** Timestamps of worker restarts, pruned to the last minute (plan §3.3 cap). */
925
1715
  restarts = [];
1716
+ /**
1717
+ * D55: whether the "heap near its cap" warning has already been logged for the current
1718
+ * crossing. Edge-triggered rather than level-triggered, so a room sitting above the line warns
1719
+ * once instead of on every metrics poll; cleared when it drops back under.
1720
+ */
1721
+ heapWarned = false;
1722
+ /**
1723
+ * D63-e: the live half of the backfill flag, as `room.backfill.set` has left it.
1724
+ *
1725
+ * On the record rather than in the worker so it is not in the hibernation blob (the blob format
1726
+ * does not move for this), and **reset to the declaration whenever a worker reports ready** —
1727
+ * a fresh start and a wake alike.
1728
+ *
1729
+ * The reset is the deliberate part, and the alternative was considered. Carrying the value
1730
+ * across a wake would make the supervisor's copy and the room's own `room.backfill.open` say
1731
+ * different things the moment the worker restarted, because the worker's mirror is seeded from
1732
+ * the declaration and nothing pushes the old value back into it. Resetting keeps the two
1733
+ * honest. What it costs is nothing real: a room hibernates because it went idle, and a versus
1734
+ * room in the middle of a round is not idle, so the only rooms this reopens are rooms with
1735
+ * nobody in them.
1736
+ *
1737
+ * Starts `true` so a room that declared `backfill: true` and never touches the toggle is open,
1738
+ * which is the arena case and the common one.
1739
+ */
1740
+ backfillOpen = true;
926
1741
  /**
927
1742
  * The deployment version this room was created under. New rooms take the newest; rooms created
928
1743
  * before a deploy keep theirs until they idle (per-room drain, plan §3.3).
@@ -934,17 +1749,98 @@ var RoomRecord = class {
934
1749
  * ever serialized into the hibernation blob.
935
1750
  */
936
1751
  alarms = new AlarmSet();
1752
+ /**
1753
+ * D67-e: ids of cross-shard bus messages delivered to this room, newest last, bounded by the
1754
+ * supervisor. A message that arrives twice (a lost acknowledgement) is delivered twice, as
1755
+ * at-least-once promises, and its duplicate is logged by name from this set.
1756
+ */
1757
+ busSeen = /* @__PURE__ */ new Set();
1758
+ /**
1759
+ * D59: this room's bus budget.
1760
+ *
1761
+ * On the record rather than in a map keyed by room id, so it lives and dies with the room and
1762
+ * cannot be a leak or a stale entry. This is the per-room rate-limit machinery D59 asks for and
1763
+ * that the supervisor did not have: every limiter before it (`ipBuckets`, `helloBuckets`, the
1764
+ * per-session frame buckets) is scoped to a connection, an IP or a project key, and none of them
1765
+ * metered what a *room* asked the host to do.
1766
+ *
1767
+ * It survives hibernation for the same reason the alarm set does: the record does. A room cannot
1768
+ * reset its budget by sleeping.
1769
+ */
1770
+ busBucket = newBusBucket();
1771
+ /**
1772
+ * D63: every player id this room has held since it was created, newest last.
1773
+ *
1774
+ * `roomHoldsPlayer` is not enough for a rating report and the difference is the whole point: by
1775
+ * the time a room decides who won, the loser has very often already closed the tab. A report
1776
+ * that could only name players still connected would be a report no versus game could make. So
1777
+ * membership for ratings is "has this room ever held you", recorded here at join.
1778
+ *
1779
+ * On the record, not on the worker, so it survives a hibernation the same way the alarm set
1780
+ * does — a room that slept between the last round and the report must still be able to make it.
1781
+ * It is deliberately NOT serialized into the hibernation blob: a room whose tenant restarts
1782
+ * loses it, which costs a report and is the conservative direction to fail in.
1783
+ *
1784
+ * Bounded at `SEEN_PLAYERS_CAP`, oldest evicted first. An arena that has held ten thousand
1785
+ * players over a week must not carry ten thousand strings for the life of the process, and a
1786
+ * room reporting on a player who left five hundred players ago is not a match result.
1787
+ */
1788
+ seenPlayers = /* @__PURE__ */ new Set();
937
1789
  /**
938
1790
  * D24: a save generation to start this room from instead of its live snapshot, set from the
939
1791
  * placement instruction and consumed by the next start. One shot: a restore is an event, not a
940
1792
  * standing property of the room.
941
1793
  */
942
1794
  pendingRestoreSaveId;
1795
+ /**
1796
+ * D47: when this room's alive-time was last banked into the usage ledger, or `undefined` while
1797
+ * the room is not chargeable (hibernated/closed). Owned by `sampleRoomClocks`; deliberately
1798
+ * separate from `createdAt`, which counts wall time a hibernated room keeps accruing.
1799
+ */
1800
+ usageSince;
943
1801
  /** Serialises room-level async work (start / wake / restart / hibernate). */
944
1802
  transition = Promise.resolve();
945
1803
  /** Periodic crash-safety snapshot (event-mode bundle rooms) or the relay idle timer. */
946
1804
  timer;
947
1805
  config;
1806
+ /**
1807
+ * D58: the room type this room runs, parsed once from its id. `DEFAULT_ROOM_TYPE` for a plain
1808
+ * id, which is every id a project had before named room types.
1809
+ *
1810
+ * Parsed here and nowhere else downstream. Shard note 2: outside the definition lookup and the
1811
+ * limits derivation, everything keys on the whole opaque roomId, so this getter is the only
1812
+ * place the split leaks into the room's own lifetime.
1813
+ */
1814
+ get roomType() {
1815
+ return parseRoomId2(this.id)?.type ?? DEFAULT_ROOM_TYPE2;
1816
+ }
1817
+ /** D47: the engine dimension room-hours rows carry; `''` for a room with no physics. */
1818
+ get engine() {
1819
+ return this.config.engine ?? "";
1820
+ }
1821
+ /**
1822
+ * D60: the size class this room is billed as, derived from its declared `memoryMb`.
1823
+ *
1824
+ * Derived rather than stored, and derived from the same number that drives the worker's heap
1825
+ * cap, so the bill and the enforcement cannot disagree about a room. A relay room is not a
1826
+ * class: it is served by a different thing entirely and carries its own source.
1827
+ */
1828
+ get sizeClass() {
1829
+ return roomClassFor(this.config.memoryMb);
1830
+ }
1831
+ /**
1832
+ * D60: the room-hours `source` this room's hours are reported under. `room` for Small.
1833
+ *
1834
+ * **A relay room served by this supervisor reports `room`, not `relay`, deliberately.** The
1835
+ * $0.001 relay rate is honest cost accounting for a room served by the shared relay host: no RAM
1836
+ * reservation, no VM slot, just sockets and a presence map. A relay room running inside a tenant
1837
+ * VM has all of those costs — it is a supervisor in a Firecracker guest doing the work — and
1838
+ * billing it at the shared-host rate would price the discount before earning it. The `relay`
1839
+ * source is reserved for the process that actually deserves it. See the part 3 report.
1840
+ */
1841
+ get usageSource() {
1842
+ return roomHoursSourceFor(this.sizeClass);
1843
+ }
948
1844
  /** Sessions with a live socket that have completed their join. */
949
1845
  connected() {
950
1846
  return [...this.clients.values()].filter((s) => s.state === "joined" && s.open);
@@ -963,6 +1859,31 @@ var RoomRecord = class {
963
1859
  log(level, ...args) {
964
1860
  this.logs.push(level, args);
965
1861
  }
1862
+ /** D63: remember that this room held `playerId`. Re-seeing one moves it to the newest slot. */
1863
+ notePlayerSeen(playerId) {
1864
+ this.seenPlayers.delete(playerId);
1865
+ this.seenPlayers.add(playerId);
1866
+ while (this.seenPlayers.size > SEEN_PLAYERS_CAP) {
1867
+ const oldest = this.seenPlayers.values().next().value;
1868
+ if (oldest === void 0) break;
1869
+ this.seenPlayers.delete(oldest);
1870
+ }
1871
+ }
1872
+ /** D63: has this room ever held `playerId`? The rating report's membership check. */
1873
+ hasHeldPlayer(playerId) {
1874
+ return this.seenPlayers.has(playerId);
1875
+ }
1876
+ /**
1877
+ * D63-e: is this room accepting backfill right now?
1878
+ *
1879
+ * The declaration is the ceiling and `backfillOpen` is the live value, so a room that declared
1880
+ * nothing answers false however it toggled. Reported on the room list; never enforced at join —
1881
+ * `maxClients` is what refuses a join, and a lost race for the last seat is a normal outcome
1882
+ * the client retries.
1883
+ */
1884
+ get backfillNow() {
1885
+ return this.config.backfill === true && this.backfillOpen;
1886
+ }
966
1887
  info() {
967
1888
  const dueAlarmAt = this.alarms.earliestDueAt;
968
1889
  return {
@@ -974,7 +1895,13 @@ var RoomRecord = class {
974
1895
  metrics: { ...this.metrics, workerElu: this.worker?.elu ?? 0 },
975
1896
  createdAt: this.createdAt,
976
1897
  ...dueAlarmAt !== void 0 ? { dueAlarmAt } : {},
977
- ...this.alarms.size > 0 ? { alarms: this.alarms.size } : {}
1898
+ ...this.alarms.size > 0 ? { alarms: this.alarms.size } : {},
1899
+ ...this.profile !== void 0 ? { profile: this.profile } : {},
1900
+ // D63-d/e. `maxClients` is always reported (it is always known); `backfill` is reported
1901
+ // only when the room type declared it, so "absent" and "not a candidate" stay the same
1902
+ // thing all the way to control's nullable column.
1903
+ maxClients: this.config.maxClients,
1904
+ ...this.config.backfill === true ? { backfill: this.backfillNow } : {}
978
1905
  };
979
1906
  }
980
1907
  };
@@ -1000,15 +1927,52 @@ var RoomRegistry = class {
1000
1927
  }
1001
1928
  };
1002
1929
 
1930
+ // ../supervisor/src/schema-swap.ts
1931
+ import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
1932
+ var verdicts = /* @__PURE__ */ new Map();
1933
+ var MAX_MEMO = 64;
1934
+ function isAdditiveMigration(fromJson, toJson) {
1935
+ if (fromJson === toJson) return true;
1936
+ const key = `${fromJson.length}|${fromJson}${toJson}`;
1937
+ const cached = verdicts.get(key);
1938
+ if (cached !== void 0) return cached;
1939
+ let verdict = false;
1940
+ try {
1941
+ const oldSchema = schemaFromCanonical(fromJson);
1942
+ const newSchema = schemaFromCanonical(toJson);
1943
+ const changes = diffSchemas(oldSchema, newSchema);
1944
+ verdict = changes.every((c) => c.kind === "additive") && !touchesPhysicsCollection(oldSchema, newSchema);
1945
+ } catch {
1946
+ verdict = false;
1947
+ }
1948
+ if (verdicts.size >= MAX_MEMO) verdicts.clear();
1949
+ verdicts.set(key, verdict);
1950
+ return verdict;
1951
+ }
1952
+ function touchesPhysicsCollection(oldSchema, newSchema) {
1953
+ const physicsNames = /* @__PURE__ */ new Set();
1954
+ for (const c of oldSchema.collections) if (c.physics !== void 0) physicsNames.add(c.name);
1955
+ for (const c of newSchema.collections) if (c.physics !== void 0) physicsNames.add(c.name);
1956
+ if (physicsNames.size === 0) return false;
1957
+ for (const name of physicsNames) {
1958
+ const before = oldSchema.collections.find((c) => c.name === name);
1959
+ const after = newSchema.collections.find((c) => c.name === name);
1960
+ if (!before || !after) return true;
1961
+ if (JSON.stringify(before) !== JSON.stringify(after)) return true;
1962
+ }
1963
+ return false;
1964
+ }
1965
+
1003
1966
  // ../supervisor/src/session.ts
1004
1967
  import {
1005
- ErrorCode,
1006
- FrameType,
1968
+ ErrorCode as ErrorCode2,
1969
+ FrameType as FrameType2,
1007
1970
  encodeErrorPayload,
1008
- encodeFrame
1971
+ encodeFrame as encodeFrame2
1009
1972
  } from "@irtio/protocol";
1010
1973
  var CLOSE_POLICY = 1008;
1011
1974
  var CLOSE_GRACE_MS = 250;
1975
+ var HELD_FRAMES_MAX = 16;
1012
1976
  var Session = class {
1013
1977
  constructor(ws, remoteAddress, origin, framesPerSec, maxBufferedBytes, onSlowConsumer) {
1014
1978
  this.ws = ws;
@@ -1024,6 +1988,16 @@ var Session = class {
1024
1988
  maxBufferedBytes;
1025
1989
  onSlowConsumer;
1026
1990
  state = "awaiting-hello";
1991
+ /**
1992
+ * Bug #49: frames the worker sent this session while it was still `'joining'`. The room's
1993
+ * `onJoin` runs inside `RoomCore.join`, so a `room.call(ctx.clientId)` made there reaches the
1994
+ * supervisor before the join outcome does, and the client accepts nothing before its `WELCOME`.
1995
+ * Held here, sent in order right after the welcome (`joinBundle`), discarded when the join is
1996
+ * rejected or the socket closes first. Capped at `HELD_FRAMES_MAX`; `droppedHeld` counts the
1997
+ * overflow so the first drop can be logged once.
1998
+ */
1999
+ pendingOut = [];
2000
+ droppedHeld = 0;
1027
2001
  clientId = "";
1028
2002
  roomId = "";
1029
2003
  /** Set once the HELLO resolved a room. */
@@ -1038,6 +2012,24 @@ var Session = class {
1038
2012
  * session whose schema is older than the room's new version by name (`E_SCHEMA_MISMATCH`)
1039
2013
  * rather than send it a resync WELCOME it cannot decode. */
1040
2014
  schemaHash8;
2015
+ /**
2016
+ * D50: this client set `HELLO_SCHEMA_SWAP_BIT`, so it can rebuild its codec from a `SCHEMA`
2017
+ * frame and an additive migrate may swap it in place instead of closing it. Load-bearing in
2018
+ * the negative direction too: `decodeFrame` throws on an unknown type, so a session without
2019
+ * this flag must never be sent frame 13.
2020
+ */
2021
+ schemaSwap = false;
2022
+ /**
2023
+ * D44: this session is a scripted NPC on a loopback socket. It skips the per-IP bucket and the
2024
+ * origin check on the way in (there is no address and no page), is tagged in metrics and in
2025
+ * `SessionInfo`, and is otherwise the ordinary session it looks like.
2026
+ */
2027
+ npc = false;
2028
+ /**
2029
+ * D44: the client id the room already minted for this NPC, so `room.spawnNPC` could hand a
2030
+ * handle back synchronously. Absent for every dialled session, which mints its own.
2031
+ */
2032
+ forcedClientId;
1041
2033
  /** Set when the session was rejected/kicked: no grace window on close. */
1042
2034
  fatal = false;
1043
2035
  /** Slow consumers are closed but keep their grace window (a resume gets a fresh snapshot). */
@@ -1054,6 +2046,14 @@ var Session = class {
1054
2046
  get open() {
1055
2047
  return this.ws.readyState === this.ws.OPEN;
1056
2048
  }
2049
+ /**
2050
+ * D47: the ONE place billable egress is counted. It is here rather than beside the five
2051
+ * `room.metrics.egressBytes += sent` call sites for two reasons the anti-lying tests pin: a
2052
+ * sixth copy of a five-place pattern is a sixth place to forget, and the five call sites are
2053
+ * *observability* — they count NPC loopback bytes, which never leave the box and must never be
2054
+ * billed. Set by the server for every session; the NPC exclusion is below, not in the callback.
2055
+ */
2056
+ onEgress;
1057
2057
  /**
1058
2058
  * Sends one already-framed message. Returns the byte count sent (0 when dropped). A client whose
1059
2059
  * `bufferedAmount` is over the limit is closed with `E_SLOW_CONSUMER` — that's exactly what
@@ -1066,15 +2066,16 @@ var Session = class {
1066
2066
  return 0;
1067
2067
  }
1068
2068
  this.ws.send(bytes, { binary: true });
2069
+ if (!this.npc) this.onEgress?.(bytes.length);
1069
2070
  return bytes.length;
1070
2071
  }
1071
2072
  sendFrame(type, payload) {
1072
- return this.send(encodeFrame(type, payload));
2073
+ return this.send(encodeFrame2(type, payload));
1073
2074
  }
1074
2075
  sendError(code, message, fatal) {
1075
2076
  this.sendFrame(
1076
- FrameType.ERROR,
1077
- encodeErrorPayload({ code: ErrorCode[code].code, message, fatal })
2077
+ FrameType2.ERROR,
2078
+ encodeErrorPayload({ code: ErrorCode2[code].code, message, fatal })
1078
2079
  );
1079
2080
  }
1080
2081
  /** ERROR + close (1008). Fatal rejections skip the grace window on the following `close` event. */
@@ -1083,6 +2084,19 @@ var Session = class {
1083
2084
  this.sendError(code, message, true);
1084
2085
  this.closeSocket(CLOSE_POLICY, code);
1085
2086
  }
2087
+ /**
2088
+ * M5 part 3.5 (Part C): `fail`, but with a chosen WebSocket close code.
2089
+ *
2090
+ * It exists for exactly one caller, the egress wall, and the reason is that the close code is
2091
+ * the only part of a refusal a browser is guaranteed to see. An ERROR frame can lose the race
2092
+ * with the close; `event.code` cannot. Everything else about a fatal rejection is unchanged, so
2093
+ * a wall close still skips the reconnect grace the way a policy close does.
2094
+ */
2095
+ failWith(code, message, closeCode) {
2096
+ this.fatal = true;
2097
+ this.sendError(code, message, true);
2098
+ this.closeSocket(closeCode, code);
2099
+ }
1086
2100
  closeSocket(code = CLOSE_POLICY, reason = "") {
1087
2101
  this.clearHelloTimer();
1088
2102
  if (this.ws.readyState === this.ws.CLOSED) return;
@@ -1118,11 +2132,99 @@ var Session = class {
1118
2132
  name: this.name,
1119
2133
  state: this.state,
1120
2134
  remoteAddress: this.remoteAddress,
1121
- origin: this.origin
2135
+ origin: this.origin,
2136
+ npc: this.npc
1122
2137
  };
1123
2138
  }
1124
2139
  };
1125
2140
 
2141
+ // ../supervisor/src/usage.ts
2142
+ var USAGE_WINDOW_MS = 60 * 60 * 1e3;
2143
+ var CLOSED_WINDOW_GRACE_MS = 15 * 60 * 1e3;
2144
+ var ROOM_SAMPLE_MS = 1e3;
2145
+ function windowStartOf(nowMs) {
2146
+ return Math.floor(nowMs / USAGE_WINDOW_MS) * USAGE_WINDOW_MS;
2147
+ }
2148
+ function keyOf(meter, windowStart, source, engine) {
2149
+ return `${meter}\0${windowStart}\0${source}\0${engine}`;
2150
+ }
2151
+ var UsageAccumulator = class {
2152
+ cells = /* @__PURE__ */ new Map();
2153
+ cell(meter, source, engine, nowMs) {
2154
+ const windowStart = windowStartOf(nowMs);
2155
+ const key = keyOf(meter, windowStart, source, engine);
2156
+ let cell = this.cells.get(key);
2157
+ if (cell === void 0) {
2158
+ cell = { meter, windowStart, source, engine, value: 0 };
2159
+ this.cells.set(key, cell);
2160
+ }
2161
+ return cell;
2162
+ }
2163
+ /** Bytes that actually left the box on a real socket. NPC loopback bytes never reach here. */
2164
+ addEgressBytes(bytes, nowMs = Date.now()) {
2165
+ if (!Number.isFinite(bytes) || bytes <= 0) return;
2166
+ this.cell("egress_bytes", "room", "", nowMs).value += bytes;
2167
+ }
2168
+ /**
2169
+ * Worker-alive milliseconds for one room, reported as fractional hours. `engine` is the room's
2170
+ * physics engine or `''` — a reported dimension, not a multiplier.
2171
+ */
2172
+ addRoomMs(ms, engine, nowMs = Date.now(), source = "room") {
2173
+ if (!Number.isFinite(ms) || ms <= 0) return;
2174
+ this.cell("room_hours", source, engine, nowMs).value += ms / (60 * 60 * 1e3);
2175
+ }
2176
+ /**
2177
+ * Every window still worth reporting: the current one plus any closed window inside the grace.
2178
+ * Closed windows past the grace are dropped here, so the ledger is bounded without a timer.
2179
+ */
2180
+ windows(nowMs = Date.now()) {
2181
+ const current = windowStartOf(nowMs);
2182
+ const out = [];
2183
+ for (const [key, cell] of this.cells) {
2184
+ if (cell.windowStart < current && nowMs - (cell.windowStart + USAGE_WINDOW_MS) > CLOSED_WINDOW_GRACE_MS) {
2185
+ this.cells.delete(key);
2186
+ continue;
2187
+ }
2188
+ out.push({
2189
+ meter: cell.meter,
2190
+ windowStart: cell.windowStart,
2191
+ source: cell.source,
2192
+ engine: cell.engine,
2193
+ value: cell.value
2194
+ });
2195
+ }
2196
+ out.sort(
2197
+ (a, b) => a.windowStart - b.windowStart || a.meter.localeCompare(b.meter) || a.source.localeCompare(b.source) || a.engine.localeCompare(b.engine)
2198
+ );
2199
+ return out;
2200
+ }
2201
+ /** Test/diagnostic read of one running total. */
2202
+ valueOf(meter, nowMs = Date.now(), source = "room", engine = "") {
2203
+ return this.cells.get(keyOf(meter, windowStartOf(nowMs), source, engine))?.value ?? 0;
2204
+ }
2205
+ };
2206
+ function chargeableRoomState(state) {
2207
+ return state === "starting" || state === "running" || state === "waking";
2208
+ }
2209
+ function sampleRoomClocks(rooms, usage, nowMs) {
2210
+ for (const room of rooms) {
2211
+ const chargeable = chargeableRoomState(room.state);
2212
+ if (!chargeable) {
2213
+ if (room.usageSince !== void 0) {
2214
+ usage.addRoomMs(nowMs - room.usageSince, room.engine, nowMs, room.usageSource);
2215
+ room.usageSince = void 0;
2216
+ }
2217
+ continue;
2218
+ }
2219
+ if (room.usageSince === void 0) {
2220
+ room.usageSince = nowMs;
2221
+ continue;
2222
+ }
2223
+ usage.addRoomMs(nowMs - room.usageSince, room.engine, nowMs, room.usageSource);
2224
+ room.usageSince = nowMs;
2225
+ }
2226
+ }
2227
+
1126
2228
  // ../supervisor/src/worker-host.ts
1127
2229
  import { pathToFileURL } from "url";
1128
2230
  import { Worker } from "worker_threads";
@@ -1180,7 +2282,7 @@ var WorkerHost = class {
1180
2282
  }
1181
2283
  }
1182
2284
  onMessage(msg) {
1183
- if ((msg.t === "serialized" || msg.t === "inspected") && this.pending.has(msg.reqId)) {
2285
+ if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats" || msg.t === "recording" || msg.t === "timeline") && msg.reqId !== void 0 && this.pending.has(msg.reqId)) {
1184
2286
  const resolve2 = this.pending.get(msg.reqId);
1185
2287
  this.pending.delete(msg.reqId);
1186
2288
  resolve2?.(msg);
@@ -1251,6 +2353,49 @@ var WorkerHost = class {
1251
2353
  );
1252
2354
  return msg?.t === "inspected" ? msg : void 0;
1253
2355
  }
2356
+ /** D41: arm the room's authoritative timeline recorder. `false` when the worker did not answer. */
2357
+ async startTimeline(options = {}, timeoutMs = 5e3) {
2358
+ const msg = await this.request(
2359
+ (reqId) => ({
2360
+ t: "record",
2361
+ reqId,
2362
+ ...options.maxTicks !== void 0 ? { maxTicks: options.maxTicks } : {},
2363
+ ...options.maxRecords !== void 0 ? { maxRecords: options.maxRecords } : {}
2364
+ }),
2365
+ timeoutMs
2366
+ );
2367
+ return msg?.t === "recording" && msg.ok;
2368
+ }
2369
+ /** D41: the recorded timeline. `undefined` when unarmed, or when the worker did not answer. */
2370
+ async timeline(timeoutMs = 1e4) {
2371
+ const msg = await this.request(
2372
+ (reqId) => ({ t: "timeline", reqId }),
2373
+ timeoutMs
2374
+ );
2375
+ return msg?.t === "timeline" ? msg.dump : void 0;
2376
+ }
2377
+ /**
2378
+ * D36: the room's own tick counters — `overruns` and `maxTickMs` above all. `undefined` when
2379
+ * the worker died or did not answer in time, and the caller must report that as *unavailable*
2380
+ * rather than as zero: a room shedding ticks and a room nobody asked look identical otherwise,
2381
+ * and that is exactly the lie the `tick-health` invariant exists to prevent.
2382
+ */
2383
+ async stats(timeoutMs = 2e3) {
2384
+ return (await this.statsMessage(timeoutMs))?.stats;
2385
+ }
2386
+ /**
2387
+ * The whole `stats` reply, including D55's `heap` ride-along. `stats()` above is the D36 shape
2388
+ * and stays as it was; this is the one callers use when they also want the worker's heap, which
2389
+ * is a fact about the thread rather than one of the room's own counters. Same round trip: the
2390
+ * heap number costs no extra message.
2391
+ */
2392
+ async statsMessage(timeoutMs = 2e3) {
2393
+ const msg = await this.request(
2394
+ (reqId) => ({ t: "stats", reqId }),
2395
+ timeoutMs
2396
+ );
2397
+ return msg?.t === "stats" ? msg : void 0;
2398
+ }
1254
2399
  /** Intentional shutdown: `onDead` is not fired. */
1255
2400
  async terminate() {
1256
2401
  if (this.disposed) return;
@@ -1271,14 +2416,44 @@ var WorkerHost = class {
1271
2416
  };
1272
2417
 
1273
2418
  // ../supervisor/src/server.ts
2419
+ function relayHostRoomFor(sup, room) {
2420
+ const relayRoom = room.relayRoom;
2421
+ if (!relayRoom) throw new Error("irtio: relay host adapter on a room with no relay presence");
2422
+ return {
2423
+ id: room.id,
2424
+ relayRoom,
2425
+ peers: () => room.clients.values(),
2426
+ maxClients: room.config.maxClients,
2427
+ countEgress(bytes) {
2428
+ room.metrics.egressBytes += bytes;
2429
+ room.metrics.framesOut++;
2430
+ },
2431
+ touched(tick) {
2432
+ room.lastTick = tick;
2433
+ }
2434
+ };
2435
+ }
1274
2436
  var WS_PING_MS = 2e4;
1275
2437
  var WS_PING_MISSES = 2;
1276
2438
  var MAX_STRIKES = 3;
1277
2439
  var JOIN_TIMEOUT_MS = 15e3;
2440
+ var IDENTITY_KEY_WAIT_MS = 2e3;
2441
+ var MAX_IDENTITY_KEY_WAITERS = 64;
2442
+ var RESUME_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
2443
+ var BUS_SEEN_MAX = 256;
2444
+ var SHARD_ASSIGNMENT_RETRY_MS = [1e3, 3e4];
1278
2445
  var KvUnavailable = class extends Error {
1279
2446
  code = KV_ERRORS.unavailable;
1280
2447
  name = "KvUnavailable";
1281
2448
  };
2449
+ var LeaderboardUnavailable = class extends Error {
2450
+ code = LEADERBOARD_ERRORS.unavailable;
2451
+ name = "LeaderboardUnavailable";
2452
+ };
2453
+ var RatingUnavailable = class extends Error {
2454
+ code = RATING_ERRORS.unavailable;
2455
+ name = "RatingUnavailable";
2456
+ };
1282
2457
  function codeOf(err) {
1283
2458
  const code = err?.code;
1284
2459
  return typeof code === "string" && code.startsWith("E_") ? code : void 0;
@@ -1322,6 +2497,7 @@ var SupervisorImpl = class {
1322
2497
  `placement carries ${this.pendingRestores.size} pending restore(s): ` + [...this.pendingRestores].map(([r, sv]) => `${r}<-${sv}`).join(", ")
1323
2498
  );
1324
2499
  }
2500
+ this.identityKeys = config.identityKeys ?? [];
1325
2501
  this.resumeSecret = config.resumeSecret ?? randomBytes(32).toString("base64url");
1326
2502
  if (config.resumeSecret === void 0) {
1327
2503
  this.log("warn", "no resumeSecret configured: resume tokens die with this process");
@@ -1332,6 +2508,15 @@ var SupervisorImpl = class {
1332
2508
  this.limits.connectionsPerIpPerMin / 60
1333
2509
  )
1334
2510
  );
2511
+ this.npcs = new NpcManager({
2512
+ projectId: config.projectId,
2513
+ openLoopback: (roomId, clientId) => this.openLoopback(roomId, clientId),
2514
+ log: (roomId, level, message) => {
2515
+ const room = this.registry.get(roomId);
2516
+ if (room) this.roomLog(room, level, message);
2517
+ else this.log(level, message);
2518
+ }
2519
+ });
1335
2520
  this.helloBuckets = new BucketMap(
1336
2521
  () => new TokenBucket(this.limits.hellosPerMin, this.limits.hellosPerMin / 60)
1337
2522
  );
@@ -1353,6 +2538,8 @@ var SupervisorImpl = class {
1353
2538
  res.writeHead(404).end();
1354
2539
  });
1355
2540
  this.wss = new WebSocketServer({ server: this.http });
2541
+ this.wss.on("error", () => {
2542
+ });
1356
2543
  this.wss.on("connection", (ws, req) => {
1357
2544
  this.onConnection(ws, {
1358
2545
  origin: header(req.headers.origin),
@@ -1370,6 +2557,11 @@ var SupervisorImpl = class {
1370
2557
  limits;
1371
2558
  store;
1372
2559
  processMetrics = newProcessMetrics();
2560
+ /**
2561
+ * D38: the ring behind the reserved `__tenant` room id. Same bounded ring every room gets, so
2562
+ * a noisy boot costs the same as a noisy room and nothing here can grow without limit.
2563
+ */
2564
+ tenantLogs = new LogRing();
1373
2565
  /** The newest runnable deployment; every new room is created under it. */
1374
2566
  bundle;
1375
2567
  schemaHash;
@@ -1380,9 +2572,21 @@ var SupervisorImpl = class {
1380
2572
  wss;
1381
2573
  readyPromise;
1382
2574
  ipBuckets;
2575
+ /**
2576
+ * D59: this tenant's channel-to-subscriber map, beside the room registry it already keeps.
2577
+ *
2578
+ * One per supervisor, which is one per tenant, which is what makes a cross-project reach
2579
+ * unrepresentable rather than refused: there is no other registry to look in and no project
2580
+ * argument on any path that reaches this.
2581
+ */
2582
+ bus = new BusRegistry();
2583
+ /** Monotonic within this process; feeds `mintMailboxName` so two sends never share a name. */
2584
+ mailboxCounter = 0;
1383
2585
  helloBuckets;
1384
2586
  resumeSecret;
1385
2587
  clientIds = /* @__PURE__ */ new Set();
2588
+ /** D44: the scripted NPCs this tenant is running, and the bot runtime driving each. */
2589
+ npcs;
1386
2590
  joinWaiters = /* @__PURE__ */ new Map();
1387
2591
  readyWaiters = /* @__PURE__ */ new Map();
1388
2592
  creating = /* @__PURE__ */ new Map();
@@ -1397,8 +2601,116 @@ var SupervisorImpl = class {
1397
2601
  * reported on `/admin/idle` so the host-agent can escalate instead of reading "not idle yet"
1398
2602
  * forever. Reset the moment a flush succeeds cleanly, or a client reconnects. */
1399
2603
  flushFailures = 0;
2604
+ /**
2605
+ * D56: `true` once any room worker in this VM generation has reported that a Rapier world
2606
+ * stepped, which is the point V8 tier-compiled the physics WASM and paid its RSS transient.
2607
+ * Reported on `/admin/idle` so the host agent can hold the tenant's *first* VM snapshot until
2608
+ * the compile is inside the image instead of being redone after every wake.
2609
+ *
2610
+ * Sticky for the life of the process, and deliberately not reset by the idle path: the fact is
2611
+ * about this VM's memory, not about the current idle window, and the room that set it has
2612
+ * usually been hibernated away by the time the tenant goes idle.
2613
+ */
2614
+ physicsWarm = false;
2615
+ /**
2616
+ * D56: measure the Rapier tier-up RSS transient where it actually matters — inside a placed
2617
+ * guest. The desktop re-measurement (2026-08-31) showed the transient peaks on V8's background
2618
+ * compile threads *seconds after* the first step returns (~107 MB above baseline on a many-core
2619
+ * machine, settling back), which a before/after sample around the step misses entirely; a
2620
+ * 1-vCPU guest's peak is the number `VM_MEM_MIB_PHYSICS` actually needs, and nobody has it.
2621
+ * So: on the first `physicsStepped`, sample process RSS (workers are threads, so this is the
2622
+ * whole VM's node process) every 250 ms for 12 s and log baseline, peak, and settled values.
2623
+ * One shot per process; timers unref'd so this never holds the tenant open.
2624
+ */
2625
+ notePhysicsWarm() {
2626
+ if (this.physicsWarm) return;
2627
+ this.physicsWarm = true;
2628
+ const mib = (b) => (b / 1024 / 1024).toFixed(1);
2629
+ const baseline = process.memoryUsage().rss;
2630
+ const t0 = Date.now();
2631
+ let peak = baseline;
2632
+ let peakAtMs = 0;
2633
+ const sampler = setInterval(() => {
2634
+ const rss = process.memoryUsage().rss;
2635
+ if (rss > peak) {
2636
+ peak = rss;
2637
+ peakAtMs = Date.now() - t0;
2638
+ }
2639
+ }, 250);
2640
+ sampler.unref?.();
2641
+ const done = setTimeout(() => {
2642
+ clearInterval(sampler);
2643
+ const settled = process.memoryUsage().rss;
2644
+ this.log(
2645
+ "info",
2646
+ `rapier first step: rss ${mib(baseline)} MiB at step, peak +${mib(peak - baseline)} MiB at ${peakAtMs} ms, settled +${mib(settled - baseline)} MiB after 12 s (D56 tier-up transient; VM_MEM_MIB_PHYSICS evidence)`
2647
+ );
2648
+ }, 12e3);
2649
+ done.unref?.();
2650
+ }
1400
2651
  idleTimer;
1401
2652
  pingTimer;
2653
+ /**
2654
+ * D47: this tenant's usage ledger. One supervisor serves one project, so it needs no project
2655
+ * id; the host agent stamps one on the way out. Public so a test can read the running totals
2656
+ * the way `/admin/usage` does.
2657
+ */
2658
+ usage = new UsageAccumulator();
2659
+ usageTimer;
2660
+ /**
2661
+ * D48: this tenant's cap state, pushed from control over `/admin/caps` and held in memory.
2662
+ *
2663
+ * Public for the same reason `usage` is: a test drives the real thing. Note what it is not: a
2664
+ * client of anything. It has no fetch, no database and no timer, so the pin test's claim that a
2665
+ * join touches no billing query is a property of the type rather than of a careful reading.
2666
+ */
2667
+ caps = new CapGate();
2668
+ /**
2669
+ * M5 part 3.5 (Part C): the leased egress budget this box spends locally.
2670
+ *
2671
+ * Beside `caps` rather than inside it, because they answer different questions at different
2672
+ * moments. `caps` answers "may this project start new work", once per join, from a picture
2673
+ * control drew up to ninety seconds ago. This answers "may this byte leave", on every send,
2674
+ * from a budget the box is decrementing itself. Both are needed and neither subsumes the other.
2675
+ */
2676
+ egressLease = new EgressLease();
2677
+ /**
2678
+ * Pricing amendment 1, decision 1: this project's sustained bandwidth ceiling.
2679
+ *
2680
+ * A third question beside the other two, asked at a third moment. `caps` answers "may this
2681
+ * project start new work", `egressLease` answers "may this byte leave at all this month", and
2682
+ * this answers "is this project sending too fast right now". Only the last one is recoverable
2683
+ * without anybody doing anything: it disengages on its own when the rate falls.
2684
+ *
2685
+ * Public for the same reason the other two are: a test drives the real thing.
2686
+ */
2687
+ bandwidth = new BandwidthCeiling();
2688
+ /**
2689
+ * D53: the platform public keys this tenant will accept identity assertions from, pushed from
2690
+ * control over `/admin/identity-keys` exactly the way cap state is.
2691
+ *
2692
+ * What matters about this field is what it is NOT: there is no private key here, and no way to
2693
+ * obtain one. A supervisor — and therefore any hostile room code sharing its process — can
2694
+ * check that an assertion was minted by the platform and can do nothing else with these bytes.
2695
+ * That is the whole reason the assertion is Ed25519 rather than an HMAC like the BYO-JWT path,
2696
+ * where verification key and minting key are the same string.
2697
+ *
2698
+ * Empty (a tenant that has not been handed keys yet) refuses assertion joins by name —
2699
+ * `E_ASSERTION_UNVERIFIABLE`, meaning retry — rather than failing open. Cap state fails open on
2700
+ * purpose; a credential check must not.
2701
+ */
2702
+ identityKeys = [];
2703
+ /**
2704
+ * M5 part 4: assertion HELLOs parked waiting for the first key push, and the cap on how many
2705
+ * may be parked at once.
2706
+ *
2707
+ * The cap is the safety argument. The wait sits AFTER the HELLO rate bucket, so nothing here
2708
+ * lets a caller open more sockets than the bucket already allows — but a parked socket is a
2709
+ * socket held open, and an unbounded list of them would turn a key-delivery gap into a way to
2710
+ * hold every connection a project is allowed. Past the cap the refusal is immediate, and it is
2711
+ * the same refusal, with the same code, that every one of these got before the wait existed.
2712
+ */
2713
+ identityKeyWaiters = [];
1402
2714
  /**
1403
2715
  * D24: `roomId -> saveId` placement instructions that have not been consumed yet. Populated
1404
2716
  * from `SupervisorConfig.restoreRooms` at boot — placement is the only channel into a tenant,
@@ -1416,6 +2728,7 @@ var SupervisorImpl = class {
1416
2728
  }
1417
2729
  this.bundle = this.newestBundle();
1418
2730
  this.schemaHash = this.bundle?.schemaHash;
2731
+ this.checkDeclaredSizing();
1419
2732
  this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
1420
2733
  await new Promise((resolve2, reject) => {
1421
2734
  this.http.once("error", reject);
@@ -1427,6 +2740,8 @@ var SupervisorImpl = class {
1427
2740
  });
1428
2741
  this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
1429
2742
  this.pingTimer.unref?.();
2743
+ this.usageTimer = setInterval(() => this.sampleUsage(), ROOM_SAMPLE_MS);
2744
+ this.usageTimer.unref?.();
1430
2745
  const tenantIdleMs = this.config.tenantIdleMs ?? 3e5;
1431
2746
  if (tenantIdleMs > 0) {
1432
2747
  const every = Math.min(1e3, Math.max(50, Math.floor(tenantIdleMs / 4)));
@@ -1437,7 +2752,29 @@ var SupervisorImpl = class {
1437
2752
  "info",
1438
2753
  `supervisor listening on ${this.port} (${this.bundle ? "bundle" : "relay"} tenant)`
1439
2754
  );
1440
- await this.resumeAlarmedRooms();
2755
+ this.logCompileCacheState();
2756
+ if (this.isSharded()) {
2757
+ void this.resumeAlarmedRooms().catch((err) => {
2758
+ this.log("warn", "the shard-assigned boot scan failed", err);
2759
+ });
2760
+ } else {
2761
+ await this.resumeAlarmedRooms();
2762
+ }
2763
+ }
2764
+ /**
2765
+ * D56 fix 3 verification: say at boot whether this process sees the compile cache baked into
2766
+ * the rootfs, so the first live evidence that the bake reached a guest is a log line instead
2767
+ * of an ssh session into a paused VM. One line, boot only; absent env means dev, and says so.
2768
+ */
2769
+ logCompileCacheState() {
2770
+ const dir = process.env.NODE_COMPILE_CACHE;
2771
+ if (dir === void 0 || dir === "") return;
2772
+ try {
2773
+ const entries = readdirSync(dir, { recursive: true }).length;
2774
+ this.log("info", `compile cache at ${dir}: ${entries} entries`);
2775
+ } catch (err) {
2776
+ this.log("warn", `compile cache at ${dir}: unreadable (${err.message})`);
2777
+ }
1441
2778
  }
1442
2779
  /**
1443
2780
  * D26, and the piece staging caught missing: **start every room that owes an alarm**, at boot.
@@ -1484,11 +2821,16 @@ var SupervisorImpl = class {
1484
2821
  );
1485
2822
  return;
1486
2823
  }
1487
- this.log(
1488
- "info",
1489
- `resuming ${rooms.size} room(s) with pending alarms: ${[...rooms].join(", ")}`
1490
- );
1491
- for (const roomId of [...rooms].sort()) {
2824
+ const mine = this.isSharded() ? await this.assignedRooms([...rooms].sort()) : [...rooms].sort();
2825
+ if (mine.length === 0) {
2826
+ this.log(
2827
+ "info",
2828
+ `no rooms to resume on this shard: ${rooms.size} candidate(s) belong elsewhere`
2829
+ );
2830
+ return;
2831
+ }
2832
+ this.log("info", `resuming ${mine.length} room(s) with pending alarms: ${mine.join(", ")}`);
2833
+ for (const roomId of mine) {
1492
2834
  try {
1493
2835
  await this.ensureRoom(roomId);
1494
2836
  } catch (err) {
@@ -1496,6 +2838,42 @@ var SupervisorImpl = class {
1496
2838
  }
1497
2839
  }
1498
2840
  }
2841
+ /** D67-d: true for a shard of a project with more than one; false for every tenant today. */
2842
+ isSharded() {
2843
+ return (this.config.shards ?? 1) > 1;
2844
+ }
2845
+ /**
2846
+ * D67-d: the subset of `candidates` this shard owns, from the control plane through the
2847
+ * gateway's `/shard/filter`. Retries 1, 2, 4 ... 30 s (capped, never giving up) on any failure,
2848
+ * logging "waiting for shard assignment" at warn each time, because a shard that guesses would
2849
+ * either double-start rooms another shard holds or leave alarms undelivered; waiting is the only
2850
+ * honest state. The sleep is injected so the schedule is tested with a recorded fake.
2851
+ */
2852
+ async assignedRooms(candidates) {
2853
+ const shard = this.config.shard ?? 0;
2854
+ const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((resolve2) => {
2855
+ const t = setTimeout(resolve2, ms);
2856
+ t.unref?.();
2857
+ }));
2858
+ let delayMs = SHARD_ASSIGNMENT_RETRY_MS[0];
2859
+ for (let attempt = 1; ; attempt++) {
2860
+ if (this.closing) return [];
2861
+ try {
2862
+ const client = this.config.shardClient;
2863
+ if (!client) throw new Error("no shard client is configured on this tenant");
2864
+ const mine = await client.filter(shard, candidates);
2865
+ const known = new Set(candidates);
2866
+ return mine.filter((r) => known.has(r)).sort();
2867
+ } catch (err) {
2868
+ this.log(
2869
+ "warn",
2870
+ `waiting for shard assignment (shard ${shard} of ${this.config.shards ?? 1}, attempt ${attempt}, ${candidates.length} candidate(s), retry in ${delayMs} ms): ${err instanceof Error ? err.message : String(err)}`
2871
+ );
2872
+ await sleep2(delayMs);
2873
+ delayMs = Math.min(delayMs * 2, SHARD_ASSIGNMENT_RETRY_MS[1]);
2874
+ }
2875
+ }
2876
+ }
1499
2877
  /**
1500
2878
  * Everything a **VM-snapshot restore** owes the alarm clock, run from `/admin/resume` once the
1501
2879
  * guest wall clock has been corrected.
@@ -1554,7 +2932,7 @@ var SupervisorImpl = class {
1554
2932
  * rooms that are still serving the old code.
1555
2933
  */
1556
2934
  async reloadBundle(bundlePath) {
1557
- const loaded = await loadBundle(bundlePath, this.deploymentVersion);
2935
+ const loaded = await loadBundle(bundlePath, this.deploymentVersion, DEFAULT_ROOM_TYPE3);
1558
2936
  for (const room of this.registry.values()) {
1559
2937
  room.wakeQueue.length = 0;
1560
2938
  room.droppedWakeFrames = 0;
@@ -1564,18 +2942,36 @@ var SupervisorImpl = class {
1564
2942
  this.deployments.set(loaded.version, {
1565
2943
  version: loaded.version,
1566
2944
  schemaJson: loaded.schemaJson,
1567
- bundle: loaded
2945
+ bundle: loaded,
2946
+ bundles: /* @__PURE__ */ new Map([[DEFAULT_ROOM_TYPE3, loaded]])
1568
2947
  });
1569
2948
  this.bundle = loaded;
1570
2949
  this.schemaHash = loaded.schemaHash;
1571
2950
  this.log("info", `bundle reloaded from ${bundlePath}`);
1572
2951
  }
2952
+ /**
2953
+ * D47: banks every chargeable room's alive-time into the ledger. Called on
2954
+ * {@link ROOM_SAMPLE_MS}, on close, and immediately before `/admin/usage` is read, so what the
2955
+ * host agent ships is never a second stale.
2956
+ */
2957
+ sampleUsage(now = Date.now()) {
2958
+ sampleRoomClocks(this.registry.values(), this.usage, now);
2959
+ }
2960
+ /** The `/admin/usage` body, and the ledger read the host agent ships. */
2961
+ usageWindows(now = Date.now()) {
2962
+ this.sampleUsage(now);
2963
+ return this.usage.windows(now);
2964
+ }
1573
2965
  async close() {
1574
2966
  if (this.closing) return;
1575
2967
  this.closing = true;
1576
2968
  this.quiesceAlarms();
2969
+ this.npcs.despawnAll();
2970
+ this.releaseIdentityKeyWaiters();
1577
2971
  if (this.idleTimer) clearInterval(this.idleTimer);
1578
2972
  if (this.pingTimer) clearInterval(this.pingTimer);
2973
+ this.sampleUsage();
2974
+ if (this.usageTimer) clearInterval(this.usageTimer);
1579
2975
  try {
1580
2976
  await flushAll(this);
1581
2977
  } catch (err) {
@@ -1601,13 +2997,50 @@ var SupervisorImpl = class {
1601
2997
  // -------------------------------------------------------------------------
1602
2998
  // Logging
1603
2999
  // -------------------------------------------------------------------------
3000
+ /**
3001
+ * D38: a tenant-level event now enters the log pipeline instead of only reaching the console.
3002
+ *
3003
+ * `/admin/logs` reads per-room rings, so before this every event that was not about a room —
3004
+ * boot, resume, deploy, placement, alarm recovery, storage failures — was invisible to the host
3005
+ * agent, to the control plane, and to `irtio logs`. Those are precisely the lines somebody
3006
+ * reaches for when a project is not working, and the only way to see them was to be on the box
3007
+ * reading the guest console.
3008
+ *
3009
+ * They go into a ring under the reserved id `__tenant` (D38), so the whole existing pipeline
3010
+ * carries them with no new route and no new frame.
3011
+ */
1604
3012
  log(level, ...args) {
3013
+ this.tenantLogs.push(level, args);
3014
+ this.emit(level, args);
3015
+ }
3016
+ /** The console/config sink, with no ring behind it. */
3017
+ emit(level, args) {
1605
3018
  if (this.config.log) this.config.log(level, ...args);
1606
3019
  else console[level === "info" ? "log" : level]("[irtio]", ...args);
1607
3020
  }
3021
+ /** A room event: it belongs in that room's ring, and nowhere near the `__tenant` one. */
3022
+ /**
3023
+ * D44: opens one NPC session and hands back the client half of its socket. It goes straight
3024
+ * through `onConnection` — the same function the WebSocket server calls — so an NPC and a
3025
+ * browser differ by a flag on the accept, and by nothing at all after it.
3026
+ */
3027
+ openLoopback(roomId, clientId) {
3028
+ const { server, client } = loopbackPair();
3029
+ this.onConnection(server, {
3030
+ loopback: { clientId },
3031
+ origin: void 0,
3032
+ routerOrigin: void 0,
3033
+ forwardedFor: void 0,
3034
+ routerSecret: void 0,
3035
+ // Never a real address, and deliberately not `127.0.0.1`: an operator reading a session
3036
+ // list should be able to tell an NPC from something dialling the loopback interface.
3037
+ remoteAddress: `npc:${roomId}`
3038
+ });
3039
+ return client;
3040
+ }
1608
3041
  roomLog(room, level, ...args) {
1609
3042
  room.log(level, ...args);
1610
- this.log(level, `[${room.id}]`, ...args);
3043
+ this.emit(level, [`[${room.id}]`, ...args]);
1611
3044
  }
1612
3045
  /**
1613
3046
  * Week-5 snapshot keys carry the deployment version (`@v3`); version `0` keeps the week-3 key
@@ -1647,18 +3080,32 @@ var SupervisorImpl = class {
1647
3080
  // §10.8 escalation: consecutive flush passes that left a room unflushed. The host agent
1648
3081
  // treats >=3 as worth an error-level log (once, deduped) instead of reading the tenant
1649
3082
  // as merely slow to go idle.
1650
- flushFailures: this.flushFailures
3083
+ flushFailures: this.flushFailures,
3084
+ // D56: has a Rapier world stepped in this VM yet? The agent gates a cold-booted
3085
+ // tenant's first snapshot on it. Additive; an agent that ignores it behaves as before.
3086
+ physicsWarm: this.physicsWarm
1651
3087
  });
1652
3088
  return true;
1653
3089
  case "/admin/rooms":
1654
3090
  json({ rooms: this.rooms(), deploymentVersion: this.deploymentVersion });
1655
3091
  return true;
1656
3092
  case "/admin/metrics":
1657
- json({
1658
- at: Date.now(),
1659
- metrics: this.metrics(),
1660
- rooms: this.rooms().map((r) => r.metrics)
1661
- });
3093
+ void this.refreshTickHealth().then(
3094
+ () => json({
3095
+ at: Date.now(),
3096
+ metrics: this.metrics(),
3097
+ rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
3098
+ // D47: the usage ledger rides the poll the agent already makes. A separate route
3099
+ // would be a fourth round trip per tenant per poll for four rows.
3100
+ usage: this.usageWindows()
3101
+ }),
3102
+ () => json({
3103
+ at: Date.now(),
3104
+ metrics: this.metrics(),
3105
+ rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
3106
+ usage: this.usageWindows()
3107
+ })
3108
+ );
1662
3109
  return true;
1663
3110
  // D31 (week 7): the ONE write on this otherwise read-only surface, and a deliberate
1664
3111
  // exception to its "the agent must not be able to change anything" rule: after a
@@ -1768,10 +3215,31 @@ var SupervisorImpl = class {
1768
3215
  migrationPath = nodePath.join(dir, `m${d.version}.mjs`);
1769
3216
  await writeFile(migrationPath, Buffer.from(d.migrationB64, "base64"));
1770
3217
  }
3218
+ let rooms2;
3219
+ if (d.roomsB64 !== void 0) {
3220
+ if (typeof d.roomsB64 !== "object" || d.roomsB64 === null || Array.isArray(d.roomsB64)) {
3221
+ throw new Error("roomsB64 must be an object of room type to base64 bundle");
3222
+ }
3223
+ rooms2 = {};
3224
+ for (const [type, b64] of Object.entries(d.roomsB64)) {
3225
+ if (!ROOM_TYPE_RE.test(type)) {
3226
+ throw new Error(
3227
+ `roomsB64 names an illegal room type ${JSON.stringify(type)}`
3228
+ );
3229
+ }
3230
+ if (typeof b64 !== "string") {
3231
+ throw new Error(`roomsB64.${type} must be a base64 bundle`);
3232
+ }
3233
+ const typePath = nodePath.join(dir, `v${d.version}.${type}.mjs`);
3234
+ await writeFile(typePath, Buffer.from(b64, "base64"));
3235
+ rooms2[type] = typePath;
3236
+ }
3237
+ }
1771
3238
  await this.addBundle({
1772
3239
  version: d.version,
1773
3240
  ...typeof d.schemaJson === "string" ? { schemaJson: d.schemaJson } : {},
1774
3241
  ...bundlePath !== void 0 ? { bundlePath } : {},
3242
+ ...rooms2 !== void 0 ? { rooms: rooms2 } : {},
1775
3243
  ...migrationPath !== void 0 ? { migrationPath } : {}
1776
3244
  });
1777
3245
  added.push(d.version);
@@ -1796,32 +3264,187 @@ var SupervisorImpl = class {
1796
3264
  });
1797
3265
  return true;
1798
3266
  }
1799
- case "/admin/logs": {
1800
- const since = Number(url.searchParams.get("since") ?? 0) || 0;
1801
- const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
1802
- const entries = [];
1803
- for (const room of this.registry.values()) {
1804
- for (const entry of this.logs(room.id)) {
1805
- if (entry.at <= since) continue;
1806
- entries.push({
1807
- at: entry.at,
1808
- level: entry.level,
1809
- roomId: room.id,
1810
- message: entry.args.map((x) => typeof x === "string" ? x : String(x)).join(" ")
1811
- });
1812
- }
3267
+ // D48: the THIRD write on this otherwise read-only surface, and the reasoning is the same
3268
+ // one that carved out the other two. Cap state changes at runtime by design (a meter
3269
+ // crosses, a card is added, an owner raises a ceiling) and the boot-time env channel is
3270
+ // closed to it: env is hashed into the VM snapshot's identity (D29), so a cap that rode it
3271
+ // would cold-boot the fleet on every cap change. GET answers what this tenant currently
3272
+ // believes, which is the thing an operator wants when a join is being refused and nobody
3273
+ // can see why.
3274
+ // M5 part 7 (D67-e): the FOURTH write on this surface. A bus message another shard sent to
3275
+ // a room on this one, forwarded by the control plane through the host agent. Load-if-exists
3276
+ // then the local park; never a room from nothing. Answers the local `busSend` result so the
3277
+ // sender's promise resolves with the same refusals it would see on one shard.
3278
+ case "/admin/bus/deliver": {
3279
+ if (req.method !== "POST") {
3280
+ res.writeHead(405, { "content-type": "application/json" });
3281
+ res.end('{"error":"POST only"}');
3282
+ return true;
1813
3283
  }
1814
- entries.sort((x, y) => x.at - y.at);
1815
- json({ entries: entries.slice(-limit) });
3284
+ let body = "";
3285
+ req.on("data", (d) => {
3286
+ body += d.toString();
3287
+ });
3288
+ req.on("end", () => {
3289
+ void (async () => {
3290
+ let parsed;
3291
+ try {
3292
+ parsed = JSON.parse(body || "{}");
3293
+ } catch {
3294
+ res.writeHead(400, { "content-type": "application/json" });
3295
+ res.end('{"error":"body is not JSON"}');
3296
+ return;
3297
+ }
3298
+ const { to, from, payload, id } = parsed;
3299
+ if (typeof to !== "string" || typeof from !== "string" || typeof payload !== "string" || typeof id !== "string") {
3300
+ res.writeHead(400, { "content-type": "application/json" });
3301
+ res.end('{"error":"to, from, payload and id must be strings"}');
3302
+ return;
3303
+ }
3304
+ if (busPayloadProblem(payload)) {
3305
+ res.writeHead(400, { "content-type": "application/json" });
3306
+ res.end('{"error":"payload over the bus limit"}');
3307
+ return;
3308
+ }
3309
+ try {
3310
+ json(await this.deliverForwarded({ to, from, payload, id }));
3311
+ } catch (err) {
3312
+ this.log("error", "admin bus deliver failed", err);
3313
+ res.writeHead(500, { "content-type": "application/json" });
3314
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
3315
+ }
3316
+ })();
3317
+ });
1816
3318
  return true;
1817
3319
  }
1818
- default:
1819
- res.writeHead(404, { "content-type": "application/json" });
1820
- res.end('{"error":"not found"}');
1821
- return true;
1822
- }
1823
- }
1824
- storeKey(room, version) {
3320
+ case "/admin/caps": {
3321
+ if (req.method === "GET") {
3322
+ const state = this.caps.current();
3323
+ json({
3324
+ present: state !== void 0,
3325
+ ageMs: this.caps.ageMs() ?? null,
3326
+ state: state ?? null
3327
+ });
3328
+ return true;
3329
+ }
3330
+ if (req.method !== "POST") {
3331
+ res.writeHead(405, { "content-type": "application/json" });
3332
+ res.end('{"error":"GET or POST"}');
3333
+ return true;
3334
+ }
3335
+ let body = "";
3336
+ req.on("data", (d) => {
3337
+ body += d.toString();
3338
+ });
3339
+ req.on("end", () => {
3340
+ let parsed;
3341
+ try {
3342
+ parsed = JSON.parse(body || "null");
3343
+ } catch {
3344
+ res.writeHead(400, { "content-type": "application/json" });
3345
+ res.end('{"error":"body is not JSON"}');
3346
+ return;
3347
+ }
3348
+ const state = parseCapState(parsed);
3349
+ if (!state) {
3350
+ res.writeHead(400, { "content-type": "application/json" });
3351
+ res.end('{"error":"body is not a cap state"}');
3352
+ return;
3353
+ }
3354
+ this.caps.apply(state);
3355
+ this.bandwidth.setPlan(state.plan);
3356
+ const leaseBytes = this.caps.leaseFor("egress_bytes");
3357
+ if (leaseBytes !== void 0) {
3358
+ const wasTripped = this.egressLease.tripped;
3359
+ this.egressLease.apply({
3360
+ remainingBytes: leaseBytes,
3361
+ periodStart: state.periodStart
3362
+ });
3363
+ if (this.egressLease.tripped && !wasTripped) this.tripEgressWall();
3364
+ else if (!this.egressLease.tripped && wasTripped) {
3365
+ this.log("info", "egress wall lifted: a fresh budget was leased");
3366
+ }
3367
+ }
3368
+ const crossed = state.meters.filter((m) => m.mode === "hard" && m.status === "crossed").map((m) => m.meter);
3369
+ if (crossed.length > 0) {
3370
+ this.log("warn", `usage caps applied: hard cap reached on ${crossed.join(", ")}`);
3371
+ }
3372
+ json({ ok: true, applied: state.meters.length, crossed });
3373
+ });
3374
+ return true;
3375
+ }
3376
+ /**
3377
+ * D53: the platform identity public keys, pushed the way cap state is and for the same
3378
+ * reason — an env var would be hashed into the VM snapshot's identity (D29) and every key
3379
+ * rotation would cold-boot the fleet.
3380
+ *
3381
+ * GET answers what this tenant currently holds (kids only; the PEMs are public but an
3382
+ * operator debugging a refused join wants the fingerprints, not a wall of base64). POST
3383
+ * replaces the set wholesale: two keys may be active at once, so a rotation is an overlap
3384
+ * rather than a flag day, exactly like `irtio keys jwt-secret`.
3385
+ */
3386
+ case "/admin/identity-keys": {
3387
+ if (req.method === "GET") {
3388
+ json({ kids: this.identityKeys.map((k) => k.kid) });
3389
+ return true;
3390
+ }
3391
+ if (req.method !== "POST") {
3392
+ res.writeHead(405, { "content-type": "application/json" });
3393
+ res.end('{"error":"GET or POST"}');
3394
+ return true;
3395
+ }
3396
+ let body = "";
3397
+ req.on("data", (d) => {
3398
+ body += d.toString();
3399
+ });
3400
+ req.on("end", () => {
3401
+ let parsed;
3402
+ try {
3403
+ parsed = JSON.parse(body || "null");
3404
+ } catch {
3405
+ res.writeHead(400, { "content-type": "application/json" });
3406
+ res.end('{"error":"body is not JSON"}');
3407
+ return;
3408
+ }
3409
+ const keys = parseIdentityKeys(parsed);
3410
+ if (!keys) {
3411
+ res.writeHead(400, { "content-type": "application/json" });
3412
+ res.end('{"error":"body is not {keys: [{kid, publicKeyPem}]}"}');
3413
+ return;
3414
+ }
3415
+ this.identityKeys = keys;
3416
+ if (keys.length > 0) this.releaseIdentityKeyWaiters();
3417
+ json({ ok: true, kids: keys.map((k) => k.kid) });
3418
+ });
3419
+ return true;
3420
+ }
3421
+ case "/admin/logs": {
3422
+ const since = Number(url.searchParams.get("since") ?? 0) || 0;
3423
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
3424
+ const entries = [];
3425
+ const streams = [TENANT_ROOM_ID, ...this.registry.values().map((r) => r.id)];
3426
+ for (const roomId of streams) {
3427
+ for (const entry of this.logs(roomId)) {
3428
+ if (entry.at <= since) continue;
3429
+ entries.push({
3430
+ at: entry.at,
3431
+ level: entry.level,
3432
+ roomId,
3433
+ message: entry.args.map((x) => typeof x === "string" ? x : String(x)).join(" ")
3434
+ });
3435
+ }
3436
+ }
3437
+ entries.sort((x, y) => x.at - y.at);
3438
+ json({ entries: entries.slice(-limit) });
3439
+ return true;
3440
+ }
3441
+ default:
3442
+ res.writeHead(404, { "content-type": "application/json" });
3443
+ res.end('{"error":"not found"}');
3444
+ return true;
3445
+ }
3446
+ }
3447
+ storeKey(room, version) {
1825
3448
  const id = typeof room === "string" ? room : room.id;
1826
3449
  const v = version ?? (typeof room === "string" ? 0 : room.version);
1827
3450
  const base = `${this.config.projectId}/${id}`;
@@ -1843,7 +3466,25 @@ var SupervisorImpl = class {
1843
3466
  }
1844
3467
  async registerDeployment(ref) {
1845
3468
  const migrationUrl = ref.migrationPath !== void 0 ? toFileUrl(ref.migrationPath) : void 0;
1846
- const bundle = ref.bundlePath !== void 0 ? await loadBundle(ref.bundlePath, ref.version) : void 0;
3469
+ const bundles = /* @__PURE__ */ new Map();
3470
+ if (ref.bundlePath !== void 0) {
3471
+ bundles.set(
3472
+ DEFAULT_ROOM_TYPE3,
3473
+ await loadBundle(ref.bundlePath, ref.version, DEFAULT_ROOM_TYPE3)
3474
+ );
3475
+ }
3476
+ for (const [type, path2] of Object.entries(ref.rooms ?? {})) {
3477
+ if (!ROOM_TYPE_RE.test(type)) {
3478
+ throw new Error(
3479
+ `irtio: deployment v${ref.version} names a room type ${JSON.stringify(type)}, which is not a legal type name (lowercase, starting with a letter or digit, up to 24 characters)`
3480
+ );
3481
+ }
3482
+ if (bundles.has(type)) {
3483
+ throw new Error(`irtio: deployment v${ref.version} defines the room type ${type} twice`);
3484
+ }
3485
+ bundles.set(type, await loadBundle(path2, ref.version, type));
3486
+ }
3487
+ const bundle = bundles.get(DEFAULT_ROOM_TYPE3);
1847
3488
  const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
1848
3489
  if (schemaJson === void 0) {
1849
3490
  throw new Error(
@@ -1853,14 +3494,210 @@ var SupervisorImpl = class {
1853
3494
  this.deployments.set(ref.version, {
1854
3495
  version: ref.version,
1855
3496
  schemaJson,
3497
+ bundles,
1856
3498
  ...migrationUrl !== void 0 ? { migrationUrl } : {},
1857
3499
  ...bundle !== void 0 ? { bundle } : {}
1858
3500
  });
1859
3501
  }
3502
+ /**
3503
+ * The newest loaded bundle for a room type, or `undefined` when the tenant defines no such type.
3504
+ *
3505
+ * Deliberately distinct from "no bundle at all": a tenant with room types that is asked for a
3506
+ * type it does not have has made a naming mistake, and answering that with the relay rung would
3507
+ * silently give the client a schema-less room instead of an error it can read.
3508
+ */
3509
+ bundleFor(type) {
3510
+ let best;
3511
+ for (const d of this.deployments.values()) {
3512
+ const b = d.bundles.get(type);
3513
+ if (b && (!best || b.version > best.version)) best = b;
3514
+ }
3515
+ return best;
3516
+ }
3517
+ /** Every room type this tenant defines, newest deployment first, sorted for stable messages. */
3518
+ roomTypes() {
3519
+ const types = /* @__PURE__ */ new Set();
3520
+ for (const d of this.deployments.values()) for (const type of d.bundles.keys()) types.add(type);
3521
+ return [...types].sort();
3522
+ }
3523
+ /**
3524
+ * M5 part 3.5: does what this project declared fit the VM it was given?
3525
+ *
3526
+ * **This check inverted in part 3.5, and what it does on failure inverted with it.** In part 2
3527
+ * the VM size was a class guess and the declarations were checked against it, so a mismatch was
3528
+ * a real possibility for an ordinary project and logging was the only defensible response:
3529
+ * refusing to boot would have stopped a project that was serving players, over arithmetic that
3530
+ * was deliberately pessimistic.
3531
+ *
3532
+ * Now the declarations *decide* the size, through the same `vmSizeFor` the host agent calls
3533
+ * before it builds the machine. An ordinary project therefore fits by construction and this
3534
+ * check can only fail one way: the project set an explicit `vmMemMib` override smaller than its
3535
+ * own declarations. That is not pessimism meeting reality, it is a project having stated two
3536
+ * incompatible things, and the honest answer to it pre-release is to refuse the room set at
3537
+ * boot rather than to serve rooms inside a machine that cannot hold them.
3538
+ *
3539
+ * No VM budget means a dev box, and dev keeps the generous defaults untouched.
3540
+ */
3541
+ checkDeclaredSizing() {
3542
+ const vmMemMib = this.config.vmMemMib;
3543
+ if (vmMemMib === void 0) return;
3544
+ const types = this.roomTypes();
3545
+ if (types.length === 0) return;
3546
+ const declared = types.map((type) => {
3547
+ const config = this.bundleFor(type)?.config;
3548
+ return {
3549
+ type,
3550
+ memoryMb: config?.memoryMb,
3551
+ ...config?.maxAwake !== void 0 ? { maxAwake: config.maxAwake } : {},
3552
+ ...config?.engine !== void 0 ? { physics: true } : {}
3553
+ };
3554
+ });
3555
+ const fit = checkDeclaredFit(vmMemMib, declared);
3556
+ if (fit.fits) {
3557
+ this.log("info", `room type sizing fits the VM budget: ${fit.detail}`);
3558
+ return;
3559
+ }
3560
+ const message = `this project's room types do not fit its VM: ${fit.detail}. Arithmetic sizing gives a project the VM its declarations ask for, so the only way to reach this is an explicit vmMemMib override smaller than the declarations under it. These types need ${fit.requiredMib} MiB and the override booked ${fit.budgetMib}. Raise or remove the override, or lower a memoryMb or maxAwake declaration.` + (fit.undeclared.length > 0 ? ` Counted at the platform default because they declare no memoryMb: ${fit.undeclared.join(", ")}.` : "");
3561
+ this.log("error", message);
3562
+ throw new Error(`irtio: ${message}`);
3563
+ }
3564
+ /** D58: the worker limits a room of this type runs under: its declaration, or the default. */
3565
+ limitsFor(type) {
3566
+ const declared = this.bundleFor(type)?.config.memoryMb;
3567
+ if (declared === void 0) return this.limits;
3568
+ return {
3569
+ workerMaxOldGenMb: declared,
3570
+ workerMaxYoungGenMb: this.limits.workerMaxYoungGenMb
3571
+ };
3572
+ }
3573
+ /**
3574
+ * M5 part 3.5: how many rooms of a type are holding worker memory right now.
3575
+ *
3576
+ * Awake means "costs the VM its declared heap": starting, running, waking, and hibernating (a
3577
+ * room mid-sleep still has its worker). A hibernated or closed room holds nothing, which is why
3578
+ * `maxAwake` is a bound on this number and not on how many rooms a project may ever have.
3579
+ */
3580
+ awakeOfType(type) {
3581
+ let n = 0;
3582
+ for (const room of this.registry.values()) {
3583
+ if (room.relay) continue;
3584
+ if (roomTypeOf(room.id) !== type) continue;
3585
+ if (room.state === "hibernated" || room.state === "closed") continue;
3586
+ n++;
3587
+ }
3588
+ return n;
3589
+ }
3590
+ /**
3591
+ * The refusal for starting a *new* room of a type that is already at its **declared** `maxAwake`,
3592
+ * or `undefined` when there is room.
3593
+ *
3594
+ * **This is what makes declared sizing honest rather than optimistic.** The tenant's VM was
3595
+ * built with `(memoryMb + youngGen) x maxAwake` MiB for this type, so room N+1 is a room the
3596
+ * machine was never sized to hold; admitting it would recreate exactly the "several under-cap
3597
+ * rooms sum past the VM" caveat part 1 recorded and part 3.5 exists to close.
3598
+ *
3599
+ * Four boundaries, stated rather than left implicit, because each of them narrows the claim.
3600
+ *
3601
+ * - **It applies only to a placed tenant.** No `IRT_VM_MEM_MIB` means a dev box, and a dev box
3602
+ * has headroom rather than a machine sized to a declaration — which is the same signal, and
3603
+ * the same reasoning, that leaves `DEFAULT_LIMITS` alone in `heap-caps.ts`. It is also what
3604
+ * `games/dive`'s end-to-end suite discovered the hard way: enforcing a production-shaped bound
3605
+ * against a developer's laptop refuses rooms nothing was going to run out of memory over. The
3606
+ * cost is that a developer meets this refusal in production rather than in dev, which is
3607
+ * recorded as a debt.
3608
+ *
3609
+ * - **Only a declared `maxAwake` is enforced.** An undeclared type is *sized* at one awake room,
3610
+ * which is the conservative number for arithmetic, but it is not *capped* at one. Capping it
3611
+ * would invent a limit no project asked for and would stop every existing multi-room project
3612
+ * dead at its second room. So for an undeclared type the pre-part-3.5 situation stands
3613
+ * unchanged: rooms are contained by their heap caps and their sum is not bounded. The bound is
3614
+ * something a project opts into by saying a number, and the sum is exact for the types that
3615
+ * did. This is narrower than the plan asked for and it is recorded as such.
3616
+ * - **Relay rooms are exempt.** They have no bundle, so they have no declaration, and their
3617
+ * client cap is a `SupervisorLimits` knob rather than a room-type one.
3618
+ * - **A wake is not refused, only a create.** Refusing to wake an existing room would leave a
3619
+ * room its players cannot get back into and its state stranded, which is the "silently dies
3620
+ * under a cap" failure D48 was built around avoiding. The consequence is that a project which
3621
+ * accumulates hibernated rooms can exceed its declared concurrency by waking them. Recorded as
3622
+ * a debt rather than hidden here: the bound bites where rooms are *created*, which is the
3623
+ * point a project controls.
3624
+ */
3625
+ maxAwakeRefusal(roomId) {
3626
+ if (this.config.vmMemMib === void 0) return void 0;
3627
+ const type = roomTypeOf(roomId);
3628
+ const declared = this.bundleFor(type)?.config.maxAwake;
3629
+ if (declared === void 0) return void 0;
3630
+ if (this.awakeOfType(type) < declared) return void 0;
3631
+ return `room type ${JSON.stringify(type)} already has ${declared} room${declared === 1 ? "" : "s"} awake, which is what it declared. The tenant VM was sized for exactly that many, so starting another would put this project over the memory it asked for. Raise \`maxAwake\` in this type's defineRoom config and deploy; the change takes effect at the next placement.`;
3632
+ }
3633
+ /**
3634
+ * M5 part 3.5 (Part C): how many rooms of any type are holding a worker right now.
3635
+ *
3636
+ * The same "awake" the per-type bound uses: starting, running, waking and hibernating hold a
3637
+ * worker; hibernated and closed do not. Relay rooms count, because a relay room is a real room
3638
+ * in the registry with real sockets attached, and the cap exists to bound exactly the burst a
3639
+ * crowd of relay rooms produces.
3640
+ */
3641
+ awakeRooms() {
3642
+ let n = 0;
3643
+ for (const room of this.registry.values()) {
3644
+ if (room.state === "hibernated" || room.state === "closed") continue;
3645
+ n++;
3646
+ }
3647
+ return n;
3648
+ }
3649
+ /**
3650
+ * The refusal for starting a new room on a free project that is already at the concurrent
3651
+ * awake-rooms cap, or `undefined` when there is room.
3652
+ *
3653
+ * Free only, deliberately. A card project's exposure is bounded by money — the included tier,
3654
+ * then metered overage under a spend cap it controls — and adding a shape cap on top would be
3655
+ * refusing work a customer has agreed to pay for.
3656
+ *
3657
+ * A box with no cap state does not enforce this, for the same reason it enforces no other cap:
3658
+ * the failure direction is "serve", and a fleet socket blinking must not look like a free tier.
3659
+ */
3660
+ freeAwakeRoomsRefusal() {
3661
+ if (this.caps.plan() !== "free") return void 0;
3662
+ const cap = this.limits.freeMaxAwakeRooms;
3663
+ if (this.awakeRooms() < cap) return void 0;
3664
+ return `this project is on the free plan, which allows ${cap} rooms awake at the same time, and ${cap} are awake now. Rooms that go idle free a slot within a minute. Add a card in the billing page to lift the limit.`;
3665
+ }
3666
+ /**
3667
+ * M5 part 3.5 (Part C): the wall trips. Forwarding stops and every session closes.
3668
+ *
3669
+ * Three things are deliberate about what this does and does not do.
3670
+ *
3671
+ * **Sockets close, rooms do not.** Rooms hibernate on their ordinary idle path once their
3672
+ * clients are gone, which means state is snapshotted normally and comes back when the period
3673
+ * rolls or a card is added. A wall that destroyed rooms would turn a billing event into data
3674
+ * loss, which is the "silently dies under a cap" failure this milestone is built around
3675
+ * avoiding.
3676
+ *
3677
+ * **Every socket gets the distinct close code**, not just the ones that were sending. A player
3678
+ * who was idle when the wall tripped needs the same explanation as one who was not.
3679
+ *
3680
+ * **NPCs are closed too, and that is not a contradiction of their exemption.** An NPC's bytes
3681
+ * are loopback and never counted against the lease, so an NPC cannot spend a project into the
3682
+ * wall. But an NPC in a room whose players have all been disconnected is a worker burning
3683
+ * room-hours for an audience of nobody.
3684
+ */
3685
+ tripEgressWall() {
3686
+ const plan = this.caps.plan() ?? "free";
3687
+ const message = egressWallMessage(plan);
3688
+ this.log(
3689
+ "warn",
3690
+ `egress wall tripped: the leased data-out budget is spent. Closing ${this.sessions.size} session${this.sessions.size === 1 ? "" : "s"} with close code ${CLOSE_EGRESS_WALL}. ` + message
3691
+ );
3692
+ for (const session of [...this.sessions]) {
3693
+ if (!session.open) continue;
3694
+ session.failWith("E_EGRESS_WALL", message, CLOSE_EGRESS_WALL);
3695
+ }
3696
+ }
1860
3697
  /** Registers a newer deployment. Rooms already running keep theirs and drain (plan §3.3). */
1861
3698
  async addBundle(ref) {
1862
- if (ref.bundlePath === void 0 && ref.schemaJson === void 0) {
1863
- throw new Error("addBundle: a deployment needs a bundlePath or a schemaJson");
3699
+ if (ref.bundlePath === void 0 && ref.schemaJson === void 0 && Object.keys(ref.rooms ?? {}).length === 0) {
3700
+ throw new Error("addBundle: a deployment needs a bundlePath, a rooms map or a schemaJson");
1864
3701
  }
1865
3702
  await this.registerDeployment(ref);
1866
3703
  const newest = this.newestBundle();
@@ -1912,14 +3749,16 @@ var SupervisorImpl = class {
1912
3749
  * `from` up to `to`. `undefined` when there is nothing to do; throws when a version in between
1913
3750
  * is unknown, which would otherwise mean decoding bytes with the wrong schema.
1914
3751
  */
1915
- migrationChain(from, to) {
3752
+ migrationChain(from, to, type = DEFAULT_ROOM_TYPE3) {
1916
3753
  if (from >= to) return void 0;
1917
3754
  const steps = [];
1918
3755
  for (const d of [...this.deployments.values()].sort((a, b) => a.version - b.version)) {
1919
3756
  if (d.version < from || d.version > to) continue;
1920
3757
  steps.push({
1921
3758
  version: d.version,
1922
- schemaJson: d.schemaJson,
3759
+ // D58: each type migrates against its own schema. The default type reads `d.schemaJson`,
3760
+ // which is exactly what every project got before types, so nothing single-room shifts.
3761
+ schemaJson: d.bundles.get(type)?.schemaJson ?? d.schemaJson,
1923
3762
  ...d.migrationUrl !== void 0 && d.version > from ? { migrationUrl: d.migrationUrl } : {}
1924
3763
  });
1925
3764
  }
@@ -1948,6 +3787,15 @@ var SupervisorImpl = class {
1948
3787
  this.limits.maxBufferedBytes,
1949
3788
  (s) => this.onSlowConsumer(s)
1950
3789
  );
3790
+ if (meta.loopback) {
3791
+ session.npc = true;
3792
+ session.forcedClientId = meta.loopback.clientId;
3793
+ }
3794
+ session.onEgress = (bytes) => {
3795
+ this.usage.addEgressBytes(bytes);
3796
+ if (this.egressLease.spend(bytes)) this.tripEgressWall();
3797
+ this.bandwidth.count(bytes);
3798
+ };
1951
3799
  this.sessions.add(session);
1952
3800
  this.idleSince = 0;
1953
3801
  this.idleFired = false;
@@ -1960,13 +3808,20 @@ var SupervisorImpl = class {
1960
3808
  session.missedPongs = 0;
1961
3809
  });
1962
3810
  ws.on("message", (data) => this.onSocketMessage(session, data));
1963
- if (!originAllowed(this.config.origins, this.config.allowNoOrigin !== false, origin)) {
1964
- session.fail("E_ORIGIN", formatError("E_ORIGIN", { origin: origin ?? "(none)" }));
1965
- return;
1966
- }
1967
- if (!this.ipBuckets.take(remoteAddress)) {
1968
- session.fail("E_RATE_LIMITED", "too many connections from this address");
1969
- return;
3811
+ if (!session.npc) {
3812
+ if (!originAllowed(this.config.origins, this.config.allowNoOrigin !== false, origin)) {
3813
+ session.fail("E_ORIGIN", formatError("E_ORIGIN", { origin: origin ?? "(none)" }));
3814
+ return;
3815
+ }
3816
+ if (!this.ipBuckets.take(remoteAddress)) {
3817
+ session.fail("E_RATE_LIMITED", "too many connections from this address");
3818
+ return;
3819
+ }
3820
+ const capRefusal = this.caps.refusal("egress_bytes");
3821
+ if (capRefusal !== void 0) {
3822
+ session.fail("E_USAGE_CAP", capRefusal);
3823
+ return;
3824
+ }
1970
3825
  }
1971
3826
  session.helloTimer = setTimeout(() => {
1972
3827
  session.helloTimer = void 0;
@@ -1983,7 +3838,7 @@ var SupervisorImpl = class {
1983
3838
  let payload;
1984
3839
  try {
1985
3840
  const frame = decodeFrame(bytes);
1986
- if (frame.type !== FrameType2.HELLO) {
3841
+ if (frame.type !== FrameType3.HELLO) {
1987
3842
  session.fail("E_BAD_FRAME", formatError("E_BAD_FRAME", { frame: frame.type }));
1988
3843
  return;
1989
3844
  }
@@ -2002,10 +3857,10 @@ var SupervisorImpl = class {
2002
3857
  session.slowConsumer = true;
2003
3858
  try {
2004
3859
  session.ws.send(
2005
- encodeFrame2(
2006
- FrameType2.ERROR,
3860
+ encodeFrame3(
3861
+ FrameType3.ERROR,
2007
3862
  encodeErrorPayload2({
2008
- code: ErrorCode2.E_SLOW_CONSUMER.code,
3863
+ code: ErrorCode3.E_SLOW_CONSUMER.code,
2009
3864
  message: formatError("E_SLOW_CONSUMER"),
2010
3865
  fatal: true
2011
3866
  })
@@ -2053,6 +3908,9 @@ var SupervisorImpl = class {
2053
3908
  if (session.state !== "joined") return;
2054
3909
  session.state = "disconnected";
2055
3910
  room.metrics.connections = Math.max(0, room.metrics.connections - 1);
3911
+ if (session.npc) {
3912
+ room.metrics.npcConnections = Math.max(0, room.metrics.npcConnections - 1);
3913
+ }
2056
3914
  if (session.fatal && !session.slowConsumer) {
2057
3915
  this.expireSession(session, "kicked");
2058
3916
  return;
@@ -2064,7 +3922,7 @@ var SupervisorImpl = class {
2064
3922
  const delta = room.relayRoom.setConnected(session.clientId, false);
2065
3923
  if (delta) {
2066
3924
  room.lastTick = room.relayRoom.tick;
2067
- this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta));
3925
+ this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
2068
3926
  }
2069
3927
  }
2070
3928
  session.graceTimer = setTimeout(
@@ -2086,11 +3944,21 @@ var SupervisorImpl = class {
2086
3944
  if (!room) return;
2087
3945
  if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
2088
3946
  this.clientIds.delete(session.clientId);
3947
+ if (this.config.voice) {
3948
+ void this.config.voice.close({
3949
+ projectId: this.config.projectId,
3950
+ roomId: room.id,
3951
+ clientId: session.clientId,
3952
+ npc: session.npc
3953
+ }).catch((err) => {
3954
+ this.log("warn", `voice close failed for ${session.clientId}: ${String(err)}`);
3955
+ });
3956
+ }
2089
3957
  if (room.relay) {
2090
3958
  const delta = room.relayRoom?.remove(session.clientId);
2091
3959
  if (delta && room.relayRoom) {
2092
3960
  room.lastTick = room.relayRoom.tick;
2093
- this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta));
3961
+ this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
2094
3962
  }
2095
3963
  } else if (room.state === "running" && room.worker) {
2096
3964
  room.worker.post({ t: "leave", clientId: session.clientId, reason });
@@ -2102,6 +3970,44 @@ var SupervisorImpl = class {
2102
3970
  // -------------------------------------------------------------------------
2103
3971
  // HELLO
2104
3972
  // -------------------------------------------------------------------------
3973
+ /** Assertion HELLOs parked waiting for the platform keys. See the field for why it is bounded. */
3974
+ get identityKeyWaiterCount() {
3975
+ return this.identityKeyWaiters.length;
3976
+ }
3977
+ /** Wakes every parked assertion HELLO. Called when the first key push lands, and on close. */
3978
+ releaseIdentityKeyWaiters() {
3979
+ const waiters = this.identityKeyWaiters;
3980
+ this.identityKeyWaiters = [];
3981
+ for (const wake of waiters) wake();
3982
+ }
3983
+ /**
3984
+ * Waits, bounded, for this tenant to be handed a platform key.
3985
+ *
3986
+ * Three ways out and all three are the same to the caller: the keys arrive (the common case, in
3987
+ * about one loopback round trip), the deadline passes, or the parking list is already full. In
3988
+ * every one of them the caller goes straight back to `verifyAssertion`, which refuses with
3989
+ * `E_ASSERTION_UNVERIFIABLE` if the keys still are not there. Nothing here can turn a refusal
3990
+ * into an acceptance; the only thing it changes is how long a player waits before finding out.
3991
+ */
3992
+ awaitIdentityKeys() {
3993
+ const waitMs = this.config.identityKeyWaitMs ?? IDENTITY_KEY_WAIT_MS;
3994
+ if (waitMs <= 0) return Promise.resolve();
3995
+ if (this.identityKeyWaiters.length >= MAX_IDENTITY_KEY_WAITERS) return Promise.resolve();
3996
+ return new Promise((resolve2) => {
3997
+ let done = false;
3998
+ const finish = () => {
3999
+ if (done) return;
4000
+ done = true;
4001
+ clearTimeout(timer);
4002
+ const at = this.identityKeyWaiters.indexOf(finish);
4003
+ if (at >= 0) this.identityKeyWaiters.splice(at, 1);
4004
+ resolve2();
4005
+ };
4006
+ const timer = setTimeout(finish, waitMs);
4007
+ timer.unref?.();
4008
+ this.identityKeyWaiters.push(finish);
4009
+ });
4010
+ }
2105
4011
  async handleHello(session, payload) {
2106
4012
  session.clearHelloTimer();
2107
4013
  let hello;
@@ -2128,7 +4034,16 @@ var SupervisorImpl = class {
2128
4034
  return;
2129
4035
  }
2130
4036
  let jwt;
2131
- if (credential.kind === "jwt") {
4037
+ if (credential.kind === "assertion") {
4038
+ if (this.identityKeys.length === 0) await this.awaitIdentityKeys();
4039
+ const verdict = verifyAssertion(this.identityKeys, credential.token, this.config.projectId);
4040
+ if (!verdict.ok) {
4041
+ const vars = verdict.code === "E_TOKEN_MALFORMED" ? { reason: verdict.reason } : verdict.code === "E_TOKEN_BAD_ALG" ? { alg: verdict.reason } : {};
4042
+ session.fail(verdict.code, formatError(verdict.code, vars));
4043
+ return;
4044
+ }
4045
+ jwt = { playerId: verdict.playerId };
4046
+ } else if (credential.kind === "jwt") {
2132
4047
  const issuers = this.config.jwtIssuers;
2133
4048
  if (issuers === void 0 || issuers.length === 0) {
2134
4049
  session.fail("E_AUTH", "this project has no JWT secret \u2014 mint one: irtio keys jwt-secret");
@@ -2142,23 +4057,12 @@ var SupervisorImpl = class {
2142
4057
  }
2143
4058
  jwt = verdict;
2144
4059
  }
2145
- if (this.bundle) {
2146
- const known = [...this.deployments.values()].some(
2147
- (d) => d.bundle !== void 0 && bytesEqual(hello.schemaHash8, d.bundle.hash8)
2148
- );
2149
- if (!known) {
2150
- session.fail("E_SCHEMA_MISMATCH", formatError("E_SCHEMA_MISMATCH"));
2151
- return;
2152
- }
2153
- } else if (!isRelayHash8(hello.schemaHash8)) {
2154
- session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
2155
- return;
2156
- }
2157
4060
  session.state = "joining";
2158
4061
  session.role = jwt?.role ?? hello.role ?? "";
2159
4062
  session.name = hello.name ?? "";
2160
4063
  session.playerId = jwt?.playerId;
2161
4064
  session.schemaHash8 = hello.schemaHash8;
4065
+ session.schemaSwap = hello.schemaSwap === true;
2162
4066
  let resume;
2163
4067
  if (hello.resumeToken !== void 0 && hello.resumeToken !== "") {
2164
4068
  resume = verifyResume(this.resumeSecret, hello.resumeToken);
@@ -2184,10 +4088,60 @@ var SupervisorImpl = class {
2184
4088
  } else {
2185
4089
  roomId = newRoomCode((code) => this.registry.has(code));
2186
4090
  }
4091
+ const roomType = roomTypeOf(roomId);
4092
+ const typed = this.roomTypes().length > 0;
4093
+ if (typed) {
4094
+ const known = [...this.deployments.values()].some((d) => {
4095
+ const b = d.bundles.get(roomType);
4096
+ return b !== void 0 && bytesEqual(hello.schemaHash8, b.hash8);
4097
+ });
4098
+ if (!known) {
4099
+ if (this.bundleFor(roomType) === void 0) {
4100
+ session.fail(
4101
+ "E_ROOM_NOT_FOUND",
4102
+ `this project has no room type ${JSON.stringify(roomType)}. It defines: ` + this.roomTypes().join(", ")
4103
+ );
4104
+ return;
4105
+ }
4106
+ session.fail(
4107
+ "E_SCHEMA_MISMATCH",
4108
+ `${formatError("E_SCHEMA_MISMATCH")} for room type ${roomType}`
4109
+ );
4110
+ return;
4111
+ }
4112
+ } else if (!isRelayHash8(hello.schemaHash8)) {
4113
+ session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
4114
+ return;
4115
+ }
2187
4116
  if (jwt?.roomId !== void 0 && jwt.roomId !== roomId) {
2188
4117
  session.fail("E_TOKEN_WRONG_ROOM", formatError("E_TOKEN_WRONG_ROOM"));
2189
4118
  return;
2190
4119
  }
4120
+ if (!session.npc && !this.registry.has(roomId)) {
4121
+ const roomCapRefusal = this.caps.refusal("room_hours");
4122
+ if (roomCapRefusal !== void 0) {
4123
+ session.fail("E_USAGE_CAP", roomCapRefusal);
4124
+ return;
4125
+ }
4126
+ const tierRefusal = this.freeAwakeRoomsRefusal();
4127
+ if (tierRefusal !== void 0) {
4128
+ session.fail("E_TIER_LIMIT", tierRefusal);
4129
+ return;
4130
+ }
4131
+ if (this.egressLease.tripped) {
4132
+ session.failWith(
4133
+ "E_EGRESS_WALL",
4134
+ egressWallMessage(this.caps.plan() ?? "free"),
4135
+ CLOSE_EGRESS_WALL
4136
+ );
4137
+ return;
4138
+ }
4139
+ const awakeRefusal = this.maxAwakeRefusal(roomId);
4140
+ if (awakeRefusal !== void 0) {
4141
+ session.fail("E_TYPE_AT_CAPACITY", awakeRefusal);
4142
+ return;
4143
+ }
4144
+ }
2191
4145
  const room = await this.ensureRoom(roomId);
2192
4146
  if (!room) {
2193
4147
  session.fail("E_INTERNAL", formatError("E_INTERNAL"));
@@ -2202,8 +4156,12 @@ var SupervisorImpl = class {
2202
4156
  session.roomId = roomId;
2203
4157
  let reconnecting = false;
2204
4158
  if (resume) {
2205
- reconnecting = true;
2206
4159
  const existing = room.clients.get(resume.clientId);
4160
+ if (!existing && resume.iatMs >= room.createdAt) {
4161
+ session.fail("E_RESUME_EXPIRED", formatError("E_RESUME_EXPIRED"));
4162
+ return;
4163
+ }
4164
+ reconnecting = true;
2207
4165
  if (existing && existing !== session) {
2208
4166
  existing.clearGraceTimer();
2209
4167
  existing.state = "gone";
@@ -2216,7 +4174,16 @@ var SupervisorImpl = class {
2216
4174
  session.clientId = resume.clientId;
2217
4175
  if (session.role === "") session.role = resume.role;
2218
4176
  } else {
2219
- session.clientId = newClientId((id) => this.clientIds.has(id));
4177
+ const forced = session.forcedClientId;
4178
+ if (forced !== void 0) {
4179
+ if (this.clientIds.has(forced)) {
4180
+ session.fail("E_INTERNAL", `npc client id ${forced} is already in use`);
4181
+ return;
4182
+ }
4183
+ session.clientId = forced;
4184
+ } else {
4185
+ session.clientId = newClientId((id) => this.clientIds.has(id));
4186
+ }
2220
4187
  }
2221
4188
  this.clientIds.add(session.clientId);
2222
4189
  room.clients.set(session.clientId, session);
@@ -2232,21 +4199,37 @@ var SupervisorImpl = class {
2232
4199
  if (inFlight) return inFlight;
2233
4200
  const existing = this.registry.get(roomId);
2234
4201
  if (existing) {
2235
- if (existing.state === "hibernated") await wakeRoom(this, existing);
2236
- else await existing.transition.catch(() => {
2237
- });
4202
+ for (; ; ) {
4203
+ if (existing.state === "hibernated" || existing.state === "hibernating") {
4204
+ await wakeRoom(this, existing);
4205
+ } else {
4206
+ await existing.transition.catch(() => {
4207
+ });
4208
+ }
4209
+ const state = existing.state;
4210
+ if (state !== "hibernated" && state !== "hibernating") break;
4211
+ }
2238
4212
  return existing;
2239
4213
  }
2240
4214
  const create = (async () => {
2241
- const config = this.bundle ? this.bundle.config : relayConfig(this.limits);
2242
- const room = new RoomRecord(roomId, this.bundle === void 0, config);
4215
+ const relay = this.deployments.size === 0 || this.roomTypes().length === 0;
4216
+ const bundle = relay ? void 0 : this.bundleFor(roomTypeOf(roomId));
4217
+ if (!relay && bundle === void 0) {
4218
+ this.log(
4219
+ "warn",
4220
+ `no room type ${JSON.stringify(roomTypeOf(roomId))} in this project (join of ${roomId}); this project defines: ${this.roomTypes().join(", ")}`
4221
+ );
4222
+ return void 0;
4223
+ }
4224
+ const config = bundle ? bundle.config : relayConfig(this.limits, this.caps.plan());
4225
+ const room = new RoomRecord(roomId, bundle === void 0, config);
2243
4226
  room.version = this.deploymentVersion;
2244
4227
  room.pendingRestoreSaveId = this.pendingRestores.get(roomId);
2245
4228
  this.pendingRestores.delete(roomId);
2246
4229
  this.registry.set(room);
2247
4230
  const restored = await this.loadPendingRestore(room);
2248
- const found = restored ?? await this.findSnapshot(roomId);
2249
- const snapshot = found?.bytes;
4231
+ let found = restored ?? await this.findSnapshot(roomId);
4232
+ let snapshot = found?.bytes;
2250
4233
  if (room.relay) {
2251
4234
  room.relayRoom = snapshot ? RelayRoom.restore(snapshot) : RelayRoom.create();
2252
4235
  room.lastTick = room.relayRoom.tick;
@@ -2254,6 +4237,15 @@ var SupervisorImpl = class {
2254
4237
  this.armRelayIdle(room);
2255
4238
  return room;
2256
4239
  }
4240
+ if (found && found.version === 0 && room.version > 0 && isRelayPresenceSnapshot(snapshot)) {
4241
+ this.roomLog(
4242
+ room,
4243
+ "info",
4244
+ `ignoring a relay presence snapshot at v0 for a v${room.version} coded room; a presence list is not room state, so this room starts cold`
4245
+ );
4246
+ snapshot = void 0;
4247
+ found = void 0;
4248
+ }
2257
4249
  if (found && found.version > room.version) {
2258
4250
  this.roomLog(
2259
4251
  room,
@@ -2266,7 +4258,7 @@ var SupervisorImpl = class {
2266
4258
  let migrate;
2267
4259
  if (found && found.version !== room.version) {
2268
4260
  try {
2269
- migrate = this.migrationChain(found.version, room.version);
4261
+ migrate = this.migrationChain(found.version, room.version, room.roomType);
2270
4262
  } catch (err) {
2271
4263
  this.roomLog(room, "error", "cannot migrate this room", err);
2272
4264
  this.registry.delete(roomId);
@@ -2300,9 +4292,10 @@ var SupervisorImpl = class {
2300
4292
  }
2301
4293
  /** Spawns the worker and waits for `ready`. Leaves the room's state to the caller. */
2302
4294
  async startWorker(room, snapshot, migrate) {
2303
- const bundle = this.deployments.get(room.version)?.bundle ?? this.bundle;
4295
+ const type = room.roomType;
4296
+ const bundle = this.deployments.get(room.version)?.bundles.get(type) ?? this.bundleFor(type);
2304
4297
  if (!bundle) return false;
2305
- const worker = new WorkerHost(this.workerEntry, this.limits, {
4298
+ const worker = new WorkerHost(this.workerEntry, this.limitsFor(type), {
2306
4299
  onMessage: (msg) => this.onWorkerMessage(room, msg),
2307
4300
  onDead: (reason) => this.onWorkerDead(room, reason)
2308
4301
  });
@@ -2323,8 +4316,15 @@ var SupervisorImpl = class {
2323
4316
  roomId: room.id,
2324
4317
  bundleUrl: bundle.bundleUrl,
2325
4318
  ...this.config.publicUrl !== void 0 ? { publicUrl: this.config.publicUrl } : {},
2326
- ...snapshot ? { snapshot } : {},
2327
- ...migrate ? { migrate } : {}
4319
+ // A copy, because `post` transfers the buffer and the caller may need its bytes again:
4320
+ // `migrateRoom` restarts the old version from the same snapshot when the chain throws,
4321
+ // and posting the detached buffer a second time killed that worker with a
4322
+ // DataCloneError. The crash restart then revived the room from the store, which is how
4323
+ // the containment test passed while an orphaned ready waiter timed out 30 s later.
4324
+ ...snapshot ? { snapshot: new Uint8Array(snapshot) } : {},
4325
+ ...migrate ? { migrate } : {},
4326
+ // D65: `irtio dev --profile` only.
4327
+ ...this.config.profile === true ? { profile: true } : {}
2328
4328
  });
2329
4329
  });
2330
4330
  if (!outcome || outcome.t !== "ready") {
@@ -2338,6 +4338,7 @@ var SupervisorImpl = class {
2338
4338
  return false;
2339
4339
  }
2340
4340
  room.config = outcome.config;
4341
+ room.backfillOpen = true;
2341
4342
  if (outcome.migrated) {
2342
4343
  const m = outcome.migrated;
2343
4344
  this.roomLog(
@@ -2382,6 +4383,8 @@ var SupervisorImpl = class {
2382
4383
  const saveId = await this.saveRoom(room);
2383
4384
  return { ok: true, value: saveId };
2384
4385
  }
4386
+ case "busSend":
4387
+ return this.busSend(room, call.roomId, call.payload);
2385
4388
  case "kvGet": {
2386
4389
  const value = await this.requireKv().get(call.playerId, call.key);
2387
4390
  return value === void 0 ? { ok: true } : { ok: true, value };
@@ -2392,8 +4395,67 @@ var SupervisorImpl = class {
2392
4395
  case "kvDelete":
2393
4396
  await this.requireKv().delete(call.playerId, call.key);
2394
4397
  return { ok: true };
4398
+ case "lbSubmit": {
4399
+ if (!this.roomHoldsPlayer(room, call.playerId)) {
4400
+ return {
4401
+ ok: false,
4402
+ code: LEADERBOARD_ERRORS.notInRoom,
4403
+ message: `${call.playerId} is not a client of room ${room.id}; leaderboard submits are only accepted for players currently in the room`
4404
+ };
4405
+ }
4406
+ await this.requireLeaderboard().submit(call.board, call.playerId, call.score);
4407
+ return { ok: true };
4408
+ }
4409
+ case "ratingReport": {
4410
+ const problem = ratingQueueProblem(call.queue) ?? ratingResultsProblem(call.results);
4411
+ if (problem) return { ok: false, code: problem.code, message: problem.message };
4412
+ for (const r of call.results) {
4413
+ if (!room.hasHeldPlayer(r.playerId)) {
4414
+ return {
4415
+ ok: false,
4416
+ code: RATING_ERRORS.notInRoom,
4417
+ message: `${r.playerId} has never been a client of room ${room.id}; rating reports are only accepted for players this room has actually held`
4418
+ };
4419
+ }
4420
+ }
4421
+ await this.requireRatings().report(call.queue, call.results);
4422
+ return { ok: true };
4423
+ }
4424
+ case "ratingSet": {
4425
+ const problem = ratingQueueProblem(call.queue) ?? ratingPlayerProblem(call.playerId) ?? ratingValueProblem({
4426
+ rating: call.rating,
4427
+ ...call.deviation !== void 0 ? { deviation: call.deviation } : {}
4428
+ });
4429
+ if (problem) return { ok: false, code: problem.code, message: problem.message };
4430
+ if (!room.hasHeldPlayer(call.playerId)) {
4431
+ return {
4432
+ ok: false,
4433
+ code: RATING_ERRORS.notInRoom,
4434
+ message: `${call.playerId} has never been a client of room ${room.id}; a rating may only be set for a player this room has actually held`
4435
+ };
4436
+ }
4437
+ await this.requireRatings().set(call.queue, call.playerId, {
4438
+ rating: call.rating,
4439
+ ...call.deviation !== void 0 ? { deviation: call.deviation } : {}
4440
+ });
4441
+ return { ok: true };
4442
+ }
2395
4443
  }
2396
4444
  }
4445
+ /**
4446
+ * Is `playerId` one of this room's current clients?
4447
+ *
4448
+ * The identity a session answers to is its verified `playerId` when it has one (a JWT or a
4449
+ * platform assertion) and its client id otherwise — the same `ctx.playerId` ladder the room
4450
+ * itself sees, so a room submitting under the id it was handed always passes, and a room
4451
+ * submitting under an id it invented never does.
4452
+ */
4453
+ roomHoldsPlayer(room, playerId) {
4454
+ for (const session of room.clients.values()) {
4455
+ if ((session.playerId ?? session.clientId) === playerId) return true;
4456
+ }
4457
+ return false;
4458
+ }
2397
4459
  requireKv() {
2398
4460
  const kv = this.config.kv;
2399
4461
  if (!kv) {
@@ -2403,6 +4465,24 @@ var SupervisorImpl = class {
2403
4465
  }
2404
4466
  return kv;
2405
4467
  }
4468
+ requireLeaderboard() {
4469
+ const lb = this.config.leaderboard;
4470
+ if (!lb) {
4471
+ throw new LeaderboardUnavailable(
4472
+ "leaderboards are not configured for this tenant (no control plane behind it)"
4473
+ );
4474
+ }
4475
+ return lb;
4476
+ }
4477
+ requireRatings() {
4478
+ const ratings = this.config.ratings;
4479
+ if (!ratings) {
4480
+ throw new RatingUnavailable(
4481
+ "skill ratings are not configured for this tenant (no control plane behind it)"
4482
+ );
4483
+ }
4484
+ return ratings;
4485
+ }
2406
4486
  /**
2407
4487
  * D24: one save generation. Serializes exactly the way hibernation does — same
2408
4488
  * `worker.serialize()`, same bytes, same `putSnapshotWithRetry` durability — writes it under
@@ -2534,54 +4614,382 @@ var SupervisorImpl = class {
2534
4614
  }
2535
4615
  }
2536
4616
  /**
2537
- * D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
2538
- *
2539
- * Anything already due fires immediately — an alarm is "at or after", never before, and a
2540
- * tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
4617
+ * D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
4618
+ *
4619
+ * Anything already due fires immediately — an alarm is "at or after", never before, and a
4620
+ * tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
4621
+ */
4622
+ async loadAlarms(room) {
4623
+ if (room.relay) return;
4624
+ let persisted = [];
4625
+ try {
4626
+ const bytes = await this.store.get(alarmsKey(this.storeKey(room)));
4627
+ if (bytes) persisted = decodeAlarms(bytes);
4628
+ } catch (err) {
4629
+ this.roomLog(room, "warn", "could not read this room's alarms; starting with none", err);
4630
+ }
4631
+ room.alarms.onChange = () => this.persistAlarms(room);
4632
+ room.alarms.attach((names) => this.deliverAlarms(room, names));
4633
+ if (persisted.length > 0) {
4634
+ room.alarms.load(persisted);
4635
+ const overdue = room.alarms.fireDue();
4636
+ if (overdue.length > 0) {
4637
+ this.roomLog(
4638
+ room,
4639
+ "info",
4640
+ `firing ${overdue.length} overdue alarm(s): ${overdue.join(", ")}`
4641
+ );
4642
+ }
4643
+ }
4644
+ }
4645
+ /**
4646
+ * Delivers due alarms into the room, **waking it first if it is asleep**. This is the whole
4647
+ * hibernated case: the timer kept counting because it lives on the record rather than in the
4648
+ * worker, and the wake is the same one a joining client would have triggered.
4649
+ *
4650
+ * The loop is not defensive padding — it is the fix for a real race that cost an hour to find.
4651
+ * `fireDue` removes an alarm before delivering it, so between the state check and the `await`
4652
+ * the room can slip into hibernation (`hibernateRoom` flips the state synchronously). A single
4653
+ * check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
4654
+ * no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
4655
+ * watching. Re-checking after every transition covers the slip, and anything still undeliverable
4656
+ * is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
4657
+ * the next placement's problem, not a lost one.
4658
+ */
4659
+ // -------------------------------------------------------------------------
4660
+ // D59: the bus
4661
+ // -------------------------------------------------------------------------
4662
+ /**
4663
+ * Takes one token from a room's bus budget, or returns the refusal.
4664
+ *
4665
+ * Both verbs draw on the same bucket, on purpose: publish costs a fan-out and send costs a store
4666
+ * write, and a limiter that metered only one of them would be a limiter a room could route
4667
+ * around by choosing the other. This is also the whole of the per-room rate-limit machinery D59
4668
+ * asks for. Every limiter in the supervisor before it was scoped to a connection, an IP or a
4669
+ * project key, and none of them metered what a room asked the host to do.
4670
+ */
4671
+ /**
4672
+ * D59: forget everything a room was subscribed to. Called when a room is gone for good.
4673
+ *
4674
+ * Note what this is NOT called for: hibernation. A hibernated room keeping a subscription entry
4675
+ * is harmless, because `busPublish` posts only to rooms that are `running` with a live worker,
4676
+ * and that liveness check — not this bookkeeping — is what makes "publish reaches awake
4677
+ * subscribers only" true. Doing it here as well would mean chasing eight separate places where a
4678
+ * worker goes away, and getting one wrong would be a silent correctness bug rather than a leak.
4679
+ */
4680
+ busRoomClosed(roomId) {
4681
+ this.bus.unsubscribeAll(roomId);
4682
+ }
4683
+ /** D59: the channels a room currently holds, for `/admin/rooms` and for tests. */
4684
+ busSubscriptionsOf(roomId) {
4685
+ return this.bus.subscriptionsOf(roomId);
4686
+ }
4687
+ busBudget(room, verb) {
4688
+ if (room.busBucket.take()) return void 0;
4689
+ return {
4690
+ code: BUS_ERRORS.rateLimited,
4691
+ message: `room.bus.${verb}: this room is over its bus budget of ${BUS_LIMITS.opsPerSecond} operations per second`
4692
+ };
4693
+ }
4694
+ /**
4695
+ * `room.bus.publish`: fan out to awake subscribers, and nothing else.
4696
+ *
4697
+ * Every refusal here is a log line rather than a value, because the verb is fire-and-forget on
4698
+ * the runtime side and there is nothing to answer. The `from` handed to each subscriber is
4699
+ * `room.id`, the record's own id and not anything the worker supplied, so a room cannot publish
4700
+ * under another room's name.
4701
+ */
4702
+ busPublish(room, channel, payload) {
4703
+ const problem = busChannelProblem(channel) ?? busPayloadProblem(payload) ?? this.busBudget(room, "publish");
4704
+ if (problem) {
4705
+ this.roomLog(room, "warn", `bus.publish: ${problem.code}: ${problem.message}`);
4706
+ return;
4707
+ }
4708
+ for (const id of this.bus.subscribers(channel, room.id)) {
4709
+ const target = this.registry.get(id);
4710
+ if (!target || target.state !== "running" || !target.worker) continue;
4711
+ target.worker.post({ t: "busEvent", channel, from: room.id, payload });
4712
+ }
4713
+ }
4714
+ /**
4715
+ * `room.bus.send`: park a durable, at-least-once message in the target's mailbox.
4716
+ *
4717
+ * The mailbox is the alarm set. Arming for `Date.now()` means `deliverAlarms` picks it up on the
4718
+ * next turn and, the part that makes the feature work at all, wakes the room first if it is
4719
+ * hibernated, because the alarm timer lives on the `RoomRecord` and outlives the worker.
4720
+ *
4721
+ * Refusals are values rather than log lines, unlike publish, because each one is a different
4722
+ * thing for the sender to do: shrink the payload, fix the roomId, back off, or slow down.
4723
+ */
4724
+ async busSend(room, targetId, payload) {
4725
+ const refuse = (p) => ({
4726
+ ok: false,
4727
+ code: p.code,
4728
+ message: p.message
4729
+ });
4730
+ const budget = busPayloadProblem(payload) ?? this.busBudget(room, "send");
4731
+ if (budget) return refuse(budget);
4732
+ const normalized = normalizeRoomId(targetId);
4733
+ const target = normalized === void 0 ? void 0 : this.registry.get(normalized);
4734
+ if (target && target.state !== "closed") {
4735
+ return this.parkLocally(target, room.id, payload, targetId);
4736
+ }
4737
+ if (!this.isSharded() || normalized === void 0) {
4738
+ return refuse({
4739
+ code: BUS_ERRORS.noSuchRoom,
4740
+ message: `no room ${JSON.stringify(targetId)} in this project`
4741
+ });
4742
+ }
4743
+ if (room.alarms.mailboxDepth >= BUS_LIMITS.mailboxDepth) {
4744
+ return refuse({
4745
+ code: BUS_ERRORS.mailboxFull,
4746
+ message: `room ${JSON.stringify(room.id)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages in its mailbox and outbox; it is not keeping up`
4747
+ });
4748
+ }
4749
+ const name = mintOutboxName(this.mailboxCounter++);
4750
+ const entry = {
4751
+ to: normalized,
4752
+ from: room.id,
4753
+ payload,
4754
+ id: name,
4755
+ firstAt: this.busNow(),
4756
+ attempts: 0
4757
+ };
4758
+ room.alarms.set(name, this.busNow() + this.outboxDelay(0), encodeOutboxEntry(entry));
4759
+ const outcome = await this.forwardOutbox(room, name, entry);
4760
+ switch (outcome) {
4761
+ case "delivered":
4762
+ return { ok: true };
4763
+ case "no-such-room":
4764
+ return refuse({
4765
+ code: BUS_ERRORS.noSuchRoom,
4766
+ message: `no room ${JSON.stringify(targetId)} in this project, on any shard`
4767
+ });
4768
+ case "mailbox-full":
4769
+ room.alarms.set(name, void 0);
4770
+ return refuse({
4771
+ code: BUS_ERRORS.mailboxFull,
4772
+ message: `room ${JSON.stringify(targetId)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages; it is not keeping up`
4773
+ });
4774
+ default:
4775
+ return { ok: true };
4776
+ }
4777
+ }
4778
+ /** Today's local park-and-wake, factored out so the cross-shard target side can share it. */
4779
+ parkLocally(target, from, payload, targetId) {
4780
+ if (target.relay) {
4781
+ return {
4782
+ ok: false,
4783
+ code: BUS_ERRORS.noSuchRoom,
4784
+ message: `room ${JSON.stringify(targetId)} is a relay room and runs no bus handlers`
4785
+ };
4786
+ }
4787
+ if (target.alarms.mailboxDepth >= BUS_LIMITS.mailboxDepth) {
4788
+ return {
4789
+ ok: false,
4790
+ code: BUS_ERRORS.mailboxFull,
4791
+ message: `room ${JSON.stringify(targetId)} has ${BUS_LIMITS.mailboxDepth} undelivered bus messages; it is not keeping up`
4792
+ };
4793
+ }
4794
+ const entry = { from, payload, attempts: 0 };
4795
+ target.alarms.set(
4796
+ mintMailboxName(this.mailboxCounter++),
4797
+ Date.now(),
4798
+ encodeMailboxEntry(entry)
4799
+ );
4800
+ return { ok: true };
4801
+ }
4802
+ /** D67-e: the clock the outbox stamps and ages by; injected so the hour is tested, not waited. */
4803
+ busNow() {
4804
+ return this.config.busClock?.() ?? Date.now();
4805
+ }
4806
+ /** D67-e: the retry schedule, injectable for the same reason the clock is. */
4807
+ outboxDelay(attempts) {
4808
+ return this.config.busRetryDelayMs?.(attempts) ?? outboxRetryDelayMs(attempts);
4809
+ }
4810
+ /**
4811
+ * Whether the store holds anything for `roomId`: its live key, an `@v<N>` key, or anything
4812
+ * under `<key>/` (the alarm sidecar, saves). The listing is by prefix, so `abc` would match
4813
+ * `abcd`'s keys without the filter, which is the same filter control's directory applies.
4814
+ */
4815
+ async roomExistsInStore(roomId) {
4816
+ const base = `${this.config.projectId}/${roomId}`;
4817
+ try {
4818
+ const keys = await this.store.list(base);
4819
+ return keys.some((k) => k === base || k.startsWith(`${base}@v`) || k.startsWith(`${base}/`));
4820
+ } catch {
4821
+ return false;
4822
+ }
4823
+ }
4824
+ /**
4825
+ * One forward of an outbox entry through the control plane. Answers what the sender should
4826
+ * conclude: `delivered` and `no-such-room` drop the entry, `mailbox-full` is the target's
4827
+ * answer, and `retry` (the target's shard is starting, the hop failed) leaves the entry armed.
4828
+ */
4829
+ async forwardOutbox(room, name, entry) {
4830
+ const client = this.config.shardClient;
4831
+ if (!client) {
4832
+ this.roomLog(
4833
+ room,
4834
+ "warn",
4835
+ `bus.send to ${entry.to}: no shard client on this tenant; will retry`
4836
+ );
4837
+ return "retry";
4838
+ }
4839
+ try {
4840
+ const result = await client.send({
4841
+ shard: this.config.shard ?? 0,
4842
+ to: entry.to,
4843
+ from: entry.from,
4844
+ payload: entry.payload,
4845
+ id: entry.id
4846
+ });
4847
+ if (result.ok) {
4848
+ room.alarms.set(name, void 0);
4849
+ return "delivered";
4850
+ }
4851
+ if (result.code === BUS_ERRORS.noSuchRoom) {
4852
+ room.alarms.set(name, void 0);
4853
+ return "no-such-room";
4854
+ }
4855
+ if (result.code === BUS_ERRORS.mailboxFull) return "mailbox-full";
4856
+ return "retry";
4857
+ } catch (err) {
4858
+ this.roomLog(
4859
+ room,
4860
+ "warn",
4861
+ `bus.send to ${entry.to} could not be forwarded (attempt ${entry.attempts + 1}); will retry`,
4862
+ err
4863
+ );
4864
+ return "retry";
4865
+ }
4866
+ }
4867
+ /**
4868
+ * An outbox entry's alarm came due: forward it again, or dead-letter it. Runs on the record,
4869
+ * whatever the room's state, and never wakes the room: a retry is a host call, not room code,
4870
+ * so a hibernated sender stays asleep and is not billed for its own outbox.
2541
4871
  */
2542
- async loadAlarms(room) {
2543
- if (room.relay) return;
2544
- let persisted = [];
2545
- try {
2546
- const bytes = await this.store.get(alarmsKey(this.storeKey(room)));
2547
- if (bytes) persisted = decodeAlarms(bytes);
2548
- } catch (err) {
2549
- this.roomLog(room, "warn", "could not read this room's alarms; starting with none", err);
4872
+ retryOutbox(room, name, raw) {
4873
+ const entry = decodeOutboxEntry(raw);
4874
+ if (!entry) {
4875
+ this.roomLog(room, "warn", `dropping an unreadable bus outbox entry (${name})`);
4876
+ return;
2550
4877
  }
2551
- room.alarms.onChange = () => this.persistAlarms(room);
2552
- room.alarms.attach((names) => this.deliverAlarms(room, names));
2553
- if (persisted.length > 0) {
2554
- room.alarms.load(persisted);
2555
- const overdue = room.alarms.fireDue();
2556
- if (overdue.length > 0) {
4878
+ const now = this.busNow();
4879
+ if (now - entry.firstAt >= BUS_OUTBOX2.ttlMs) {
4880
+ this.roomLog(
4881
+ room,
4882
+ "warn",
4883
+ `a bus message to ${JSON.stringify(entry.to)} could not be delivered across shards in ${entry.attempts} attempt(s) over an hour and has been dropped (${entry.id})`
4884
+ );
4885
+ return;
4886
+ }
4887
+ const next = { ...entry, attempts: entry.attempts + 1 };
4888
+ room.alarms.set(name, now + this.outboxDelay(next.attempts), encodeOutboxEntry(next));
4889
+ void this.forwardOutbox(room, name, next).then((outcome) => {
4890
+ if (outcome === "mailbox-full") {
2557
4891
  this.roomLog(
2558
4892
  room,
2559
4893
  "info",
2560
- `firing ${overdue.length} overdue alarm(s): ${overdue.join(", ")}`
4894
+ `bus.send to ${entry.to}: the target's mailbox is full; will retry`
2561
4895
  );
2562
4896
  }
4897
+ });
4898
+ }
4899
+ /**
4900
+ * D67-e, the target side: `POST /admin/bus/deliver` from the host agent, carrying a message
4901
+ * the control plane forwarded from another shard. Load-if-exists, then today's local park; never
4902
+ * `ensureRoom` from nothing, because a room the directory did not place here is not this
4903
+ * shard's to create. A message whose `id` this room has seen before is delivered again (a lost
4904
+ * acknowledgement, at-least-once) and the duplicate is logged by name.
4905
+ */
4906
+ async deliverForwarded(message) {
4907
+ const normalized = normalizeRoomId(message.to);
4908
+ if (normalized === void 0) {
4909
+ return {
4910
+ ok: false,
4911
+ code: BUS_ERRORS.noSuchRoom,
4912
+ message: `no room ${JSON.stringify(message.to)}`
4913
+ };
4914
+ }
4915
+ let target = this.registry.get(normalized);
4916
+ if ((!target || target.state === "closed") && await this.roomExistsInStore(normalized)) {
4917
+ target = await this.ensureRoom(normalized);
4918
+ }
4919
+ if (!target || target.state === "closed") {
4920
+ return {
4921
+ ok: false,
4922
+ code: BUS_ERRORS.noSuchRoom,
4923
+ message: `no room ${JSON.stringify(message.to)} on this shard`
4924
+ };
4925
+ }
4926
+ if (target.busSeen.has(message.id)) {
4927
+ this.roomLog(
4928
+ target,
4929
+ "warn",
4930
+ `duplicate bus delivery ${message.id} from ${message.from}; delivering again`
4931
+ );
4932
+ } else {
4933
+ target.busSeen.add(message.id);
4934
+ if (target.busSeen.size > BUS_SEEN_MAX) {
4935
+ const oldest = target.busSeen.values().next().value;
4936
+ if (oldest !== void 0) target.busSeen.delete(oldest);
4937
+ }
2563
4938
  }
4939
+ return this.parkLocally(target, message.from, message.payload, message.to);
2564
4940
  }
2565
4941
  /**
2566
- * Delivers due alarms into the room, **waking it first if it is asleep**. This is the whole
2567
- * hibernated case: the timer kept counting because it lives on the record rather than in the
2568
- * worker, and the wake is the same one a joining client would have triggered.
4942
+ * Delivers one mailbox entry, or bounds and drops it.
2569
4943
  *
2570
- * The loop is not defensive padding it is the fix for a real race that cost an hour to find.
2571
- * `fireDue` removes an alarm before delivering it, so between the state check and the `await`
2572
- * the room can slip into hibernation (`hibernateRoom` flips the state synchronously). A single
2573
- * check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
2574
- * no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
2575
- * watching. Re-checking after every transition covers the slip, and anything still undeliverable
2576
- * is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
2577
- * the next placement's problem, not a lost one.
4944
+ * The attempt counter is incremented *before* delivery and persisted with the entry, so a
4945
+ * message whose handler kills the room still counts the attempt. Counting after a successful
4946
+ * delivery would count nothing at all in exactly the case the bound exists for.
2578
4947
  */
2579
- deliverAlarms(room, names) {
4948
+ deliverMailbox(room, name, raw) {
4949
+ const entry = decodeMailboxEntry(raw);
4950
+ if (!entry) {
4951
+ this.roomLog(room, "warn", `dropping an unreadable bus mailbox entry (${name})`);
4952
+ return;
4953
+ }
4954
+ const attempts = entry.attempts + 1;
4955
+ if (attempts > BUS_LIMITS.maxDeliveryAttempts) {
4956
+ this.roomLog(
4957
+ room,
4958
+ "error",
4959
+ `a bus message from ${JSON.stringify(entry.from)} could not be delivered in ${BUS_LIMITS.maxDeliveryAttempts} attempts and has been dropped`
4960
+ );
4961
+ return;
4962
+ }
4963
+ if (room.state === "running" && room.worker) {
4964
+ room.alarms.set(
4965
+ name,
4966
+ Date.now() + BUS_LIMITS.redeliveryDelayMs,
4967
+ encodeMailboxEntry({ ...entry, attempts })
4968
+ );
4969
+ room.worker.post({ t: "busMessage", name, from: entry.from, payload: entry.payload });
4970
+ return;
4971
+ }
4972
+ room.alarms.set(name, Date.now(), encodeMailboxEntry({ ...entry, attempts }));
4973
+ }
4974
+ deliverAlarms(room, allNames) {
4975
+ const names = [];
4976
+ for (const name of allNames) {
4977
+ if (isOutboxAlarm(name)) this.retryOutbox(room, name, room.alarms.payloadOf(name));
4978
+ else names.push(name);
4979
+ }
4980
+ if (names.length === 0) return;
4981
+ const mailboxPayloads = /* @__PURE__ */ new Map();
4982
+ for (const name of names) {
4983
+ if (isMailboxAlarm(name)) mailboxPayloads.set(name, room.alarms.payloadOf(name));
4984
+ }
2580
4985
  void (async () => {
2581
4986
  for (let attempt = 0; attempt < 4; attempt++) {
2582
4987
  if (room.state === "closed") return;
2583
4988
  if (room.state === "running" && room.worker) {
2584
- for (const name of names) room.worker.post({ t: "alarm", name });
4989
+ for (const name of names) {
4990
+ if (isMailboxAlarm(name)) this.deliverMailbox(room, name, mailboxPayloads.get(name));
4991
+ else room.worker.post({ t: "alarm", name });
4992
+ }
2585
4993
  return;
2586
4994
  }
2587
4995
  if (room.state === "hibernated") {
@@ -2597,7 +5005,10 @@ var SupervisorImpl = class {
2597
5005
  `alarm(s) ${names.join(", ")} came due while the room was ${room.state}; re-armed for the next start rather than dropped`
2598
5006
  );
2599
5007
  const now = Date.now();
2600
- for (const name of names) room.alarms.set(name, now);
5008
+ for (const name of names) {
5009
+ if (isMailboxAlarm(name)) this.deliverMailbox(room, name, mailboxPayloads.get(name));
5010
+ else room.alarms.set(name, now);
5011
+ }
2601
5012
  })();
2602
5013
  }
2603
5014
  /**
@@ -2672,9 +5083,11 @@ var SupervisorImpl = class {
2672
5083
  closeRoom(room, code, message) {
2673
5084
  if (room.state === "closed") return;
2674
5085
  room.state = "closed";
5086
+ this.npcs.despawnRoom(room.id);
2675
5087
  this.clearRoomTimer(room);
2676
5088
  room.alarms.detach();
2677
5089
  room.alarms.onChange = void 0;
5090
+ this.busRoomClosed(room.id);
2678
5091
  for (const session of [...room.clients.values()]) {
2679
5092
  session.fatal = true;
2680
5093
  session.sendError(code, message, true);
@@ -2699,22 +5112,45 @@ var SupervisorImpl = class {
2699
5112
  async joinBundle(room, session, reconnecting) {
2700
5113
  const outcome = await this.requestJoin(room, session, reconnecting);
2701
5114
  if (!outcome) {
5115
+ session.pendingOut.length = 0;
2702
5116
  session.fail("E_INTERNAL", "the room did not answer the join");
2703
5117
  return;
2704
5118
  }
2705
5119
  if (outcome.t === "joinRejected") {
5120
+ session.pendingOut.length = 0;
2706
5121
  room.clients.delete(session.clientId);
2707
5122
  this.clientIds.delete(session.clientId);
2708
5123
  session.fail(outcome.code, outcome.reason ?? formatError(outcome.code, { roomId: room.id }));
2709
5124
  return;
2710
5125
  }
2711
- if (!session.open) return;
5126
+ if (!session.open) {
5127
+ session.pendingOut.length = 0;
5128
+ session.state = "gone";
5129
+ if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
5130
+ this.clientIds.delete(session.clientId);
5131
+ const reason = session.fatal ? "kicked" : "timeout";
5132
+ if (room.state === "running" && room.worker) {
5133
+ room.worker.post({ t: "leave", clientId: session.clientId, reason });
5134
+ } else if (room.state !== "closed") {
5135
+ room.pendingLeaves.add(session.clientId);
5136
+ }
5137
+ return;
5138
+ }
2712
5139
  session.role = outcome.role;
2713
5140
  session.state = "joined";
5141
+ room.notePlayerSeen(session.playerId ?? session.clientId);
2714
5142
  if (outcome.tick > room.lastTick) room.lastTick = outcome.tick;
2715
5143
  room.metrics.connections++;
2716
5144
  room.metrics.totalConnections++;
5145
+ if (session.npc) room.metrics.npcConnections++;
2717
5146
  this.sendWelcome(session, outcome.tick, outcome.snapshot);
5147
+ for (const bytes of session.pendingOut.splice(0)) {
5148
+ const sent = session.send(bytes);
5149
+ if (sent > 0) {
5150
+ room.metrics.egressBytes += sent;
5151
+ room.metrics.framesOut++;
5152
+ }
5153
+ }
2718
5154
  }
2719
5155
  requestJoin(room, session, reconnecting) {
2720
5156
  const worker = room.worker;
@@ -2737,43 +5173,53 @@ var SupervisorImpl = class {
2737
5173
  ...session.name !== "" ? { name: session.name } : {},
2738
5174
  // D27: the verified JWT subject; absent for key joins (playerId stays the client id).
2739
5175
  ...session.playerId !== void 0 ? { playerId: session.playerId } : {},
2740
- ...reconnecting ? { reconnecting: true } : {}
5176
+ ...reconnecting ? { reconnecting: true } : {},
5177
+ ...session.npc ? { npc: true } : {}
2741
5178
  });
2742
5179
  });
2743
5180
  }
5181
+ /**
5182
+ * D66: the admission decision now lives in `relay-host.ts`, so the process that will serve
5183
+ * relay-only tenants without a VM runs the same code. What stays here is what is the
5184
+ * *supervisor's* and not a relay's: the session registries, the welcome frame, and the idle
5185
+ * timer that leads to hibernation.
5186
+ */
2744
5187
  joinRelay(room, session, _reconnecting) {
2745
5188
  const relay = room.relayRoom;
2746
5189
  if (!relay) {
2747
5190
  session.fail("E_INTERNAL", formatError("E_INTERNAL"));
2748
5191
  return;
2749
5192
  }
2750
- if (!relay.has(session.clientId) && relay.size >= room.config.maxClients) {
5193
+ const outcome = relayJoin(relayHostRoomFor(this, room), session);
5194
+ if (!outcome.ok) {
2751
5195
  room.clients.delete(session.clientId);
2752
5196
  this.clientIds.delete(session.clientId);
2753
- session.fail("E_ROOM_FULL", formatError("E_ROOM_FULL", { roomId: room.id }));
5197
+ session.fail(outcome.code, formatError(outcome.code, { roomId: room.id }));
2754
5198
  return;
2755
5199
  }
2756
- const delta = relay.add(session.clientId, session.role, session.name);
2757
- room.lastTick = relay.tick;
5200
+ room.lastTick = outcome.tick;
2758
5201
  session.state = "joined";
5202
+ room.notePlayerSeen(session.playerId ?? session.clientId);
2759
5203
  room.metrics.connections++;
2760
5204
  room.metrics.totalConnections++;
2761
- this.sendWelcome(session, relay.tick, relay.snapshot());
2762
- this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta), session.clientId);
5205
+ this.sendWelcome(session, outcome.tick, outcome.snapshot);
5206
+ this.broadcast(room, encodeFrame3(FrameType3.DELTA, outcome.delta), session.clientId);
2763
5207
  this.armRelayIdle(room);
2764
5208
  }
2765
5209
  /** `WELCOME` with a fresh resume token — also used as the resync after a wake/restart. */
2766
5210
  sendWelcome(session, tick, snapshot) {
2767
5211
  const room = session.room;
2768
5212
  if (!room) return;
5213
+ const now = Date.now();
2769
5214
  const resumeToken = signResume(this.resumeSecret, {
2770
5215
  clientId: session.clientId,
2771
5216
  roomId: room.id,
2772
5217
  role: session.role,
2773
- expMs: Date.now() + room.config.reconnectGraceMs
5218
+ expMs: now + RESUME_TOKEN_TTL_MS,
5219
+ iatMs: now
2774
5220
  });
2775
- const bytes = encodeFrame2(
2776
- FrameType2.WELCOME,
5221
+ const bytes = encodeFrame3(
5222
+ FrameType3.WELCOME,
2777
5223
  encodeWelcome({
2778
5224
  clientId: session.clientId,
2779
5225
  role: session.role,
@@ -2781,8 +5227,11 @@ var SupervisorImpl = class {
2781
5227
  snapshot,
2782
5228
  resumeToken,
2783
5229
  roomId: room.id,
2784
- // 0 for a relay room (tickRate 0): "unknown" — the client falls back to its 50 ms floor.
2785
- tickIntervalMs: room.config.tickRate > 0 ? Math.round(1e3 / room.config.tickRate) : 0
5230
+ // 0 for a relay room: "unknown" — the client falls back to its 50 ms floor.
5231
+ tickRate: room.config.tickRate,
5232
+ // Always meaningful: bundle rooms default to 64 (@irtio/server DEFAULTS) and relay rooms
5233
+ // come from SupervisorLimits.relayMaxClients, so unlike tickRate this is never 0/unset.
5234
+ maxClients: room.config.maxClients
2786
5235
  })
2787
5236
  );
2788
5237
  const sent = session.send(bytes);
@@ -2798,17 +5247,31 @@ var SupervisorImpl = class {
2798
5247
  * the new client, instead of feeding it bytes it will mis-decode. Clients already on the new
2799
5248
  * schema (the page reloaded after the client shipped) rejoin normally.
2800
5249
  */
2801
- failOutdatedSessions(room, version) {
2802
- const hash8 = this.deployments.get(version)?.bundle?.hash8;
2803
- if (!hash8) return;
5250
+ failOutdatedSessions(room, version, fromVersion) {
5251
+ const type = room.roomType;
5252
+ const target = this.deployments.get(version);
5253
+ const targetBundle = target?.bundles.get(type);
5254
+ const hash8 = targetBundle?.hash8;
5255
+ if (!hash8 || !target) return 0;
5256
+ const from = fromVersion !== void 0 ? this.deployments.get(fromVersion) : void 0;
5257
+ const fromSchemaJson = from?.bundles.get(type)?.schemaJson;
5258
+ const targetSchemaJson = targetBundle.schemaJson;
5259
+ const swappable = fromSchemaJson !== void 0 && isAdditiveMigration(fromSchemaJson, targetSchemaJson);
5260
+ let swapped = 0;
2804
5261
  for (const session of room.connected()) {
2805
- if (session.schemaHash8 !== void 0 && !bytesEqual(session.schemaHash8, hash8)) {
2806
- session.fail(
2807
- "E_SCHEMA_MISMATCH",
2808
- "the room migrated to a newer deployment; reconnect with an updated client"
2809
- );
5262
+ if (session.schemaHash8 === void 0 || bytesEqual(session.schemaHash8, hash8)) continue;
5263
+ if (swappable && session.schemaSwap) {
5264
+ session.sendFrame(FrameType3.SCHEMA, schemaPayload(targetSchemaJson));
5265
+ session.schemaHash8 = hash8;
5266
+ swapped++;
5267
+ continue;
2810
5268
  }
5269
+ session.fail(
5270
+ "E_SCHEMA_MISMATCH",
5271
+ "the room migrated to a newer deployment; reconnect with an updated client"
5272
+ );
2811
5273
  }
5274
+ return swapped;
2812
5275
  }
2813
5276
  /** Re-joins every still-connected socket after a wake or a crash restart, with a resync WELCOME. */
2814
5277
  async rejoinAll(room) {
@@ -2861,23 +5324,33 @@ var SupervisorImpl = class {
2861
5324
  this.badFrame(session, "unknown frame type");
2862
5325
  return;
2863
5326
  }
2864
- if (type === FrameType2.PING) {
5327
+ if (type === FrameType3.PING) {
2865
5328
  let t = 0;
2866
5329
  try {
2867
5330
  t = decodePing(payload).t;
2868
5331
  } catch {
2869
5332
  }
2870
- const sent = session.sendFrame(FrameType2.PONG, encodePong({ t, serverTick: room.lastTick }));
5333
+ const sent = session.sendFrame(FrameType3.PONG, encodePong({ t, serverTick: room.lastTick }));
2871
5334
  if (sent > 0) {
2872
5335
  room.metrics.egressBytes += sent;
2873
5336
  room.metrics.framesOut++;
2874
5337
  }
2875
5338
  return;
2876
5339
  }
2877
- if (type === FrameType2.LEAVE) {
5340
+ if (type === FrameType3.LEAVE) {
2878
5341
  this.handleLeaveFrame(session);
2879
5342
  return;
2880
5343
  }
5344
+ if (type === FrameType3.MSG && this.bandwidth.engaged) {
5345
+ if (this.bandwidth.takeWarning(room.id)) {
5346
+ this.roomLog(room, "warn", bandwidthWarning(this.caps.plan() ?? "free"));
5347
+ }
5348
+ return;
5349
+ }
5350
+ if (type === FrameType3.MSG && isVoiceMsg(payload)) {
5351
+ this.handleVoiceFrame(room, session, payload.subarray(1));
5352
+ return;
5353
+ }
2881
5354
  if (room.relay) {
2882
5355
  this.relayFrame(room, session, type, payload);
2883
5356
  return;
@@ -2916,43 +5389,81 @@ var SupervisorImpl = class {
2916
5389
  } catch {
2917
5390
  }
2918
5391
  }
5392
+ /**
5393
+ * D66: the forwarding rules now live in `relay-host.ts` — see that module for why. What stays
5394
+ * here is the supervisor's half of a refusal (`badFrame` closes the connection its own way) and
5395
+ * the idle timer.
5396
+ */
2919
5397
  relayFrame(room, session, type, payload) {
2920
- const relay = room.relayRoom;
2921
- if (!relay) return;
2922
- if (type !== FrameType2.MSG) {
2923
- this.badFrame(session, "relay rooms accept MSG only (no room code is deployed)");
5398
+ if (!room.relayRoom) return;
5399
+ const outcome = relayForward(relayHostRoomFor(this, room), session, type, payload);
5400
+ if (!outcome.ok) {
5401
+ this.badFrame(session, outcome.reason);
2924
5402
  return;
2925
5403
  }
2926
- let target;
2927
- let body;
2928
- try {
2929
- const msg = decodeMsg(payload);
2930
- target = msg.target;
2931
- body = msg.payload;
2932
- } catch {
2933
- this.badFrame(session, "malformed MSG");
5404
+ this.armRelayIdle(room);
5405
+ }
5406
+ /**
5407
+ * D51: one intercepted voice-signaling message. Never reaches room code — see the interception
5408
+ * in `routeFrame` and the module note on `VoiceRelay`.
5409
+ *
5410
+ * The supervisor's contribution here is deliberately thin: identity (which project, room and
5411
+ * client this is, and whether it is an NPC) and a way back to the socket. It does not parse the
5412
+ * message, so no signaling shape is pinned inside the tenant boundary.
5413
+ */
5414
+ handleVoiceFrame(room, session, message) {
5415
+ const relay = this.config.voice;
5416
+ if (!relay) {
5417
+ this.sendVoice(
5418
+ session,
5419
+ encodeVoiceMessage({
5420
+ t: "error",
5421
+ code: "E_VOICE_UNAVAILABLE",
5422
+ message: "voice is not configured for this tenant"
5423
+ })
5424
+ );
2934
5425
  return;
2935
5426
  }
2936
- if (target.kind === "server") {
2937
- this.badFrame(session, "relay rooms have no server to address");
2938
- return;
5427
+ if (!session.npc) {
5428
+ const voiceCapRefusal = this.caps.refusal("voice_minutes");
5429
+ if (voiceCapRefusal !== void 0 && decodeVoiceMessage(message)?.t === "join") {
5430
+ this.sendVoice(
5431
+ session,
5432
+ encodeVoiceMessage({ t: "error", code: "E_USAGE_CAP", message: voiceCapRefusal })
5433
+ );
5434
+ return;
5435
+ }
2939
5436
  }
2940
- const out = encodeFrame2(
2941
- FrameType2.MSG,
2942
- encodeMsg({ target: { kind: "client", clientId: session.clientId }, payload: body })
5437
+ const ctx = {
5438
+ projectId: this.config.projectId,
5439
+ roomId: room.id,
5440
+ clientId: session.clientId,
5441
+ npc: session.npc
5442
+ };
5443
+ void relay.signal(ctx, message, (reply) => this.sendVoice(session, reply)).catch((err) => {
5444
+ this.log("warn", `voice relay failed for ${session.clientId}: ${String(err)}`);
5445
+ this.sendVoice(
5446
+ session,
5447
+ encodeVoiceMessage({
5448
+ t: "error",
5449
+ code: "E_VOICE_FAILED",
5450
+ message: "voice signaling failed"
5451
+ })
5452
+ );
5453
+ });
5454
+ }
5455
+ /** Writes one voice message back to a single client, inside its `{ kind: 'voice' }` envelope. */
5456
+ sendVoice(session, message) {
5457
+ if (session.state !== "joined") return;
5458
+ const sent = session.sendFrame(
5459
+ FrameType3.MSG,
5460
+ encodeMsg2({ target: { kind: "voice" }, payload: message })
2943
5461
  );
2944
- for (const peer of room.clients.values()) {
2945
- if (peer === session || peer.state !== "joined") continue;
2946
- if (target.kind === "client" && peer.clientId !== target.clientId) continue;
2947
- if (target.kind === "role" && peer.role !== target.role) continue;
2948
- const sent = peer.send(out);
2949
- if (sent > 0) {
2950
- room.metrics.egressBytes += sent;
2951
- room.metrics.framesOut++;
2952
- }
5462
+ const room = session.room;
5463
+ if (sent > 0 && room) {
5464
+ room.metrics.egressBytes += sent;
5465
+ room.metrics.framesOut++;
2953
5466
  }
2954
- room.lastTick = relay.advance();
2955
- this.armRelayIdle(room);
2956
5467
  }
2957
5468
  badFrame(session, message) {
2958
5469
  session.badFrameStrikes++;
@@ -2986,7 +5497,30 @@ var SupervisorImpl = class {
2986
5497
  case "send": {
2987
5498
  noteTick(room, msg.bytes);
2988
5499
  const session = room.clients.get(msg.clientId);
2989
- if (!session || session.state !== "joined") return;
5500
+ if (!session) {
5501
+ if (!room.unknownSendWarned.has(msg.clientId)) {
5502
+ room.unknownSendWarned.add(msg.clientId);
5503
+ this.roomLog(
5504
+ room,
5505
+ "warn",
5506
+ `dropped a ${frameName(msg.bytes)} frame for unknown client ${msg.clientId}`
5507
+ );
5508
+ }
5509
+ return;
5510
+ }
5511
+ if (session.state === "joining") {
5512
+ if (session.pendingOut.length < HELD_FRAMES_MAX) {
5513
+ session.pendingOut.push(msg.bytes);
5514
+ } else if (session.droppedHeld++ === 0) {
5515
+ this.roomLog(
5516
+ room,
5517
+ "warn",
5518
+ `dropped a ${frameName(msg.bytes)} frame for ${msg.clientId}: more than ${HELD_FRAMES_MAX} frames sent while it was still joining`
5519
+ );
5520
+ }
5521
+ return;
5522
+ }
5523
+ if (session.state !== "joined") return;
2990
5524
  const sent = session.send(msg.bytes);
2991
5525
  if (sent > 0) {
2992
5526
  room.metrics.egressBytes += sent;
@@ -3008,14 +5542,58 @@ var SupervisorImpl = class {
3008
5542
  );
3009
5543
  return;
3010
5544
  case "sleep":
5545
+ this.npcs.despawnRoom(room.id);
3011
5546
  void hibernateRoom(this, room);
3012
5547
  return;
3013
5548
  case "hostCall":
3014
5549
  void this.runHostCall(room, msg.reqId, msg.call);
3015
5550
  return;
5551
+ case "setBackfill":
5552
+ room.backfillOpen = msg.open;
5553
+ return;
3016
5554
  case "setAlarm":
5555
+ if (isMailboxAlarm(msg.name)) {
5556
+ this.roomLog(
5557
+ room,
5558
+ "warn",
5559
+ `refusing to arm ${JSON.stringify(msg.name)}: names beginning with ${JSON.stringify(BUS_MAILBOX_PREFIX2)} belong to the bus`
5560
+ );
5561
+ return;
5562
+ }
3017
5563
  room.alarms.set(msg.name, msg.atMs);
3018
5564
  return;
5565
+ case "busPublish":
5566
+ this.busPublish(room, msg.channel, msg.payload);
5567
+ return;
5568
+ case "busSubscribe": {
5569
+ if (!msg.subscribed) {
5570
+ this.bus.unsubscribe(room.id, msg.channel);
5571
+ return;
5572
+ }
5573
+ const problem = this.busBudget(room, "subscribe") ?? this.bus.subscribe(room.id, msg.channel);
5574
+ if (problem) {
5575
+ this.roomLog(room, "warn", `bus.subscribe: ${problem.code}: ${problem.message}`);
5576
+ }
5577
+ return;
5578
+ }
5579
+ case "spawnNpc": {
5580
+ const definition = this.deployments.get(room.version)?.bundle?.definition;
5581
+ if (!definition) {
5582
+ this.roomLog(room, "error", "irtio: spawnNPC needs a room bundle; none is loaded");
5583
+ return;
5584
+ }
5585
+ this.npcs.spawn(room.id, definition, msg.clientId, msg.config);
5586
+ return;
5587
+ }
5588
+ case "despawnNpc":
5589
+ this.npcs.despawn(msg.clientId);
5590
+ return;
5591
+ case "busAck":
5592
+ room.alarms.set(msg.name, void 0);
5593
+ return;
5594
+ case "stats":
5595
+ if (msg.physicsStepped === true) this.notePhysicsWarm();
5596
+ return;
3019
5597
  case "log":
3020
5598
  room.log(msg.level, ...msg.args);
3021
5599
  return;
@@ -3029,6 +5607,11 @@ var SupervisorImpl = class {
3029
5607
  /** Worker died: restart from the latest snapshot and resync everyone (plan §3.3). */
3030
5608
  onWorkerDead(room, reason) {
3031
5609
  if (room.state === "closed" || this.closing) return;
5610
+ const initializing = this.readyWaiters.get(room);
5611
+ if (initializing) {
5612
+ initializing({ t: "initFailed", reason });
5613
+ return;
5614
+ }
3032
5615
  this.roomLog(room, "error", `worker died (${reason}); restarting`);
3033
5616
  room.worker = void 0;
3034
5617
  room.metrics.restarts++;
@@ -3127,8 +5710,72 @@ var SupervisorImpl = class {
3127
5710
  };
3128
5711
  }
3129
5712
  logs(roomId) {
5713
+ if (roomId === TENANT_ROOM_ID) return this.tenantLogs.list();
3130
5714
  return this.registry.get(roomId)?.logs.list() ?? [];
3131
5715
  }
5716
+ /**
5717
+ * D36: pull every live room's tick counters out of its worker and into `RoomMetrics`.
5718
+ *
5719
+ * The counters exist already — `RoomCore.stats.overruns` has been incremented next to the
5720
+ * `tick overrun` log line since week 2 — but they never left the worker thread, so the one
5721
+ * signal that says "this room is shedding work" reached nothing a client, a CLI or an operator
5722
+ * could read. Every `overruns` a report shows now came through here.
5723
+ *
5724
+ * A room that does not answer is left exactly as it was, with `overruns` still `undefined` if
5725
+ * it has never answered. That is the whole point of the field being optional: `tick-health`
5726
+ * reports `unavailable` for it, rather than a zero nobody measured.
5727
+ */
5728
+ async refreshTickHealth(timeoutMs = 2e3) {
5729
+ const live = this.registry.values().filter((r) => r.worker?.alive === true);
5730
+ const answered = await Promise.all(
5731
+ live.map(async (room) => {
5732
+ const msg = await room.worker?.statsMessage(timeoutMs);
5733
+ if (!msg) return void 0;
5734
+ const stats = msg.stats;
5735
+ room.metrics.ticks = stats.ticks;
5736
+ room.metrics.maxTickMs = stats.maxTickMs;
5737
+ room.metrics.overruns = stats.overruns;
5738
+ if (msg.heap !== void 0) {
5739
+ this.recordHeap(room, msg.heap.usedBytes, msg.heap.limitBytes);
5740
+ }
5741
+ if (msg.profile !== void 0) room.profile = msg.profile;
5742
+ if (msg.physicsStepped === true) this.notePhysicsWarm();
5743
+ return room.id;
5744
+ })
5745
+ );
5746
+ return answered.filter((id) => id !== void 0);
5747
+ }
5748
+ /**
5749
+ * D55 step 2: the per-room heap reading, plus the warning that is the whole point of taking it.
5750
+ *
5751
+ * Before this, the first signal a developer got that a room was too big for its VM was the
5752
+ * tenant dying. The warning fires once per crossing of `HEAP_WARN_FRACTION` rather than on
5753
+ * every poll, and re-arms when the room drops back under the line, so a room that sits at 85%
5754
+ * says so once instead of filling the ring the operator would read it from. Room-scoped
5755
+ * (`roomLog`), because "which room" is the actionable half.
5756
+ */
5757
+ recordHeap(room, usedBytes, limitBytes) {
5758
+ room.metrics.heapUsedBytes = usedBytes;
5759
+ room.metrics.heapLimitBytes = limitBytes;
5760
+ const capBytes = this.limits.workerMaxOldGenMb * 1024 * 1024;
5761
+ if (capBytes <= 0) return;
5762
+ const fraction = usedBytes / capBytes;
5763
+ if (fraction >= HEAP_WARN_FRACTION) {
5764
+ if (!room.heapWarned) {
5765
+ room.heapWarned = true;
5766
+ const usedMb = Math.round(usedBytes / (1024 * 1024));
5767
+ const limitMb = this.limits.workerMaxOldGenMb;
5768
+ const pct = Math.round(fraction * 100);
5769
+ this.roomLog(
5770
+ room,
5771
+ "warn",
5772
+ `room heap at ${pct}% of its worker cap (${usedMb} MB of ${limitMb} MB); past the cap the worker is killed and the room restarts, and three restarts in a minute close it`
5773
+ );
5774
+ }
5775
+ return;
5776
+ }
5777
+ room.heapWarned = false;
5778
+ }
3132
5779
  async inspect(roomId) {
3133
5780
  const room = this.registry.get(roomId);
3134
5781
  if (!room) return void 0;
@@ -3152,6 +5799,34 @@ var SupervisorImpl = class {
3152
5799
  rss: inspected.rss
3153
5800
  };
3154
5801
  }
5802
+ /**
5803
+ * D41: arm a room's timeline recorder. Bundle rooms only: a relay room has no schema and no
5804
+ * authority over its state, so there is nothing authoritative to record, and answering `false`
5805
+ * is more honest than handing back an empty recording that would read as "nothing happened".
5806
+ */
5807
+ async startTimeline(roomId, options = {}) {
5808
+ const room = this.registry.get(roomId);
5809
+ if (!room?.worker || room.state !== "running") return false;
5810
+ return room.worker.startTimeline(options);
5811
+ }
5812
+ async readTimeline(roomId) {
5813
+ const room = this.registry.get(roomId);
5814
+ if (!room?.worker || room.state !== "running") return void 0;
5815
+ return room.worker.timeline();
5816
+ }
5817
+ /**
5818
+ * D43: a save generation, and its bytes. `saveRoom` writes it (same serialization, same
5819
+ * retention, same durability as `room.save()`), and the object is then read straight back out
5820
+ * of the store, so what a truth-seam diff decodes is the stored artifact rather than a copy of
5821
+ * it made on the way past.
5822
+ */
5823
+ async saveNow(roomId) {
5824
+ const room = this.registry.get(roomId);
5825
+ if (!room?.worker || room.state !== "running") return void 0;
5826
+ const saveId = await this.saveRoom(room);
5827
+ const bytes = await this.store.get(saveKey(this.storeKey(room), saveId));
5828
+ return bytes ? { saveId, bytes } : void 0;
5829
+ }
3155
5830
  /**
3156
5831
  * Ops/test hook: hard-kill a room's worker. The room takes the normal §3.3 crash path (restore
3157
5832
  * from the latest snapshot, resync everyone, restart cap). `false` when the room has no worker.
@@ -3167,14 +5842,19 @@ function header(v) {
3167
5842
  if (v === void 0) return void 0;
3168
5843
  return Array.isArray(v) ? v[0] : v;
3169
5844
  }
5845
+ function frameName(bytes) {
5846
+ const type = bytes[0];
5847
+ for (const [name, value] of Object.entries(FrameType3)) if (value === type) return name;
5848
+ return `type ${String(type)}`;
5849
+ }
3170
5850
  function noteTick(room, bytes) {
3171
5851
  if (bytes.length < 5) return;
3172
5852
  const type = bytes[0];
3173
- if (type !== FrameType2.DELTA && type !== FrameType2.CORRECT) return;
5853
+ if (type !== FrameType3.DELTA && type !== FrameType3.CORRECT) return;
3174
5854
  const tick = new DataView(bytes.buffer, bytes.byteOffset + 1, 4).getUint32(0, true);
3175
5855
  if (tick > room.lastTick) room.lastTick = tick;
3176
5856
  }
3177
- async function loadBundle(bundlePath, version) {
5857
+ async function loadBundle(bundlePath, version, type) {
3178
5858
  const bundleUrl = bundlePath.startsWith("file:") ? bundlePath : pathToFileURL2(bundlePath).href;
3179
5859
  let mod;
3180
5860
  try {
@@ -3191,6 +5871,7 @@ async function loadBundle(bundlePath, version) {
3191
5871
  const config = definition.config;
3192
5872
  return {
3193
5873
  version,
5874
+ type,
3194
5875
  definition,
3195
5876
  schemaHash: definition.schema.hash,
3196
5877
  hash8: definition.schema.hash8,
@@ -3200,7 +5881,17 @@ async function loadBundle(bundlePath, version) {
3200
5881
  tickRate: config.tickRate,
3201
5882
  idleMs: config.idleMs,
3202
5883
  reconnectGraceMs: config.reconnectGraceMs,
3203
- maxClients: config.maxClients
5884
+ maxClients: config.maxClients,
5885
+ // D47: the engine dimension for room-hours. Additive and read-only — the supervisor never
5886
+ // acts on it, it only reports it so part 3 can price the physics multiplier.
5887
+ ...typeof config.physics?.engine === "string" ? { engine: config.physics.engine } : {},
5888
+ // D58: the declared worker heap for this type. Absent means the tenant's derived default,
5889
+ // which is what every room got before types existed.
5890
+ ...typeof config.memoryMb === "number" ? { memoryMb: config.memoryMb } : {},
5891
+ // M5 part 3.5: the declared concurrency. Enforced (`startRoom` refuses room N+1), not
5892
+ // reported — the VM was sized on the strength of this number, so a room past it is a room
5893
+ // the machine was never built to hold.
5894
+ ...typeof config.maxAwake === "number" ? { maxAwake: config.maxAwake } : {}
3204
5895
  },
3205
5896
  bundleUrl,
3206
5897
  schemaJson: definition.schema.canonical
@@ -3402,40 +6093,8 @@ function resolveEntry(cwd, room) {
3402
6093
  create one (export default defineRoom(schema, {...})) or pass --room <file>`
3403
6094
  );
3404
6095
  }
3405
- async function readProjectId(cwd) {
3406
- const file = path.join(cwd, "irtio.json");
3407
- if (!existsSync(file)) return "dev";
3408
- let parsed;
3409
- try {
3410
- parsed = JSON.parse(await readFile(file, "utf8"));
3411
- } catch (err) {
3412
- throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
3413
- }
3414
- const project = parsed?.project;
3415
- if (project === void 0) return "dev";
3416
- if (typeof project !== "string" || project.length === 0) {
3417
- throw new Error(`irtio dev: ${file} has a "project" that is not a non-empty string`);
3418
- }
3419
- return project;
3420
- }
3421
- async function readClientEntry(cwd) {
3422
- const file = path.join(cwd, "irtio.json");
3423
- if (!existsSync(file)) return void 0;
3424
- let parsed;
3425
- try {
3426
- parsed = JSON.parse(await readFile(file, "utf8"));
3427
- } catch (err) {
3428
- throw new Error(`irtio dev: ${file} is not valid JSON: ${String(err)}`);
3429
- }
3430
- const client = parsed?.client;
3431
- if (client === void 0) return void 0;
3432
- if (typeof client !== "string" || client.length === 0) {
3433
- throw new Error(`irtio dev: ${file} has a "client" that is not a non-empty string`);
3434
- }
3435
- return client;
3436
- }
3437
- async function warnIfClientImportsRoom(cwd, entry, log) {
3438
- const client = await readClientEntry(cwd);
6096
+ async function warnIfClientImportsRoom(cwd, entry, config, log) {
6097
+ const client = config.client;
3439
6098
  if (client === void 0) return;
3440
6099
  const clientFile = path.resolve(cwd, client);
3441
6100
  if (await clientImportsRoom(clientFile, entry)) {
@@ -3446,20 +6105,18 @@ async function warnIfClientImportsRoom(cwd, entry, log) {
3446
6105
  );
3447
6106
  }
3448
6107
  }
3449
- function rapierAbsolutePlugin(fromPackageDir) {
6108
+ function engineAbsolutePlugin(fromPackageDir, pkg) {
3450
6109
  let resolved;
3451
6110
  try {
3452
- resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(
3453
- "@dimforge/rapier3d-compat"
3454
- );
6111
+ resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(pkg);
3455
6112
  } catch {
3456
6113
  return void 0;
3457
6114
  }
3458
6115
  const specifier = pathToFileURL3(resolved).href;
3459
6116
  return {
3460
- name: "irtio-dev-rapier-absolute",
6117
+ name: `irtio-dev-${pkg.replace(/[^a-z0-9]+/g, "-")}-absolute`,
3461
6118
  setup(build2) {
3462
- build2.onResolve({ filter: /^@dimforge\/rapier3d-compat$/ }, () => ({
6119
+ build2.onResolve({ filter: new RegExp(`^${pkg.replace(/[/\-]/g, "$&")}$`) }, () => ({
3463
6120
  path: specifier,
3464
6121
  external: true
3465
6122
  }));
@@ -3484,7 +6141,8 @@ async function resolveWorkerEntry2(outDir) {
3484
6141
  );
3485
6142
  }
3486
6143
  const outfile = path.join(outDir, "worker.mjs");
3487
- const rapierPlugin = rapierAbsolutePlugin(path.join(packagesDir, "runtime"));
6144
+ const runtimeDir = path.join(packagesDir, "runtime");
6145
+ const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
3488
6146
  await esbuild.build({
3489
6147
  entryPoints: [source],
3490
6148
  bundle: true,
@@ -3496,29 +6154,35 @@ async function resolveWorkerEntry2(outDir) {
3496
6154
  "@irtio/server": path.join(packagesDir, "server/src/index.ts"),
3497
6155
  "@irtio/protocol": path.join(packagesDir, "protocol/src/index.ts")
3498
6156
  },
3499
- ...rapierPlugin !== void 0 ? { plugins: [rapierPlugin] } : {},
6157
+ ...enginePlugins.length > 0 ? { plugins: enginePlugins } : {},
3500
6158
  // Rapier stays external for the same reason it does in packages/supervisor/test/support.ts:
3501
6159
  // it is a dependency of `@irtio/runtime`, resolved once at load time. Bundling a second copy
3502
6160
  // here would give the dev worker its own WASM instance, separate from the one the room
3503
6161
  // bundle resolves via node_modules. `rapierPlugin` (above) already externalizes it at an
3504
6162
  // absolute path when `@irtio/runtime`'s own copy can be found; this string entry is the
3505
6163
  // fallback for a published install where that resolution comes for free from node_modules.
3506
- external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat"]
6164
+ external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat", "matter-js"]
3507
6165
  });
3508
6166
  return outfile;
3509
6167
  }
3510
- async function buildState(supervisor, projectId, bundle) {
6168
+ async function buildState(supervisor, projectId, bundle, startedAt) {
6169
+ await supervisor.refreshTickHealth().catch(() => []);
3511
6170
  const infos = supervisor.rooms();
3512
6171
  const inspects = await Promise.all(
3513
6172
  infos.map((room) => supervisor.inspect(room.id).catch(() => void 0))
3514
6173
  );
3515
6174
  const logs = [];
3516
- for (const room of infos) {
3517
- for (const entry of supervisor.logs(room.id)) logs.push({ ...entry, roomId: room.id });
6175
+ for (const roomId of [TENANT_ROOM_ID, ...infos.map((r) => r.id)]) {
6176
+ for (const entry of supervisor.logs(roomId)) logs.push({ ...entry, roomId });
3518
6177
  }
3519
6178
  logs.sort((a, b) => a.at - b.at);
3520
6179
  return {
3521
6180
  project: projectId,
6181
+ // bugs.md #16: which build of the room is this server actually serving, and since when.
6182
+ // The bundle hash alone is not enough to catch the failure that motivated it: a forgotten
6183
+ // `dev` from an earlier session serves an old bundle from an old process, and a reader
6184
+ // comparing two runs needs to see that the *process* did not restart either.
6185
+ startedAt,
3522
6186
  bundle: {
3523
6187
  file: bundle.file,
3524
6188
  hash: bundle.hash,
@@ -3548,13 +6212,15 @@ function send(res, status, type, body) {
3548
6212
  }
3549
6213
  async function startDev(options = {}) {
3550
6214
  const cwd = path.resolve(options.cwd ?? process.cwd());
6215
+ const startedAt = Date.now();
3551
6216
  const log = options.log ?? ((line) => console.log(line));
3552
6217
  const entry = resolveEntry(cwd, options.room);
3553
- const projectId = await readProjectId(cwd);
6218
+ const config = await readProjectConfig(cwd, "irtio dev", options.config);
6219
+ const projectId = config.project ?? "dev";
3554
6220
  const outDir = path.join(cwd, ".irtio", "dev");
3555
6221
  const port = options.port ?? DEFAULT_PORT;
3556
6222
  await mkdir(outDir, { recursive: true });
3557
- await warnIfClientImportsRoom(cwd, entry, log);
6223
+ await warnIfClientImportsRoom(cwd, entry, config, log);
3558
6224
  const bundleOptions = {
3559
6225
  entry,
3560
6226
  outDir,
@@ -3576,6 +6242,69 @@ async function startDev(options = {}) {
3576
6242
  send(res, 200, "text/html; charset=utf-8", DEV_PAGE);
3577
6243
  return true;
3578
6244
  }
6245
+ if (route === "/__irt/record.json" || route === "/__irt/timeline.json") {
6246
+ const query = new URL(req.url ?? "", "http://localhost");
6247
+ const roomId = query.searchParams.get("room") ?? "";
6248
+ void (async () => {
6249
+ try {
6250
+ if (route === "/__irt/record.json") {
6251
+ const maxTicks = Number(query.searchParams.get("maxTicks"));
6252
+ const maxRecords = Number(query.searchParams.get("maxRecords"));
6253
+ const started = await supervisor.startTimeline(roomId, {
6254
+ ...Number.isInteger(maxTicks) && maxTicks > 0 ? { maxTicks } : {},
6255
+ ...Number.isInteger(maxRecords) && maxRecords > 0 ? { maxRecords } : {}
6256
+ });
6257
+ send(
6258
+ res,
6259
+ started ? 200 : 404,
6260
+ "application/json; charset=utf-8",
6261
+ JSON.stringify({
6262
+ recording: started,
6263
+ room: roomId,
6264
+ ...started ? {} : { error: `no running room ${roomId} with a worker to record` }
6265
+ })
6266
+ );
6267
+ return;
6268
+ }
6269
+ const dump = await supervisor.readTimeline(roomId);
6270
+ send(
6271
+ res,
6272
+ dump === void 0 ? 404 : 200,
6273
+ "application/json; charset=utf-8",
6274
+ JSON.stringify(
6275
+ dump ?? { error: `room ${roomId} has no recorded timeline`, room: roomId }
6276
+ )
6277
+ );
6278
+ } catch (err) {
6279
+ send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6280
+ }
6281
+ })();
6282
+ return true;
6283
+ }
6284
+ if (route === "/__irt/save.json") {
6285
+ const query = new URL(req.url ?? "", "http://localhost");
6286
+ const roomId = query.searchParams.get("room") ?? "";
6287
+ void (async () => {
6288
+ try {
6289
+ const saved = await supervisor.saveNow(roomId);
6290
+ send(
6291
+ res,
6292
+ saved === void 0 ? 404 : 200,
6293
+ "application/json; charset=utf-8",
6294
+ JSON.stringify(
6295
+ saved === void 0 ? { error: `no running room ${roomId} to save`, room: roomId } : {
6296
+ room: roomId,
6297
+ saveId: saved.saveId,
6298
+ bytes: Buffer.from(saved.bytes).toString("base64")
6299
+ }
6300
+ )
6301
+ );
6302
+ } catch (err) {
6303
+ send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6304
+ }
6305
+ })();
6306
+ return true;
6307
+ }
3579
6308
  if (route === "/__irt/state.json") {
3580
6309
  void (async () => {
3581
6310
  try {
@@ -3583,7 +6312,7 @@ async function startDev(options = {}) {
3583
6312
  res,
3584
6313
  200,
3585
6314
  "application/json; charset=utf-8",
3586
- JSON.stringify(await buildState(supervisor, projectId, bundle))
6315
+ JSON.stringify(await buildState(supervisor, projectId, bundle, startedAt))
3587
6316
  );
3588
6317
  } catch (err) {
3589
6318
  send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
@@ -3604,6 +6333,7 @@ async function startDev(options = {}) {
3604
6333
  store: new DiskStore(path.join(cwd, ".irtio", "snapshots")),
3605
6334
  publicUrl: `http://localhost:${port}`,
3606
6335
  httpHandler,
6336
+ ...options.profile === true ? { profile: true } : {},
3607
6337
  log: (level, ...args) => {
3608
6338
  const line = args.map((a) => typeof a === "string" ? a : safeJson(a)).join(" ");
3609
6339
  log(level === "error" ? pc.red(line) : level === "warn" ? pc.yellow(line) : pc.dim(line));
@@ -3685,8 +6415,23 @@ async function startDev(options = {}) {
3685
6415
  }
3686
6416
  };
3687
6417
  }
6418
+ var USAGE = `usage: irtio dev [options]
6419
+
6420
+ Bundles the room file, runs a real supervisor on it locally, and serves the inspector on the same
6421
+ port. Rebuilds and resets the rooms whenever the room code changes.
6422
+
6423
+ options:
6424
+ --room <file> the room entry (default: irtio/room.ts, irtio/room.js, room.ts)
6425
+ --port <n> port to listen on (default 7070; 0 picks a free one)
6426
+ --no-watch do not rebuild on change
6427
+ -c, --config <f> the project file to read (default irtio.json)
6428
+ --profile print a bandwidth breakdown per room once a second
6429
+ --profile-top <n> rows per table (default 12; implies --profile)
6430
+ -h, --help print this
6431
+ `;
3688
6432
  function parseDevArgs(args) {
3689
- const parsed = { watch: true };
6433
+ if (helpRequested(args)) throw helpFor(USAGE);
6434
+ const parsed = { watch: true, profile: false };
3690
6435
  for (let i = 0; i < args.length; i++) {
3691
6436
  const arg = args[i];
3692
6437
  const eq = arg.indexOf("=");
@@ -3712,6 +6457,22 @@ function parseDevArgs(args) {
3712
6457
  case "--no-watch":
3713
6458
  parsed.watch = false;
3714
6459
  break;
6460
+ case "--profile":
6461
+ parsed.profile = true;
6462
+ break;
6463
+ case "--profile-top": {
6464
+ const n = Number(value());
6465
+ if (!Number.isInteger(n) || n < 1 || n > 200) {
6466
+ throw new Error("irtio dev: --profile-top must be a row count between 1 and 200");
6467
+ }
6468
+ parsed.profile = true;
6469
+ parsed.profileTop = n;
6470
+ break;
6471
+ }
6472
+ case "-c":
6473
+ case "--config":
6474
+ parsed.config = value();
6475
+ break;
3715
6476
  default:
3716
6477
  throw new Error(`irtio dev: unknown option ${JSON.stringify(arg)}`);
3717
6478
  }
@@ -3719,15 +6480,22 @@ function parseDevArgs(args) {
3719
6480
  return parsed;
3720
6481
  }
3721
6482
  async function dev(args) {
3722
- const parsed = parseDevArgs(args);
6483
+ let parsed;
3723
6484
  let server;
3724
6485
  try {
6486
+ parsed = parseDevArgs(args);
3725
6487
  server = await startDev({
3726
6488
  watch: parsed.watch,
3727
6489
  ...parsed.room !== void 0 ? { room: parsed.room } : {},
3728
- ...parsed.port !== void 0 ? { port: parsed.port } : {}
6490
+ ...parsed.port !== void 0 ? { port: parsed.port } : {},
6491
+ ...parsed.config !== void 0 ? { config: parsed.config } : {},
6492
+ ...parsed.profile ? { profile: true } : {}
3729
6493
  });
3730
6494
  } catch (err) {
6495
+ if (err instanceof HelpRequested) {
6496
+ console.log(err.usage);
6497
+ return;
6498
+ }
3731
6499
  console.error(pc.red(err instanceof Error ? err.message : String(err)));
3732
6500
  process.exitCode = 1;
3733
6501
  return;
@@ -3739,10 +6507,42 @@ async function dev(args) {
3739
6507
  `share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
3740
6508
  )
3741
6509
  );
6510
+ let profileTimer;
6511
+ if (parsed.profile) {
6512
+ let history = /* @__PURE__ */ new Map();
6513
+ let busy = false;
6514
+ profileTimer = setInterval(() => {
6515
+ if (busy) return;
6516
+ busy = true;
6517
+ void (async () => {
6518
+ try {
6519
+ await server.supervisor.refreshTickHealth();
6520
+ const at = Date.now();
6521
+ const readings = server.supervisor.rooms().map((room) => ({
6522
+ id: room.id,
6523
+ profile: room.profile,
6524
+ egressBytes: room.metrics.egressBytes,
6525
+ ingressBytes: room.metrics.ingressBytes,
6526
+ at
6527
+ }));
6528
+ const tick = profileTick(readings, history, {
6529
+ ...parsed.profileTop !== void 0 ? { top: parsed.profileTop } : {}
6530
+ });
6531
+ history = tick.history;
6532
+ for (const line of tick.lines) console.log(line);
6533
+ } catch {
6534
+ } finally {
6535
+ busy = false;
6536
+ }
6537
+ })();
6538
+ }, 1e3);
6539
+ profileTimer.unref?.();
6540
+ }
3742
6541
  let stopping = false;
3743
6542
  const shutdown = () => {
3744
6543
  if (stopping) return;
3745
6544
  stopping = true;
6545
+ if (profileTimer) clearInterval(profileTimer);
3746
6546
  void (async () => {
3747
6547
  await server.stop();
3748
6548
  process.exit(0);
@@ -3754,6 +6554,7 @@ async function dev(args) {
3754
6554
  });
3755
6555
  }
3756
6556
  export {
6557
+ USAGE,
3757
6558
  dev,
3758
6559
  parseDevArgs,
3759
6560
  startDev