@irtio/cli 0.5.2 → 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 (37) 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/{chunk-GBNHBWES.js → chunk-3HQMVCYA.js} +10 -6
  5. package/dist/{chunk-32QTPKVT.js → chunk-DKWG7MGO.js} +1 -1
  6. package/dist/{chunk-KRQUAEN2.js → chunk-OTSFRVJN.js} +12 -6
  7. package/dist/chunk-RNAH5T4W.js +96 -0
  8. package/dist/chunk-RQSJZWQC.js +452 -0
  9. package/dist/chunk-UPHQM6NZ.js +72 -0
  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-3SABPL3T.js → deploy.js} +166 -44
  17. package/dist/{dev-QJOGXLKM.js → dev-QM26ONKS.js} +2957 -231
  18. package/dist/index.js +97 -25
  19. package/dist/init.d.ts +1 -1
  20. package/dist/init.js +20 -4
  21. package/dist/{keys-XBORZAPI.js → keys-JHLMEGRA.js} +8 -4
  22. package/dist/leaderboard-SYPSBPS3.js +352 -0
  23. package/dist/{login-3EXB4CGX.js → login-2M73HBZT.js} +12 -5
  24. package/dist/{logs-2EOXLNWF.js → logs-2W7CPZO5.js} +9 -5
  25. package/dist/{migrate-WUO2GBMX.js → migrate-T3DZJREY.js} +12 -8
  26. package/dist/ratings-VG32WFDG.js +297 -0
  27. package/dist/{rollback-GA6UY772.js → rollback-SO74MVZV.js} +9 -5
  28. package/dist/{rooms-B66LQIIF.js → rooms-VI33P4RA.js} +36 -10
  29. package/dist/simulate.d.ts +147 -4
  30. package/dist/simulate.js +680 -53
  31. package/dist/{static-deploy-5TBH4VNA.js → static-deploy-KOWFKWZA.js} +6 -4
  32. package/dist/status-HF3ZEKB7.js +219 -0
  33. package/dist/usage-4G23QXCH.js +213 -0
  34. package/dist/{whoami-CI5D5RCC.js → whoami-KTMTQNHM.js} +8 -4
  35. package/package.json +23 -7
  36. package/dist/chunk-BPE452KF.js +0 -180
  37. package/dist/chunk-TV66QHFP.js +0 -167
@@ -1,29 +1,43 @@
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";
19
27
  import {
20
28
  readProjectConfig
21
- } from "./chunk-32QTPKVT.js";
29
+ } from "./chunk-DKWG7MGO.js";
30
+ import {
31
+ BundleError,
32
+ bundleRoom
33
+ } from "./chunk-OTSFRVJN.js";
22
34
  import {
23
35
  HelpRequested,
24
36
  helpFor,
25
37
  helpRequested
26
- } from "./chunk-TV66QHFP.js";
38
+ } from "./chunk-ZD4ND6X6.js";
39
+ import "./chunk-RNAH5T4W.js";
40
+ import "./chunk-UPHQM6NZ.js";
27
41
 
28
42
  // src/dev.ts
29
43
  import { existsSync } from "fs";
@@ -48,13 +62,20 @@ var DEFAULT_LIMITS = {
48
62
  relayIdleMs: 3e4,
49
63
  relayReconnectGraceMs: 3e4,
50
64
  relayMaxClients: 64,
65
+ relayMaxClientsFree: 16,
66
+ freeMaxAwakeRooms: 10,
51
67
  saveRetain: DEFAULT_SAVE_RETAIN
52
68
  };
53
69
 
54
70
  // ../supervisor/src/codes.ts
55
71
  import { randomInt } from "crypto";
56
- var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
57
- var ROOM_ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
72
+ import {
73
+ CODE_ALPHABET,
74
+ DEFAULT_ROOM_TYPE,
75
+ ROOM_ID_RE,
76
+ formatRoomId,
77
+ parseRoomId
78
+ } from "@irtio/protocol";
58
79
  var TENANT_ROOM_ID = "__tenant";
59
80
  var CODE_SHAPE_RE = /^[A-Za-z0-9]{4,5}$/;
60
81
  function pick(n) {
@@ -86,9 +107,14 @@ function looksLikeRoomCode(id) {
86
107
  return true;
87
108
  }
88
109
  function normalizeRoomId(raw) {
89
- if (!ROOM_ID_RE.test(raw)) return void 0;
90
- if (raw === TENANT_ROOM_ID) return void 0;
91
- return looksLikeRoomCode(raw) ? raw.toUpperCase() : 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;
92
118
  }
93
119
 
94
120
  // ../supervisor/src/resilience.ts
@@ -132,27 +158,38 @@ async function putSnapshotWithRetry(store, key, bytes, metrics, log) {
132
158
  // ../supervisor/src/server.ts
133
159
  import { execFile } from "child_process";
134
160
  import { randomBytes, timingSafeEqual as timingSafeEqual2 } from "crypto";
161
+ import { readdirSync } from "fs";
135
162
  import { mkdtemp, writeFile } from "fs/promises";
136
163
  import { createServer } from "http";
137
164
  import { tmpdir } from "os";
138
165
  import * as nodePath from "path";
139
166
  import { pathToFileURL as pathToFileURL2 } from "url";
140
167
  import {
141
- ErrorCode as ErrorCode2,
142
- 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,
143
174
  PROTOCOL_VERSION,
175
+ ROOM_TYPE_RE,
144
176
  decodeFrame,
145
177
  decodeHello,
146
- decodeMsg,
147
178
  decodePing,
179
+ decodeVoiceMessage,
148
180
  encodeErrorPayload as encodeErrorPayload2,
149
- encodeFrame as encodeFrame2,
150
- encodeMsg,
181
+ encodeFrame as encodeFrame3,
182
+ encodeMsg as encodeMsg2,
151
183
  encodePong,
184
+ encodeVoiceMessage,
152
185
  encodeWelcome,
153
186
  formatError,
187
+ isMailboxAlarm,
188
+ isOutboxAlarm,
154
189
  isRelayHash8,
190
+ isVoiceMsg,
155
191
  relaySchema as relaySchema2,
192
+ schemaPayload,
156
193
  withBuiltins
157
194
  } from "@irtio/protocol";
158
195
  import { inspectState } from "@irtio/runtime";
@@ -179,6 +216,14 @@ var AlarmSet = class {
179
216
  /** `name` -> wall-clock ms the alarm is due at. Wall clock, not monotonic: it has to mean the
180
217
  * same thing to a control plane on another machine and across a tenant restart. */
181
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();
182
227
  timer;
183
228
  /** Called when one or more alarms come due, with the names in deterministic (name) order. */
184
229
  deliver;
@@ -197,13 +242,34 @@ var AlarmSet = class {
197
242
  this.deliver = void 0;
198
243
  this.clearTimer();
199
244
  }
200
- /** `atMs === undefined` cancels. Arming a name that is already armed replaces its due time. */
201
- set(name, atMs) {
202
- if (atMs === void 0) this.due.delete(name);
203
- 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
+ }
204
262
  this.rearm();
205
263
  this.onChange?.();
206
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
+ }
207
273
  get size() {
208
274
  return this.due.size;
209
275
  }
@@ -212,12 +278,19 @@ var AlarmSet = class {
212
278
  /** Replaces the armed set wholesale — how a room start re-arms from the persisted sidecar. */
213
279
  load(entries) {
214
280
  this.due.clear();
215
- 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
+ }
216
286
  this.rearm();
217
287
  }
218
288
  /** Snapshot for `/admin/rooms` and for tests. */
219
289
  entries() {
220
- 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));
221
294
  }
222
295
  /** The earliest due time, or `undefined` when nothing is armed. What control is told. */
223
296
  get earliestDueAt() {
@@ -236,6 +309,7 @@ var AlarmSet = class {
236
309
  if (names.length === 0) return names;
237
310
  for (const name of names) this.due.delete(name);
238
311
  this.deliver?.(names);
312
+ for (const name of names) if (!this.due.has(name)) this.payloads.delete(name);
239
313
  this.rearm();
240
314
  this.onChange?.();
241
315
  return names;
@@ -296,11 +370,64 @@ function decodeAlarms(bytes) {
296
370
  const e = entry;
297
371
  if (typeof e.name !== "string" || e.name === "") continue;
298
372
  if (typeof e.dueAt !== "number" || !Number.isFinite(e.dueAt)) continue;
299
- 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
+ });
300
379
  }
301
380
  return out;
302
381
  }
303
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
+
304
431
  // ../supervisor/src/auth.ts
305
432
  import { createHmac, timingSafeEqual } from "crypto";
306
433
  import { isLocalhostOrigin, originAllowed } from "@irtio/protocol";
@@ -372,6 +499,13 @@ function verifyJwt(issuers, token, projectId, now = Date.now()) {
372
499
  if (typeof claims.iss !== "string" || !PLAYER_ISSUER_RE.test(claims.iss)) {
373
500
  return malformed("iss is required: 1-64 of a-z 0-9 . _ - (never a colon)");
374
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
+ }
375
509
  if (typeof claims.aud !== "string") {
376
510
  return malformed("aud (the project id) is required");
377
511
  }
@@ -425,7 +559,7 @@ function mac(secret, payload) {
425
559
  return createHmac("sha256", secret).update(payload).digest("base64url");
426
560
  }
427
561
  function signResume(secret, p) {
428
- 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}`;
429
563
  return Buffer.from(`${payload}.${mac(secret, payload)}`, "utf8").toString("base64url");
430
564
  }
431
565
  function verifyResume(secret, token, now = Date.now()) {
@@ -444,17 +578,428 @@ function verifyResume(secret, token, now = Date.now()) {
444
578
  const b = Buffer.from(expected, "utf8");
445
579
  if (a.length !== b.length || !timingSafeEqual(a, b)) return void 0;
446
580
  const parts = payload.split("|");
447
- if (parts.length !== 4) return void 0;
448
- const [clientId, roomId, encodedRole, expText] = parts;
581
+ if (parts.length !== 5) return void 0;
582
+ const [clientId, roomId, encodedRole, expText, iatText] = parts;
449
583
  const expMs = Number(expText);
450
584
  if (!Number.isFinite(expMs) || expMs <= now) return void 0;
585
+ const iatMs = Number(iatText);
586
+ if (!Number.isFinite(iatMs)) return void 0;
451
587
  let role;
452
588
  try {
453
589
  role = decodeURIComponent(encodedRole);
454
590
  } catch {
455
591
  return void 0;
456
592
  }
457
- return { clientId, roomId, role, expMs };
593
+ return { clientId, roomId, role, expMs, iatMs };
594
+ }
595
+
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;
604
+ }
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;
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;
698
+ }
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");
703
+ }
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;
731
+ }
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 };
736
+ }
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;
760
+ }
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)}`;
808
+ }
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
+ };
458
1003
  }
459
1004
 
460
1005
  // ../supervisor/src/relay.ts
@@ -560,6 +1105,15 @@ var RelayRoom = class _RelayRoom {
560
1105
  return this.snapshot();
561
1106
  }
562
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
+ }
563
1117
 
564
1118
  // ../supervisor/src/hibernate.ts
565
1119
  function hibernateRoom(sup, room) {
@@ -637,9 +1191,9 @@ async function afterSleep(sup, room) {
637
1191
  sup.dropRoomIfIdle(room);
638
1192
  }
639
1193
  async function wakeRoom(sup, room) {
640
- if (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
641
- await room.transition;
642
- return;
1194
+ while (room.state === "waking" || room.state === "starting" || room.state === "hibernating") {
1195
+ await room.transition.catch(() => {
1196
+ });
643
1197
  }
644
1198
  if (room.state !== "hibernated") return;
645
1199
  room.state = "waking";
@@ -703,7 +1257,7 @@ async function migrateRoom(sup, room, toVersion) {
703
1257
  const oldKey = sup.storeKey(room);
704
1258
  let chain;
705
1259
  try {
706
- chain = sup.migrationChain(fromVersion, toVersion);
1260
+ chain = sup.migrationChain(fromVersion, toVersion, room.roomType);
707
1261
  } catch (err) {
708
1262
  return failed(err instanceof Error ? err.message : String(err));
709
1263
  }
@@ -779,7 +1333,7 @@ async function migrateRoom(sup, room, toVersion) {
779
1333
  outcome = failed(`migration to v${toVersion} failed; room restarted on v${fromVersion}`);
780
1334
  return;
781
1335
  }
782
- sup.failOutdatedSessions(room, toVersion);
1336
+ const swapped = sup.failOutdatedSessions(room, toVersion, fromVersion);
783
1337
  await sup.rejoinAll(room);
784
1338
  for (const clientId of room.pendingLeaves) {
785
1339
  room.worker?.post({ t: "leave", clientId, reason: "timeout" });
@@ -792,7 +1346,11 @@ async function migrateRoom(sup, room, toVersion) {
792
1346
  const ms = performance.now() - startedAt;
793
1347
  room.metrics.lastWakeMs = ms;
794
1348
  if (ms > room.metrics.maxWakeMs) room.metrics.maxWakeMs = ms;
795
- 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
+ );
796
1354
  outcome = { roomId: room.id, outcome: "migrated" };
797
1355
  })();
798
1356
  room.transition = work.catch((err) => {
@@ -874,7 +1432,8 @@ function newRoomMetrics() {
874
1432
  lastWakeMs: 0,
875
1433
  maxWakeMs: 0,
876
1434
  workerElu: 0,
877
- droppedFramesOnRestart: 0
1435
+ droppedFramesOnRestart: 0,
1436
+ npcConnections: 0
878
1437
  };
879
1438
  }
880
1439
  function emptyRoomsByState() {
@@ -898,15 +1457,222 @@ var LogRing = class {
898
1457
  }
899
1458
  };
900
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
+
901
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";
902
1667
  var WAKE_QUEUE_CAP = 256;
903
- function relayConfig(limits) {
1668
+ var SEEN_PLAYERS_CAP = 512;
1669
+ function relayConfig(limits, plan) {
904
1670
  return {
905
1671
  mode: "event",
906
1672
  tickRate: 0,
907
1673
  idleMs: limits.relayIdleMs,
908
1674
  reconnectGraceMs: limits.relayReconnectGraceMs,
909
- maxClients: limits.relayMaxClients
1675
+ maxClients: plan === "free" ? limits.relayMaxClientsFree : limits.relayMaxClients
910
1676
  };
911
1677
  }
912
1678
  var RoomRecord = class {
@@ -923,6 +1689,14 @@ var RoomRecord = class {
923
1689
  relayRoom;
924
1690
  lastTick = 0;
925
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;
926
1700
  logs = new LogRing();
927
1701
  createdAt = Date.now();
928
1702
  /** Frames that arrived while hibernating/waking, replayed in order after the re-joins. */
@@ -931,8 +1705,39 @@ var RoomRecord = class {
931
1705
  droppedWakeFrames = 0;
932
1706
  /** Sessions whose grace expired while the room had no worker; `leave` is sent on the next wake. */
933
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();
934
1714
  /** Timestamps of worker restarts, pruned to the last minute (plan §3.3 cap). */
935
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;
936
1741
  /**
937
1742
  * The deployment version this room was created under. New rooms take the newest; rooms created
938
1743
  * before a deploy keep theirs until they idle (per-room drain, plan §3.3).
@@ -944,17 +1749,98 @@ var RoomRecord = class {
944
1749
  * ever serialized into the hibernation blob.
945
1750
  */
946
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();
947
1789
  /**
948
1790
  * D24: a save generation to start this room from instead of its live snapshot, set from the
949
1791
  * placement instruction and consumed by the next start. One shot: a restore is an event, not a
950
1792
  * standing property of the room.
951
1793
  */
952
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;
953
1801
  /** Serialises room-level async work (start / wake / restart / hibernate). */
954
1802
  transition = Promise.resolve();
955
1803
  /** Periodic crash-safety snapshot (event-mode bundle rooms) or the relay idle timer. */
956
1804
  timer;
957
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
+ }
958
1844
  /** Sessions with a live socket that have completed their join. */
959
1845
  connected() {
960
1846
  return [...this.clients.values()].filter((s) => s.state === "joined" && s.open);
@@ -973,6 +1859,31 @@ var RoomRecord = class {
973
1859
  log(level, ...args) {
974
1860
  this.logs.push(level, args);
975
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
+ }
976
1887
  info() {
977
1888
  const dueAlarmAt = this.alarms.earliestDueAt;
978
1889
  return {
@@ -984,7 +1895,13 @@ var RoomRecord = class {
984
1895
  metrics: { ...this.metrics, workerElu: this.worker?.elu ?? 0 },
985
1896
  createdAt: this.createdAt,
986
1897
  ...dueAlarmAt !== void 0 ? { dueAlarmAt } : {},
987
- ...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 } : {}
988
1905
  };
989
1906
  }
990
1907
  };
@@ -1010,15 +1927,52 @@ var RoomRegistry = class {
1010
1927
  }
1011
1928
  };
1012
1929
 
1013
- // ../supervisor/src/session.ts
1014
- import {
1015
- ErrorCode,
1016
- FrameType,
1017
- encodeErrorPayload,
1018
- encodeFrame
1019
- } from "@irtio/protocol";
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
+
1966
+ // ../supervisor/src/session.ts
1967
+ import {
1968
+ ErrorCode as ErrorCode2,
1969
+ FrameType as FrameType2,
1970
+ encodeErrorPayload,
1971
+ encodeFrame as encodeFrame2
1972
+ } from "@irtio/protocol";
1020
1973
  var CLOSE_POLICY = 1008;
1021
1974
  var CLOSE_GRACE_MS = 250;
1975
+ var HELD_FRAMES_MAX = 16;
1022
1976
  var Session = class {
1023
1977
  constructor(ws, remoteAddress, origin, framesPerSec, maxBufferedBytes, onSlowConsumer) {
1024
1978
  this.ws = ws;
@@ -1034,6 +1988,16 @@ var Session = class {
1034
1988
  maxBufferedBytes;
1035
1989
  onSlowConsumer;
1036
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;
1037
2001
  clientId = "";
1038
2002
  roomId = "";
1039
2003
  /** Set once the HELLO resolved a room. */
@@ -1048,6 +2012,24 @@ var Session = class {
1048
2012
  * session whose schema is older than the room's new version by name (`E_SCHEMA_MISMATCH`)
1049
2013
  * rather than send it a resync WELCOME it cannot decode. */
1050
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;
1051
2033
  /** Set when the session was rejected/kicked: no grace window on close. */
1052
2034
  fatal = false;
1053
2035
  /** Slow consumers are closed but keep their grace window (a resume gets a fresh snapshot). */
@@ -1064,6 +2046,14 @@ var Session = class {
1064
2046
  get open() {
1065
2047
  return this.ws.readyState === this.ws.OPEN;
1066
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;
1067
2057
  /**
1068
2058
  * Sends one already-framed message. Returns the byte count sent (0 when dropped). A client whose
1069
2059
  * `bufferedAmount` is over the limit is closed with `E_SLOW_CONSUMER` — that's exactly what
@@ -1076,15 +2066,16 @@ var Session = class {
1076
2066
  return 0;
1077
2067
  }
1078
2068
  this.ws.send(bytes, { binary: true });
2069
+ if (!this.npc) this.onEgress?.(bytes.length);
1079
2070
  return bytes.length;
1080
2071
  }
1081
2072
  sendFrame(type, payload) {
1082
- return this.send(encodeFrame(type, payload));
2073
+ return this.send(encodeFrame2(type, payload));
1083
2074
  }
1084
2075
  sendError(code, message, fatal) {
1085
2076
  this.sendFrame(
1086
- FrameType.ERROR,
1087
- encodeErrorPayload({ code: ErrorCode[code].code, message, fatal })
2077
+ FrameType2.ERROR,
2078
+ encodeErrorPayload({ code: ErrorCode2[code].code, message, fatal })
1088
2079
  );
1089
2080
  }
1090
2081
  /** ERROR + close (1008). Fatal rejections skip the grace window on the following `close` event. */
@@ -1093,6 +2084,19 @@ var Session = class {
1093
2084
  this.sendError(code, message, true);
1094
2085
  this.closeSocket(CLOSE_POLICY, code);
1095
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
+ }
1096
2100
  closeSocket(code = CLOSE_POLICY, reason = "") {
1097
2101
  this.clearHelloTimer();
1098
2102
  if (this.ws.readyState === this.ws.CLOSED) return;
@@ -1128,11 +2132,99 @@ var Session = class {
1128
2132
  name: this.name,
1129
2133
  state: this.state,
1130
2134
  remoteAddress: this.remoteAddress,
1131
- origin: this.origin
2135
+ origin: this.origin,
2136
+ npc: this.npc
1132
2137
  };
1133
2138
  }
1134
2139
  };
1135
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
+
1136
2228
  // ../supervisor/src/worker-host.ts
1137
2229
  import { pathToFileURL } from "url";
1138
2230
  import { Worker } from "worker_threads";
@@ -1190,7 +2282,7 @@ var WorkerHost = class {
1190
2282
  }
1191
2283
  }
1192
2284
  onMessage(msg) {
1193
- if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats") && msg.reqId !== void 0 && 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)) {
1194
2286
  const resolve2 = this.pending.get(msg.reqId);
1195
2287
  this.pending.delete(msg.reqId);
1196
2288
  resolve2?.(msg);
@@ -1261,6 +2353,27 @@ var WorkerHost = class {
1261
2353
  );
1262
2354
  return msg?.t === "inspected" ? msg : void 0;
1263
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
+ }
1264
2377
  /**
1265
2378
  * D36: the room's own tick counters — `overruns` and `maxTickMs` above all. `undefined` when
1266
2379
  * the worker died or did not answer in time, and the caller must report that as *unavailable*
@@ -1268,11 +2381,20 @@ var WorkerHost = class {
1268
2381
  * and that is exactly the lie the `tick-health` invariant exists to prevent.
1269
2382
  */
1270
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) {
1271
2393
  const msg = await this.request(
1272
2394
  (reqId) => ({ t: "stats", reqId }),
1273
2395
  timeoutMs
1274
2396
  );
1275
- return msg?.t === "stats" ? msg.stats : void 0;
2397
+ return msg?.t === "stats" ? msg : void 0;
1276
2398
  }
1277
2399
  /** Intentional shutdown: `onDead` is not fired. */
1278
2400
  async terminate() {
@@ -1294,14 +2416,44 @@ var WorkerHost = class {
1294
2416
  };
1295
2417
 
1296
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
+ }
1297
2436
  var WS_PING_MS = 2e4;
1298
2437
  var WS_PING_MISSES = 2;
1299
2438
  var MAX_STRIKES = 3;
1300
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];
1301
2445
  var KvUnavailable = class extends Error {
1302
2446
  code = KV_ERRORS.unavailable;
1303
2447
  name = "KvUnavailable";
1304
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
+ };
1305
2457
  function codeOf(err) {
1306
2458
  const code = err?.code;
1307
2459
  return typeof code === "string" && code.startsWith("E_") ? code : void 0;
@@ -1345,6 +2497,7 @@ var SupervisorImpl = class {
1345
2497
  `placement carries ${this.pendingRestores.size} pending restore(s): ` + [...this.pendingRestores].map(([r, sv]) => `${r}<-${sv}`).join(", ")
1346
2498
  );
1347
2499
  }
2500
+ this.identityKeys = config.identityKeys ?? [];
1348
2501
  this.resumeSecret = config.resumeSecret ?? randomBytes(32).toString("base64url");
1349
2502
  if (config.resumeSecret === void 0) {
1350
2503
  this.log("warn", "no resumeSecret configured: resume tokens die with this process");
@@ -1355,6 +2508,15 @@ var SupervisorImpl = class {
1355
2508
  this.limits.connectionsPerIpPerMin / 60
1356
2509
  )
1357
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
+ });
1358
2520
  this.helloBuckets = new BucketMap(
1359
2521
  () => new TokenBucket(this.limits.hellosPerMin, this.limits.hellosPerMin / 60)
1360
2522
  );
@@ -1376,6 +2538,8 @@ var SupervisorImpl = class {
1376
2538
  res.writeHead(404).end();
1377
2539
  });
1378
2540
  this.wss = new WebSocketServer({ server: this.http });
2541
+ this.wss.on("error", () => {
2542
+ });
1379
2543
  this.wss.on("connection", (ws, req) => {
1380
2544
  this.onConnection(ws, {
1381
2545
  origin: header(req.headers.origin),
@@ -1408,9 +2572,21 @@ var SupervisorImpl = class {
1408
2572
  wss;
1409
2573
  readyPromise;
1410
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;
1411
2585
  helloBuckets;
1412
2586
  resumeSecret;
1413
2587
  clientIds = /* @__PURE__ */ new Set();
2588
+ /** D44: the scripted NPCs this tenant is running, and the bot runtime driving each. */
2589
+ npcs;
1414
2590
  joinWaiters = /* @__PURE__ */ new Map();
1415
2591
  readyWaiters = /* @__PURE__ */ new Map();
1416
2592
  creating = /* @__PURE__ */ new Map();
@@ -1425,8 +2601,116 @@ var SupervisorImpl = class {
1425
2601
  * reported on `/admin/idle` so the host-agent can escalate instead of reading "not idle yet"
1426
2602
  * forever. Reset the moment a flush succeeds cleanly, or a client reconnects. */
1427
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
+ }
1428
2651
  idleTimer;
1429
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 = [];
1430
2714
  /**
1431
2715
  * D24: `roomId -> saveId` placement instructions that have not been consumed yet. Populated
1432
2716
  * from `SupervisorConfig.restoreRooms` at boot — placement is the only channel into a tenant,
@@ -1444,6 +2728,7 @@ var SupervisorImpl = class {
1444
2728
  }
1445
2729
  this.bundle = this.newestBundle();
1446
2730
  this.schemaHash = this.bundle?.schemaHash;
2731
+ this.checkDeclaredSizing();
1447
2732
  this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
1448
2733
  await new Promise((resolve2, reject) => {
1449
2734
  this.http.once("error", reject);
@@ -1455,6 +2740,8 @@ var SupervisorImpl = class {
1455
2740
  });
1456
2741
  this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
1457
2742
  this.pingTimer.unref?.();
2743
+ this.usageTimer = setInterval(() => this.sampleUsage(), ROOM_SAMPLE_MS);
2744
+ this.usageTimer.unref?.();
1458
2745
  const tenantIdleMs = this.config.tenantIdleMs ?? 3e5;
1459
2746
  if (tenantIdleMs > 0) {
1460
2747
  const every = Math.min(1e3, Math.max(50, Math.floor(tenantIdleMs / 4)));
@@ -1465,7 +2752,29 @@ var SupervisorImpl = class {
1465
2752
  "info",
1466
2753
  `supervisor listening on ${this.port} (${this.bundle ? "bundle" : "relay"} tenant)`
1467
2754
  );
1468
- 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
+ }
1469
2778
  }
1470
2779
  /**
1471
2780
  * D26, and the piece staging caught missing: **start every room that owes an alarm**, at boot.
@@ -1512,11 +2821,16 @@ var SupervisorImpl = class {
1512
2821
  );
1513
2822
  return;
1514
2823
  }
1515
- this.log(
1516
- "info",
1517
- `resuming ${rooms.size} room(s) with pending alarms: ${[...rooms].join(", ")}`
1518
- );
1519
- 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) {
1520
2834
  try {
1521
2835
  await this.ensureRoom(roomId);
1522
2836
  } catch (err) {
@@ -1524,6 +2838,42 @@ var SupervisorImpl = class {
1524
2838
  }
1525
2839
  }
1526
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
+ }
1527
2877
  /**
1528
2878
  * Everything a **VM-snapshot restore** owes the alarm clock, run from `/admin/resume` once the
1529
2879
  * guest wall clock has been corrected.
@@ -1582,7 +2932,7 @@ var SupervisorImpl = class {
1582
2932
  * rooms that are still serving the old code.
1583
2933
  */
1584
2934
  async reloadBundle(bundlePath) {
1585
- const loaded = await loadBundle(bundlePath, this.deploymentVersion);
2935
+ const loaded = await loadBundle(bundlePath, this.deploymentVersion, DEFAULT_ROOM_TYPE3);
1586
2936
  for (const room of this.registry.values()) {
1587
2937
  room.wakeQueue.length = 0;
1588
2938
  room.droppedWakeFrames = 0;
@@ -1592,18 +2942,36 @@ var SupervisorImpl = class {
1592
2942
  this.deployments.set(loaded.version, {
1593
2943
  version: loaded.version,
1594
2944
  schemaJson: loaded.schemaJson,
1595
- bundle: loaded
2945
+ bundle: loaded,
2946
+ bundles: /* @__PURE__ */ new Map([[DEFAULT_ROOM_TYPE3, loaded]])
1596
2947
  });
1597
2948
  this.bundle = loaded;
1598
2949
  this.schemaHash = loaded.schemaHash;
1599
2950
  this.log("info", `bundle reloaded from ${bundlePath}`);
1600
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
+ }
1601
2965
  async close() {
1602
2966
  if (this.closing) return;
1603
2967
  this.closing = true;
1604
2968
  this.quiesceAlarms();
2969
+ this.npcs.despawnAll();
2970
+ this.releaseIdentityKeyWaiters();
1605
2971
  if (this.idleTimer) clearInterval(this.idleTimer);
1606
2972
  if (this.pingTimer) clearInterval(this.pingTimer);
2973
+ this.sampleUsage();
2974
+ if (this.usageTimer) clearInterval(this.usageTimer);
1607
2975
  try {
1608
2976
  await flushAll(this);
1609
2977
  } catch (err) {
@@ -1651,6 +3019,25 @@ var SupervisorImpl = class {
1651
3019
  else console[level === "info" ? "log" : level]("[irtio]", ...args);
1652
3020
  }
1653
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
+ }
1654
3041
  roomLog(room, level, ...args) {
1655
3042
  room.log(level, ...args);
1656
3043
  this.emit(level, [`[${room.id}]`, ...args]);
@@ -1693,7 +3080,10 @@ var SupervisorImpl = class {
1693
3080
  // §10.8 escalation: consecutive flush passes that left a room unflushed. The host agent
1694
3081
  // treats >=3 as worth an error-level log (once, deduped) instead of reading the tenant
1695
3082
  // as merely slow to go idle.
1696
- 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
1697
3087
  });
1698
3088
  return true;
1699
3089
  case "/admin/rooms":
@@ -1704,12 +3094,16 @@ var SupervisorImpl = class {
1704
3094
  () => json({
1705
3095
  at: Date.now(),
1706
3096
  metrics: this.metrics(),
1707
- rooms: this.rooms().map((r) => r.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()
1708
3101
  }),
1709
3102
  () => json({
1710
3103
  at: Date.now(),
1711
3104
  metrics: this.metrics(),
1712
- rooms: this.rooms().map((r) => r.metrics)
3105
+ rooms: this.rooms().map((r) => ({ id: r.id, metrics: r.metrics })),
3106
+ usage: this.usageWindows()
1713
3107
  })
1714
3108
  );
1715
3109
  return true;
@@ -1821,10 +3215,31 @@ var SupervisorImpl = class {
1821
3215
  migrationPath = nodePath.join(dir, `m${d.version}.mjs`);
1822
3216
  await writeFile(migrationPath, Buffer.from(d.migrationB64, "base64"));
1823
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
+ }
1824
3238
  await this.addBundle({
1825
3239
  version: d.version,
1826
3240
  ...typeof d.schemaJson === "string" ? { schemaJson: d.schemaJson } : {},
1827
3241
  ...bundlePath !== void 0 ? { bundlePath } : {},
3242
+ ...rooms2 !== void 0 ? { rooms: rooms2 } : {},
1828
3243
  ...migrationPath !== void 0 ? { migrationPath } : {}
1829
3244
  });
1830
3245
  added.push(d.version);
@@ -1849,6 +3264,160 @@ var SupervisorImpl = class {
1849
3264
  });
1850
3265
  return true;
1851
3266
  }
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;
3283
+ }
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
+ });
3318
+ return true;
3319
+ }
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
+ }
1852
3421
  case "/admin/logs": {
1853
3422
  const since = Number(url.searchParams.get("since") ?? 0) || 0;
1854
3423
  const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 1e3);
@@ -1897,7 +3466,25 @@ var SupervisorImpl = class {
1897
3466
  }
1898
3467
  async registerDeployment(ref) {
1899
3468
  const migrationUrl = ref.migrationPath !== void 0 ? toFileUrl(ref.migrationPath) : void 0;
1900
- 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);
1901
3488
  const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
1902
3489
  if (schemaJson === void 0) {
1903
3490
  throw new Error(
@@ -1907,14 +3494,210 @@ var SupervisorImpl = class {
1907
3494
  this.deployments.set(ref.version, {
1908
3495
  version: ref.version,
1909
3496
  schemaJson,
3497
+ bundles,
1910
3498
  ...migrationUrl !== void 0 ? { migrationUrl } : {},
1911
3499
  ...bundle !== void 0 ? { bundle } : {}
1912
3500
  });
1913
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
+ }
1914
3697
  /** Registers a newer deployment. Rooms already running keep theirs and drain (plan §3.3). */
1915
3698
  async addBundle(ref) {
1916
- if (ref.bundlePath === void 0 && ref.schemaJson === void 0) {
1917
- 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");
1918
3701
  }
1919
3702
  await this.registerDeployment(ref);
1920
3703
  const newest = this.newestBundle();
@@ -1966,14 +3749,16 @@ var SupervisorImpl = class {
1966
3749
  * `from` up to `to`. `undefined` when there is nothing to do; throws when a version in between
1967
3750
  * is unknown, which would otherwise mean decoding bytes with the wrong schema.
1968
3751
  */
1969
- migrationChain(from, to) {
3752
+ migrationChain(from, to, type = DEFAULT_ROOM_TYPE3) {
1970
3753
  if (from >= to) return void 0;
1971
3754
  const steps = [];
1972
3755
  for (const d of [...this.deployments.values()].sort((a, b) => a.version - b.version)) {
1973
3756
  if (d.version < from || d.version > to) continue;
1974
3757
  steps.push({
1975
3758
  version: d.version,
1976
- 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,
1977
3762
  ...d.migrationUrl !== void 0 && d.version > from ? { migrationUrl: d.migrationUrl } : {}
1978
3763
  });
1979
3764
  }
@@ -2002,6 +3787,15 @@ var SupervisorImpl = class {
2002
3787
  this.limits.maxBufferedBytes,
2003
3788
  (s) => this.onSlowConsumer(s)
2004
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
+ };
2005
3799
  this.sessions.add(session);
2006
3800
  this.idleSince = 0;
2007
3801
  this.idleFired = false;
@@ -2014,13 +3808,20 @@ var SupervisorImpl = class {
2014
3808
  session.missedPongs = 0;
2015
3809
  });
2016
3810
  ws.on("message", (data) => this.onSocketMessage(session, data));
2017
- if (!originAllowed(this.config.origins, this.config.allowNoOrigin !== false, origin)) {
2018
- session.fail("E_ORIGIN", formatError("E_ORIGIN", { origin: origin ?? "(none)" }));
2019
- return;
2020
- }
2021
- if (!this.ipBuckets.take(remoteAddress)) {
2022
- session.fail("E_RATE_LIMITED", "too many connections from this address");
2023
- 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
+ }
2024
3825
  }
2025
3826
  session.helloTimer = setTimeout(() => {
2026
3827
  session.helloTimer = void 0;
@@ -2037,7 +3838,7 @@ var SupervisorImpl = class {
2037
3838
  let payload;
2038
3839
  try {
2039
3840
  const frame = decodeFrame(bytes);
2040
- if (frame.type !== FrameType2.HELLO) {
3841
+ if (frame.type !== FrameType3.HELLO) {
2041
3842
  session.fail("E_BAD_FRAME", formatError("E_BAD_FRAME", { frame: frame.type }));
2042
3843
  return;
2043
3844
  }
@@ -2056,10 +3857,10 @@ var SupervisorImpl = class {
2056
3857
  session.slowConsumer = true;
2057
3858
  try {
2058
3859
  session.ws.send(
2059
- encodeFrame2(
2060
- FrameType2.ERROR,
3860
+ encodeFrame3(
3861
+ FrameType3.ERROR,
2061
3862
  encodeErrorPayload2({
2062
- code: ErrorCode2.E_SLOW_CONSUMER.code,
3863
+ code: ErrorCode3.E_SLOW_CONSUMER.code,
2063
3864
  message: formatError("E_SLOW_CONSUMER"),
2064
3865
  fatal: true
2065
3866
  })
@@ -2107,6 +3908,9 @@ var SupervisorImpl = class {
2107
3908
  if (session.state !== "joined") return;
2108
3909
  session.state = "disconnected";
2109
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
+ }
2110
3914
  if (session.fatal && !session.slowConsumer) {
2111
3915
  this.expireSession(session, "kicked");
2112
3916
  return;
@@ -2118,7 +3922,7 @@ var SupervisorImpl = class {
2118
3922
  const delta = room.relayRoom.setConnected(session.clientId, false);
2119
3923
  if (delta) {
2120
3924
  room.lastTick = room.relayRoom.tick;
2121
- this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta));
3925
+ this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
2122
3926
  }
2123
3927
  }
2124
3928
  session.graceTimer = setTimeout(
@@ -2140,11 +3944,21 @@ var SupervisorImpl = class {
2140
3944
  if (!room) return;
2141
3945
  if (room.clients.get(session.clientId) === session) room.clients.delete(session.clientId);
2142
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
+ }
2143
3957
  if (room.relay) {
2144
3958
  const delta = room.relayRoom?.remove(session.clientId);
2145
3959
  if (delta && room.relayRoom) {
2146
3960
  room.lastTick = room.relayRoom.tick;
2147
- this.broadcast(room, encodeFrame2(FrameType2.DELTA, delta));
3961
+ this.broadcast(room, encodeFrame3(FrameType3.DELTA, delta));
2148
3962
  }
2149
3963
  } else if (room.state === "running" && room.worker) {
2150
3964
  room.worker.post({ t: "leave", clientId: session.clientId, reason });
@@ -2156,6 +3970,44 @@ var SupervisorImpl = class {
2156
3970
  // -------------------------------------------------------------------------
2157
3971
  // HELLO
2158
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
+ }
2159
4011
  async handleHello(session, payload) {
2160
4012
  session.clearHelloTimer();
2161
4013
  let hello;
@@ -2182,7 +4034,16 @@ var SupervisorImpl = class {
2182
4034
  return;
2183
4035
  }
2184
4036
  let jwt;
2185
- 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") {
2186
4047
  const issuers = this.config.jwtIssuers;
2187
4048
  if (issuers === void 0 || issuers.length === 0) {
2188
4049
  session.fail("E_AUTH", "this project has no JWT secret \u2014 mint one: irtio keys jwt-secret");
@@ -2196,23 +4057,12 @@ var SupervisorImpl = class {
2196
4057
  }
2197
4058
  jwt = verdict;
2198
4059
  }
2199
- if (this.bundle) {
2200
- const known = [...this.deployments.values()].some(
2201
- (d) => d.bundle !== void 0 && bytesEqual(hello.schemaHash8, d.bundle.hash8)
2202
- );
2203
- if (!known) {
2204
- session.fail("E_SCHEMA_MISMATCH", formatError("E_SCHEMA_MISMATCH"));
2205
- return;
2206
- }
2207
- } else if (!isRelayHash8(hello.schemaHash8)) {
2208
- session.fail("E_SCHEMA_MISMATCH", "this project has no room code deployed (relay tenant)");
2209
- return;
2210
- }
2211
4060
  session.state = "joining";
2212
4061
  session.role = jwt?.role ?? hello.role ?? "";
2213
4062
  session.name = hello.name ?? "";
2214
4063
  session.playerId = jwt?.playerId;
2215
4064
  session.schemaHash8 = hello.schemaHash8;
4065
+ session.schemaSwap = hello.schemaSwap === true;
2216
4066
  let resume;
2217
4067
  if (hello.resumeToken !== void 0 && hello.resumeToken !== "") {
2218
4068
  resume = verifyResume(this.resumeSecret, hello.resumeToken);
@@ -2238,10 +4088,60 @@ var SupervisorImpl = class {
2238
4088
  } else {
2239
4089
  roomId = newRoomCode((code) => this.registry.has(code));
2240
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
+ }
2241
4116
  if (jwt?.roomId !== void 0 && jwt.roomId !== roomId) {
2242
4117
  session.fail("E_TOKEN_WRONG_ROOM", formatError("E_TOKEN_WRONG_ROOM"));
2243
4118
  return;
2244
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
+ }
2245
4145
  const room = await this.ensureRoom(roomId);
2246
4146
  if (!room) {
2247
4147
  session.fail("E_INTERNAL", formatError("E_INTERNAL"));
@@ -2256,8 +4156,12 @@ var SupervisorImpl = class {
2256
4156
  session.roomId = roomId;
2257
4157
  let reconnecting = false;
2258
4158
  if (resume) {
2259
- reconnecting = true;
2260
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;
2261
4165
  if (existing && existing !== session) {
2262
4166
  existing.clearGraceTimer();
2263
4167
  existing.state = "gone";
@@ -2270,7 +4174,16 @@ var SupervisorImpl = class {
2270
4174
  session.clientId = resume.clientId;
2271
4175
  if (session.role === "") session.role = resume.role;
2272
4176
  } else {
2273
- 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
+ }
2274
4187
  }
2275
4188
  this.clientIds.add(session.clientId);
2276
4189
  room.clients.set(session.clientId, session);
@@ -2286,21 +4199,37 @@ var SupervisorImpl = class {
2286
4199
  if (inFlight) return inFlight;
2287
4200
  const existing = this.registry.get(roomId);
2288
4201
  if (existing) {
2289
- if (existing.state === "hibernated") await wakeRoom(this, existing);
2290
- else await existing.transition.catch(() => {
2291
- });
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
+ }
2292
4212
  return existing;
2293
4213
  }
2294
4214
  const create = (async () => {
2295
- const config = this.bundle ? this.bundle.config : relayConfig(this.limits);
2296
- 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);
2297
4226
  room.version = this.deploymentVersion;
2298
4227
  room.pendingRestoreSaveId = this.pendingRestores.get(roomId);
2299
4228
  this.pendingRestores.delete(roomId);
2300
4229
  this.registry.set(room);
2301
4230
  const restored = await this.loadPendingRestore(room);
2302
- const found = restored ?? await this.findSnapshot(roomId);
2303
- const snapshot = found?.bytes;
4231
+ let found = restored ?? await this.findSnapshot(roomId);
4232
+ let snapshot = found?.bytes;
2304
4233
  if (room.relay) {
2305
4234
  room.relayRoom = snapshot ? RelayRoom.restore(snapshot) : RelayRoom.create();
2306
4235
  room.lastTick = room.relayRoom.tick;
@@ -2308,6 +4237,15 @@ var SupervisorImpl = class {
2308
4237
  this.armRelayIdle(room);
2309
4238
  return room;
2310
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
+ }
2311
4249
  if (found && found.version > room.version) {
2312
4250
  this.roomLog(
2313
4251
  room,
@@ -2320,7 +4258,7 @@ var SupervisorImpl = class {
2320
4258
  let migrate;
2321
4259
  if (found && found.version !== room.version) {
2322
4260
  try {
2323
- migrate = this.migrationChain(found.version, room.version);
4261
+ migrate = this.migrationChain(found.version, room.version, room.roomType);
2324
4262
  } catch (err) {
2325
4263
  this.roomLog(room, "error", "cannot migrate this room", err);
2326
4264
  this.registry.delete(roomId);
@@ -2354,9 +4292,10 @@ var SupervisorImpl = class {
2354
4292
  }
2355
4293
  /** Spawns the worker and waits for `ready`. Leaves the room's state to the caller. */
2356
4294
  async startWorker(room, snapshot, migrate) {
2357
- 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);
2358
4297
  if (!bundle) return false;
2359
- const worker = new WorkerHost(this.workerEntry, this.limits, {
4298
+ const worker = new WorkerHost(this.workerEntry, this.limitsFor(type), {
2360
4299
  onMessage: (msg) => this.onWorkerMessage(room, msg),
2361
4300
  onDead: (reason) => this.onWorkerDead(room, reason)
2362
4301
  });
@@ -2377,8 +4316,15 @@ var SupervisorImpl = class {
2377
4316
  roomId: room.id,
2378
4317
  bundleUrl: bundle.bundleUrl,
2379
4318
  ...this.config.publicUrl !== void 0 ? { publicUrl: this.config.publicUrl } : {},
2380
- ...snapshot ? { snapshot } : {},
2381
- ...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 } : {}
2382
4328
  });
2383
4329
  });
2384
4330
  if (!outcome || outcome.t !== "ready") {
@@ -2392,6 +4338,7 @@ var SupervisorImpl = class {
2392
4338
  return false;
2393
4339
  }
2394
4340
  room.config = outcome.config;
4341
+ room.backfillOpen = true;
2395
4342
  if (outcome.migrated) {
2396
4343
  const m = outcome.migrated;
2397
4344
  this.roomLog(
@@ -2436,6 +4383,8 @@ var SupervisorImpl = class {
2436
4383
  const saveId = await this.saveRoom(room);
2437
4384
  return { ok: true, value: saveId };
2438
4385
  }
4386
+ case "busSend":
4387
+ return this.busSend(room, call.roomId, call.payload);
2439
4388
  case "kvGet": {
2440
4389
  const value = await this.requireKv().get(call.playerId, call.key);
2441
4390
  return value === void 0 ? { ok: true } : { ok: true, value };
@@ -2446,8 +4395,67 @@ var SupervisorImpl = class {
2446
4395
  case "kvDelete":
2447
4396
  await this.requireKv().delete(call.playerId, call.key);
2448
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
+ }
2449
4443
  }
2450
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
+ }
2451
4459
  requireKv() {
2452
4460
  const kv = this.config.kv;
2453
4461
  if (!kv) {
@@ -2457,6 +4465,24 @@ var SupervisorImpl = class {
2457
4465
  }
2458
4466
  return kv;
2459
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
+ }
2460
4486
  /**
2461
4487
  * D24: one save generation. Serializes exactly the way hibernation does — same
2462
4488
  * `worker.serialize()`, same bytes, same `putSnapshotWithRetry` durability — writes it under
@@ -2563,79 +4589,407 @@ var SupervisorImpl = class {
2563
4589
  * a rollback is precisely the case where it does. Save generations are untouched — only live
2564
4590
  * keys (and their alarm sidecars) go.
2565
4591
  */
2566
- async dropNewerLiveKeys(room) {
2567
- const base = `${this.config.projectId}/${room.id}`;
2568
- let keys;
4592
+ async dropNewerLiveKeys(room) {
4593
+ const base = `${this.config.projectId}/${room.id}`;
4594
+ let keys;
4595
+ try {
4596
+ keys = await this.store.list(base);
4597
+ } catch {
4598
+ return;
4599
+ }
4600
+ for (const key of keys) {
4601
+ let version;
4602
+ if (key === base) version = 0;
4603
+ else if (key.startsWith(`${base}@v`)) {
4604
+ version = Number(key.slice(base.length + 2));
4605
+ if (!Number.isInteger(version)) continue;
4606
+ } else continue;
4607
+ if (version > room.version) {
4608
+ this.roomLog(room, "info", `restore: dropping the rolled-back live key ${key}`);
4609
+ await this.store.delete(key).catch(() => {
4610
+ });
4611
+ await this.store.delete(alarmsKey(key)).catch(() => {
4612
+ });
4613
+ }
4614
+ }
4615
+ }
4616
+ /**
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}`;
2569
4817
  try {
2570
- keys = await this.store.list(base);
4818
+ const keys = await this.store.list(base);
4819
+ return keys.some((k) => k === base || k.startsWith(`${base}@v`) || k.startsWith(`${base}/`));
2571
4820
  } catch {
2572
- return;
2573
- }
2574
- for (const key of keys) {
2575
- let version;
2576
- if (key === base) version = 0;
2577
- else if (key.startsWith(`${base}@v`)) {
2578
- version = Number(key.slice(base.length + 2));
2579
- if (!Number.isInteger(version)) continue;
2580
- } else continue;
2581
- if (version > room.version) {
2582
- this.roomLog(room, "info", `restore: dropping the rolled-back live key ${key}`);
2583
- await this.store.delete(key).catch(() => {
2584
- });
2585
- await this.store.delete(alarmsKey(key)).catch(() => {
2586
- });
2587
- }
4821
+ return false;
2588
4822
  }
2589
4823
  }
2590
4824
  /**
2591
- * D26: re-arms this room's alarms from the sidecar and starts persisting changes to it.
2592
- *
2593
- * Anything already due fires immediately an alarm is "at or after", never before, and a
2594
- * tenant that was stopped for an hour owes the room an hour-late alarm rather than nothing.
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.
2595
4828
  */
2596
- async loadAlarms(room) {
2597
- if (room.relay) return;
2598
- let persisted = [];
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
+ }
2599
4839
  try {
2600
- const bytes = await this.store.get(alarmsKey(this.storeKey(room)));
2601
- if (bytes) persisted = decodeAlarms(bytes);
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";
2602
4857
  } catch (err) {
2603
- this.roomLog(room, "warn", "could not read this room's alarms; starting with none", 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";
2604
4865
  }
2605
- room.alarms.onChange = () => this.persistAlarms(room);
2606
- room.alarms.attach((names) => this.deliverAlarms(room, names));
2607
- if (persisted.length > 0) {
2608
- room.alarms.load(persisted);
2609
- const overdue = room.alarms.fireDue();
2610
- if (overdue.length > 0) {
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.
4871
+ */
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;
4877
+ }
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") {
2611
4891
  this.roomLog(
2612
4892
  room,
2613
4893
  "info",
2614
- `firing ${overdue.length} overdue alarm(s): ${overdue.join(", ")}`
4894
+ `bus.send to ${entry.to}: the target's mailbox is full; will retry`
2615
4895
  );
2616
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
+ }
2617
4938
  }
4939
+ return this.parkLocally(target, message.from, message.payload, message.to);
2618
4940
  }
2619
4941
  /**
2620
- * Delivers due alarms into the room, **waking it first if it is asleep**. This is the whole
2621
- * hibernated case: the timer kept counting because it lives on the record rather than in the
2622
- * worker, and the wake is the same one a joining client would have triggered.
4942
+ * Delivers one mailbox entry, or bounds and drops it.
2623
4943
  *
2624
- * The loop is not defensive padding it is the fix for a real race that cost an hour to find.
2625
- * `fireDue` removes an alarm before delivering it, so between the state check and the `await`
2626
- * the room can slip into hibernation (`hibernateRoom` flips the state synchronously). A single
2627
- * check-then-await therefore saw `running`, woke up to `hibernated`, and dropped an alarm that
2628
- * no longer existed anywhere — a round timer that simply never fired, with a warning nobody was
2629
- * watching. Re-checking after every transition covers the slip, and anything still undeliverable
2630
- * is **re-armed** rather than discarded: a durable alarm that this process could not deliver is
2631
- * 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.
2632
4947
  */
2633
- 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
+ }
2634
4985
  void (async () => {
2635
4986
  for (let attempt = 0; attempt < 4; attempt++) {
2636
4987
  if (room.state === "closed") return;
2637
4988
  if (room.state === "running" && room.worker) {
2638
- 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
+ }
2639
4993
  return;
2640
4994
  }
2641
4995
  if (room.state === "hibernated") {
@@ -2651,7 +5005,10 @@ var SupervisorImpl = class {
2651
5005
  `alarm(s) ${names.join(", ")} came due while the room was ${room.state}; re-armed for the next start rather than dropped`
2652
5006
  );
2653
5007
  const now = Date.now();
2654
- 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
+ }
2655
5012
  })();
2656
5013
  }
2657
5014
  /**
@@ -2726,9 +5083,11 @@ var SupervisorImpl = class {
2726
5083
  closeRoom(room, code, message) {
2727
5084
  if (room.state === "closed") return;
2728
5085
  room.state = "closed";
5086
+ this.npcs.despawnRoom(room.id);
2729
5087
  this.clearRoomTimer(room);
2730
5088
  room.alarms.detach();
2731
5089
  room.alarms.onChange = void 0;
5090
+ this.busRoomClosed(room.id);
2732
5091
  for (const session of [...room.clients.values()]) {
2733
5092
  session.fatal = true;
2734
5093
  session.sendError(code, message, true);
@@ -2753,22 +5112,45 @@ var SupervisorImpl = class {
2753
5112
  async joinBundle(room, session, reconnecting) {
2754
5113
  const outcome = await this.requestJoin(room, session, reconnecting);
2755
5114
  if (!outcome) {
5115
+ session.pendingOut.length = 0;
2756
5116
  session.fail("E_INTERNAL", "the room did not answer the join");
2757
5117
  return;
2758
5118
  }
2759
5119
  if (outcome.t === "joinRejected") {
5120
+ session.pendingOut.length = 0;
2760
5121
  room.clients.delete(session.clientId);
2761
5122
  this.clientIds.delete(session.clientId);
2762
5123
  session.fail(outcome.code, outcome.reason ?? formatError(outcome.code, { roomId: room.id }));
2763
5124
  return;
2764
5125
  }
2765
- 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
+ }
2766
5139
  session.role = outcome.role;
2767
5140
  session.state = "joined";
5141
+ room.notePlayerSeen(session.playerId ?? session.clientId);
2768
5142
  if (outcome.tick > room.lastTick) room.lastTick = outcome.tick;
2769
5143
  room.metrics.connections++;
2770
5144
  room.metrics.totalConnections++;
5145
+ if (session.npc) room.metrics.npcConnections++;
2771
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
+ }
2772
5154
  }
2773
5155
  requestJoin(room, session, reconnecting) {
2774
5156
  const worker = room.worker;
@@ -2791,43 +5173,53 @@ var SupervisorImpl = class {
2791
5173
  ...session.name !== "" ? { name: session.name } : {},
2792
5174
  // D27: the verified JWT subject; absent for key joins (playerId stays the client id).
2793
5175
  ...session.playerId !== void 0 ? { playerId: session.playerId } : {},
2794
- ...reconnecting ? { reconnecting: true } : {}
5176
+ ...reconnecting ? { reconnecting: true } : {},
5177
+ ...session.npc ? { npc: true } : {}
2795
5178
  });
2796
5179
  });
2797
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
+ */
2798
5187
  joinRelay(room, session, _reconnecting) {
2799
5188
  const relay = room.relayRoom;
2800
5189
  if (!relay) {
2801
5190
  session.fail("E_INTERNAL", formatError("E_INTERNAL"));
2802
5191
  return;
2803
5192
  }
2804
- if (!relay.has(session.clientId) && relay.size >= room.config.maxClients) {
5193
+ const outcome = relayJoin(relayHostRoomFor(this, room), session);
5194
+ if (!outcome.ok) {
2805
5195
  room.clients.delete(session.clientId);
2806
5196
  this.clientIds.delete(session.clientId);
2807
- session.fail("E_ROOM_FULL", formatError("E_ROOM_FULL", { roomId: room.id }));
5197
+ session.fail(outcome.code, formatError(outcome.code, { roomId: room.id }));
2808
5198
  return;
2809
5199
  }
2810
- const delta = relay.add(session.clientId, session.role, session.name);
2811
- room.lastTick = relay.tick;
5200
+ room.lastTick = outcome.tick;
2812
5201
  session.state = "joined";
5202
+ room.notePlayerSeen(session.playerId ?? session.clientId);
2813
5203
  room.metrics.connections++;
2814
5204
  room.metrics.totalConnections++;
2815
- this.sendWelcome(session, relay.tick, relay.snapshot());
2816
- 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);
2817
5207
  this.armRelayIdle(room);
2818
5208
  }
2819
5209
  /** `WELCOME` with a fresh resume token — also used as the resync after a wake/restart. */
2820
5210
  sendWelcome(session, tick, snapshot) {
2821
5211
  const room = session.room;
2822
5212
  if (!room) return;
5213
+ const now = Date.now();
2823
5214
  const resumeToken = signResume(this.resumeSecret, {
2824
5215
  clientId: session.clientId,
2825
5216
  roomId: room.id,
2826
5217
  role: session.role,
2827
- expMs: Date.now() + room.config.reconnectGraceMs
5218
+ expMs: now + RESUME_TOKEN_TTL_MS,
5219
+ iatMs: now
2828
5220
  });
2829
- const bytes = encodeFrame2(
2830
- FrameType2.WELCOME,
5221
+ const bytes = encodeFrame3(
5222
+ FrameType3.WELCOME,
2831
5223
  encodeWelcome({
2832
5224
  clientId: session.clientId,
2833
5225
  role: session.role,
@@ -2835,8 +5227,11 @@ var SupervisorImpl = class {
2835
5227
  snapshot,
2836
5228
  resumeToken,
2837
5229
  roomId: room.id,
2838
- // 0 for a relay room (tickRate 0): "unknown" — the client falls back to its 50 ms floor.
2839
- 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
2840
5235
  })
2841
5236
  );
2842
5237
  const sent = session.send(bytes);
@@ -2852,17 +5247,31 @@ var SupervisorImpl = class {
2852
5247
  * the new client, instead of feeding it bytes it will mis-decode. Clients already on the new
2853
5248
  * schema (the page reloaded after the client shipped) rejoin normally.
2854
5249
  */
2855
- failOutdatedSessions(room, version) {
2856
- const hash8 = this.deployments.get(version)?.bundle?.hash8;
2857
- 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;
2858
5261
  for (const session of room.connected()) {
2859
- if (session.schemaHash8 !== void 0 && !bytesEqual(session.schemaHash8, hash8)) {
2860
- session.fail(
2861
- "E_SCHEMA_MISMATCH",
2862
- "the room migrated to a newer deployment; reconnect with an updated client"
2863
- );
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;
2864
5268
  }
5269
+ session.fail(
5270
+ "E_SCHEMA_MISMATCH",
5271
+ "the room migrated to a newer deployment; reconnect with an updated client"
5272
+ );
2865
5273
  }
5274
+ return swapped;
2866
5275
  }
2867
5276
  /** Re-joins every still-connected socket after a wake or a crash restart, with a resync WELCOME. */
2868
5277
  async rejoinAll(room) {
@@ -2915,23 +5324,33 @@ var SupervisorImpl = class {
2915
5324
  this.badFrame(session, "unknown frame type");
2916
5325
  return;
2917
5326
  }
2918
- if (type === FrameType2.PING) {
5327
+ if (type === FrameType3.PING) {
2919
5328
  let t = 0;
2920
5329
  try {
2921
5330
  t = decodePing(payload).t;
2922
5331
  } catch {
2923
5332
  }
2924
- const sent = session.sendFrame(FrameType2.PONG, encodePong({ t, serverTick: room.lastTick }));
5333
+ const sent = session.sendFrame(FrameType3.PONG, encodePong({ t, serverTick: room.lastTick }));
2925
5334
  if (sent > 0) {
2926
5335
  room.metrics.egressBytes += sent;
2927
5336
  room.metrics.framesOut++;
2928
5337
  }
2929
5338
  return;
2930
5339
  }
2931
- if (type === FrameType2.LEAVE) {
5340
+ if (type === FrameType3.LEAVE) {
2932
5341
  this.handleLeaveFrame(session);
2933
5342
  return;
2934
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
+ }
2935
5354
  if (room.relay) {
2936
5355
  this.relayFrame(room, session, type, payload);
2937
5356
  return;
@@ -2970,43 +5389,81 @@ var SupervisorImpl = class {
2970
5389
  } catch {
2971
5390
  }
2972
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
+ */
2973
5397
  relayFrame(room, session, type, payload) {
2974
- const relay = room.relayRoom;
2975
- if (!relay) return;
2976
- if (type !== FrameType2.MSG) {
2977
- 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);
2978
5402
  return;
2979
5403
  }
2980
- let target;
2981
- let body;
2982
- try {
2983
- const msg = decodeMsg(payload);
2984
- target = msg.target;
2985
- body = msg.payload;
2986
- } catch {
2987
- 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
+ );
2988
5425
  return;
2989
5426
  }
2990
- if (target.kind === "server") {
2991
- this.badFrame(session, "relay rooms have no server to address");
2992
- 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
+ }
2993
5436
  }
2994
- const out = encodeFrame2(
2995
- FrameType2.MSG,
2996
- 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 })
2997
5461
  );
2998
- for (const peer of room.clients.values()) {
2999
- if (peer === session || peer.state !== "joined") continue;
3000
- if (target.kind === "client" && peer.clientId !== target.clientId) continue;
3001
- if (target.kind === "role" && peer.role !== target.role) continue;
3002
- const sent = peer.send(out);
3003
- if (sent > 0) {
3004
- room.metrics.egressBytes += sent;
3005
- room.metrics.framesOut++;
3006
- }
5462
+ const room = session.room;
5463
+ if (sent > 0 && room) {
5464
+ room.metrics.egressBytes += sent;
5465
+ room.metrics.framesOut++;
3007
5466
  }
3008
- room.lastTick = relay.advance();
3009
- this.armRelayIdle(room);
3010
5467
  }
3011
5468
  badFrame(session, message) {
3012
5469
  session.badFrameStrikes++;
@@ -3040,7 +5497,30 @@ var SupervisorImpl = class {
3040
5497
  case "send": {
3041
5498
  noteTick(room, msg.bytes);
3042
5499
  const session = room.clients.get(msg.clientId);
3043
- 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;
3044
5524
  const sent = session.send(msg.bytes);
3045
5525
  if (sent > 0) {
3046
5526
  room.metrics.egressBytes += sent;
@@ -3062,14 +5542,58 @@ var SupervisorImpl = class {
3062
5542
  );
3063
5543
  return;
3064
5544
  case "sleep":
5545
+ this.npcs.despawnRoom(room.id);
3065
5546
  void hibernateRoom(this, room);
3066
5547
  return;
3067
5548
  case "hostCall":
3068
5549
  void this.runHostCall(room, msg.reqId, msg.call);
3069
5550
  return;
5551
+ case "setBackfill":
5552
+ room.backfillOpen = msg.open;
5553
+ return;
3070
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
+ }
3071
5563
  room.alarms.set(msg.name, msg.atMs);
3072
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;
3073
5597
  case "log":
3074
5598
  room.log(msg.level, ...msg.args);
3075
5599
  return;
@@ -3083,6 +5607,11 @@ var SupervisorImpl = class {
3083
5607
  /** Worker died: restart from the latest snapshot and resync everyone (plan §3.3). */
3084
5608
  onWorkerDead(room, reason) {
3085
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
+ }
3086
5615
  this.roomLog(room, "error", `worker died (${reason}); restarting`);
3087
5616
  room.worker = void 0;
3088
5617
  room.metrics.restarts++;
@@ -3200,16 +5729,53 @@ var SupervisorImpl = class {
3200
5729
  const live = this.registry.values().filter((r) => r.worker?.alive === true);
3201
5730
  const answered = await Promise.all(
3202
5731
  live.map(async (room) => {
3203
- const stats = await room.worker?.stats(timeoutMs);
3204
- if (!stats) return void 0;
5732
+ const msg = await room.worker?.statsMessage(timeoutMs);
5733
+ if (!msg) return void 0;
5734
+ const stats = msg.stats;
3205
5735
  room.metrics.ticks = stats.ticks;
3206
5736
  room.metrics.maxTickMs = stats.maxTickMs;
3207
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();
3208
5743
  return room.id;
3209
5744
  })
3210
5745
  );
3211
5746
  return answered.filter((id) => id !== void 0);
3212
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
+ }
3213
5779
  async inspect(roomId) {
3214
5780
  const room = this.registry.get(roomId);
3215
5781
  if (!room) return void 0;
@@ -3233,6 +5799,34 @@ var SupervisorImpl = class {
3233
5799
  rss: inspected.rss
3234
5800
  };
3235
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
+ }
3236
5830
  /**
3237
5831
  * Ops/test hook: hard-kill a room's worker. The room takes the normal §3.3 crash path (restore
3238
5832
  * from the latest snapshot, resync everyone, restart cap). `false` when the room has no worker.
@@ -3248,14 +5842,19 @@ function header(v) {
3248
5842
  if (v === void 0) return void 0;
3249
5843
  return Array.isArray(v) ? v[0] : v;
3250
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
+ }
3251
5850
  function noteTick(room, bytes) {
3252
5851
  if (bytes.length < 5) return;
3253
5852
  const type = bytes[0];
3254
- if (type !== FrameType2.DELTA && type !== FrameType2.CORRECT) return;
5853
+ if (type !== FrameType3.DELTA && type !== FrameType3.CORRECT) return;
3255
5854
  const tick = new DataView(bytes.buffer, bytes.byteOffset + 1, 4).getUint32(0, true);
3256
5855
  if (tick > room.lastTick) room.lastTick = tick;
3257
5856
  }
3258
- async function loadBundle(bundlePath, version) {
5857
+ async function loadBundle(bundlePath, version, type) {
3259
5858
  const bundleUrl = bundlePath.startsWith("file:") ? bundlePath : pathToFileURL2(bundlePath).href;
3260
5859
  let mod;
3261
5860
  try {
@@ -3272,6 +5871,7 @@ async function loadBundle(bundlePath, version) {
3272
5871
  const config = definition.config;
3273
5872
  return {
3274
5873
  version,
5874
+ type,
3275
5875
  definition,
3276
5876
  schemaHash: definition.schema.hash,
3277
5877
  hash8: definition.schema.hash8,
@@ -3281,7 +5881,17 @@ async function loadBundle(bundlePath, version) {
3281
5881
  tickRate: config.tickRate,
3282
5882
  idleMs: config.idleMs,
3283
5883
  reconnectGraceMs: config.reconnectGraceMs,
3284
- 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 } : {}
3285
5895
  },
3286
5896
  bundleUrl,
3287
5897
  schemaJson: definition.schema.canonical
@@ -3495,20 +6105,18 @@ async function warnIfClientImportsRoom(cwd, entry, config, log) {
3495
6105
  );
3496
6106
  }
3497
6107
  }
3498
- function rapierAbsolutePlugin(fromPackageDir) {
6108
+ function engineAbsolutePlugin(fromPackageDir, pkg) {
3499
6109
  let resolved;
3500
6110
  try {
3501
- resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(
3502
- "@dimforge/rapier3d-compat"
3503
- );
6111
+ resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(pkg);
3504
6112
  } catch {
3505
6113
  return void 0;
3506
6114
  }
3507
6115
  const specifier = pathToFileURL3(resolved).href;
3508
6116
  return {
3509
- name: "irtio-dev-rapier-absolute",
6117
+ name: `irtio-dev-${pkg.replace(/[^a-z0-9]+/g, "-")}-absolute`,
3510
6118
  setup(build2) {
3511
- build2.onResolve({ filter: /^@dimforge\/rapier3d-compat$/ }, () => ({
6119
+ build2.onResolve({ filter: new RegExp(`^${pkg.replace(/[/\-]/g, "$&")}$`) }, () => ({
3512
6120
  path: specifier,
3513
6121
  external: true
3514
6122
  }));
@@ -3533,7 +6141,8 @@ async function resolveWorkerEntry2(outDir) {
3533
6141
  );
3534
6142
  }
3535
6143
  const outfile = path.join(outDir, "worker.mjs");
3536
- 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);
3537
6146
  await esbuild.build({
3538
6147
  entryPoints: [source],
3539
6148
  bundle: true,
@@ -3545,18 +6154,18 @@ async function resolveWorkerEntry2(outDir) {
3545
6154
  "@irtio/server": path.join(packagesDir, "server/src/index.ts"),
3546
6155
  "@irtio/protocol": path.join(packagesDir, "protocol/src/index.ts")
3547
6156
  },
3548
- ...rapierPlugin !== void 0 ? { plugins: [rapierPlugin] } : {},
6157
+ ...enginePlugins.length > 0 ? { plugins: enginePlugins } : {},
3549
6158
  // Rapier stays external for the same reason it does in packages/supervisor/test/support.ts:
3550
6159
  // it is a dependency of `@irtio/runtime`, resolved once at load time. Bundling a second copy
3551
6160
  // here would give the dev worker its own WASM instance, separate from the one the room
3552
6161
  // bundle resolves via node_modules. `rapierPlugin` (above) already externalizes it at an
3553
6162
  // absolute path when `@irtio/runtime`'s own copy can be found; this string entry is the
3554
6163
  // fallback for a published install where that resolution comes for free from node_modules.
3555
- external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat"]
6164
+ external: ["node:worker_threads", "node:url", "@dimforge/rapier3d-compat", "matter-js"]
3556
6165
  });
3557
6166
  return outfile;
3558
6167
  }
3559
- async function buildState(supervisor, projectId, bundle) {
6168
+ async function buildState(supervisor, projectId, bundle, startedAt) {
3560
6169
  await supervisor.refreshTickHealth().catch(() => []);
3561
6170
  const infos = supervisor.rooms();
3562
6171
  const inspects = await Promise.all(
@@ -3569,6 +6178,11 @@ async function buildState(supervisor, projectId, bundle) {
3569
6178
  logs.sort((a, b) => a.at - b.at);
3570
6179
  return {
3571
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,
3572
6186
  bundle: {
3573
6187
  file: bundle.file,
3574
6188
  hash: bundle.hash,
@@ -3598,6 +6212,7 @@ function send(res, status, type, body) {
3598
6212
  }
3599
6213
  async function startDev(options = {}) {
3600
6214
  const cwd = path.resolve(options.cwd ?? process.cwd());
6215
+ const startedAt = Date.now();
3601
6216
  const log = options.log ?? ((line) => console.log(line));
3602
6217
  const entry = resolveEntry(cwd, options.room);
3603
6218
  const config = await readProjectConfig(cwd, "irtio dev", options.config);
@@ -3627,6 +6242,69 @@ async function startDev(options = {}) {
3627
6242
  send(res, 200, "text/html; charset=utf-8", DEV_PAGE);
3628
6243
  return true;
3629
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
+ }
3630
6308
  if (route === "/__irt/state.json") {
3631
6309
  void (async () => {
3632
6310
  try {
@@ -3634,7 +6312,7 @@ async function startDev(options = {}) {
3634
6312
  res,
3635
6313
  200,
3636
6314
  "application/json; charset=utf-8",
3637
- JSON.stringify(await buildState(supervisor, projectId, bundle))
6315
+ JSON.stringify(await buildState(supervisor, projectId, bundle, startedAt))
3638
6316
  );
3639
6317
  } catch (err) {
3640
6318
  send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
@@ -3655,6 +6333,7 @@ async function startDev(options = {}) {
3655
6333
  store: new DiskStore(path.join(cwd, ".irtio", "snapshots")),
3656
6334
  publicUrl: `http://localhost:${port}`,
3657
6335
  httpHandler,
6336
+ ...options.profile === true ? { profile: true } : {},
3658
6337
  log: (level, ...args) => {
3659
6338
  const line = args.map((a) => typeof a === "string" ? a : safeJson(a)).join(" ");
3660
6339
  log(level === "error" ? pc.red(line) : level === "warn" ? pc.yellow(line) : pc.dim(line));
@@ -3746,11 +6425,13 @@ options:
3746
6425
  --port <n> port to listen on (default 7070; 0 picks a free one)
3747
6426
  --no-watch do not rebuild on change
3748
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)
3749
6430
  -h, --help print this
3750
6431
  `;
3751
6432
  function parseDevArgs(args) {
3752
6433
  if (helpRequested(args)) throw helpFor(USAGE);
3753
- const parsed = { watch: true };
6434
+ const parsed = { watch: true, profile: false };
3754
6435
  for (let i = 0; i < args.length; i++) {
3755
6436
  const arg = args[i];
3756
6437
  const eq = arg.indexOf("=");
@@ -3776,6 +6457,18 @@ function parseDevArgs(args) {
3776
6457
  case "--no-watch":
3777
6458
  parsed.watch = false;
3778
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
+ }
3779
6472
  case "-c":
3780
6473
  case "--config":
3781
6474
  parsed.config = value();
@@ -3795,7 +6488,8 @@ async function dev(args) {
3795
6488
  watch: parsed.watch,
3796
6489
  ...parsed.room !== void 0 ? { room: parsed.room } : {},
3797
6490
  ...parsed.port !== void 0 ? { port: parsed.port } : {},
3798
- ...parsed.config !== void 0 ? { config: parsed.config } : {}
6491
+ ...parsed.config !== void 0 ? { config: parsed.config } : {},
6492
+ ...parsed.profile ? { profile: true } : {}
3799
6493
  });
3800
6494
  } catch (err) {
3801
6495
  if (err instanceof HelpRequested) {
@@ -3813,10 +6507,42 @@ async function dev(args) {
3813
6507
  `share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
3814
6508
  )
3815
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
+ }
3816
6541
  let stopping = false;
3817
6542
  const shutdown = () => {
3818
6543
  if (stopping) return;
3819
6544
  stopping = true;
6545
+ if (profileTimer) clearInterval(profileTimer);
3820
6546
  void (async () => {
3821
6547
  await server.stop();
3822
6548
  process.exit(0);