@irtio/protocol 0.5.2 → 0.7.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 (3) hide show
  1. package/dist/index.d.ts +1191 -12
  2. package/dist/index.js +983 -49
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/frame.ts
2
- var PROTOCOL_VERSION = 1;
2
+ var PROTOCOL_VERSION = 3;
3
3
  var FrameType = {
4
4
  HELLO: 1,
5
5
  WELCOME: 2,
@@ -18,7 +18,19 @@ var FrameType = {
18
18
  * reconnect grace window and the room's `onLeave` sees `reason: 'left'` instead of waiting for
19
19
  * the close to time out into `'timeout'`. Additive — protocol version stays 1.
20
20
  */
21
- LEAVE: 12
21
+ LEAVE: 12,
22
+ /**
23
+ * Server → client, payload the new schema's canonical JSON as UTF-8 (D50, gap-and-resync).
24
+ * Sent during a `migrate`-strategy deploy whose schema change is purely additive, after the old
25
+ * worker stops sending and before the resync WELCOME, to a session that asked for it. The
26
+ * client rebuilds its codec from the descriptor and keeps its socket; before this, any schema
27
+ * change closed it with `E_SCHEMA_MISMATCH`.
28
+ *
29
+ * Gated, not versioned. `decodeFrame` throws on an unknown type, so a client that predates this
30
+ * frame must never receive one: the server sends it only to a session whose HELLO set
31
+ * `HELLO_SCHEMA_SWAP_BIT`. Additive — protocol version stays 1, the same way LEAVE=12 did.
32
+ */
33
+ SCHEMA: 13
22
34
  };
23
35
  var FRAME_TYPE_VALUES = new Set(Object.values(FrameType));
24
36
  function isFrameType(v) {
@@ -45,7 +57,7 @@ var ErrorCode = {
45
57
  E_SCHEMA_MISMATCH: { code: 4, message: "schema hash mismatch" },
46
58
  E_ROOM_NOT_FOUND: {
47
59
  code: 5,
48
- message: "room {roomId} not found \u2014 room ids are 1-32 of A-Z a-z 0-9 - _"
60
+ message: "room {roomId} not found. Room ids are 1-32 of A-Z a-z 0-9 - _, optionally behind a lowercase <type>: prefix"
49
61
  },
50
62
  E_ROOM_FULL: { code: 6, message: "room {roomId} is full" },
51
63
  E_ROOM_CLOSED: { code: 7, message: "room {roomId} is closed" },
@@ -74,8 +86,94 @@ var ErrorCode = {
74
86
  E_TOKEN_WRONG_ROOM: { code: 24, message: "auth token is for another room" },
75
87
  E_TOKEN_MALFORMED: { code: 25, message: "auth token is malformed: {reason}" },
76
88
  E_TOKEN_BAD_ISSUER: { code: 26, message: "auth token issuer {issuer} is not accepted" },
77
- E_TOKEN_BAD_ALG: { code: 27, message: "auth token algorithm {alg} is not allowed" }
89
+ E_TOKEN_BAD_ALG: { code: 27, message: "auth token algorithm {alg} is not allowed" },
90
+ /**
91
+ * M4 (D48): the project is at a usage cap and this join is new work.
92
+ *
93
+ * The template carries `{detail}` rather than a fixed sentence because the actionable half
94
+ * differs by tier and by meter, and a refusal that does not say what to do next is the thing
95
+ * `errors.md`'s fix-line convention exists to prevent. Control composes the detail; the box
96
+ * relays it. Non-protocol surfaces (the store gateway's write refusal, control's HTTP refusals
97
+ * for deploys and uploads) reuse the same `E_USAGE_CAP` string code in their own shapes, so one
98
+ * grep finds every place a cap can be met.
99
+ */
100
+ E_USAGE_CAP: { code: 28, message: "{detail}" },
101
+ /**
102
+ * M4 part 5 (D53): the tenant cannot verify platform identity assertions because it holds no
103
+ * platform public key yet.
104
+ *
105
+ * Deliberately not `E_AUTH` and not `E_TOKEN_INVALID`. Those say "your credential is bad", and
106
+ * this is the opposite — the credential may be perfect and the box is not yet in a position to
107
+ * say so. Keys are delivered to a tenant the way cap state is (a push from control right after
108
+ * the VM comes up), so the honest reading of this code is "retry in a moment", which is what
109
+ * the fix line in the errors reference says.
110
+ */
111
+ E_ASSERTION_UNVERIFIABLE: {
112
+ code: 29,
113
+ message: "this server cannot verify identity assertions yet"
114
+ },
115
+ /**
116
+ * M5 part 3.5: a room type is already running as many rooms as it declared it would.
117
+ *
118
+ * Deliberately its own code rather than `E_ROOM_FULL`, which is about clients in a room, and
119
+ * emphatically not `E_INTERNAL`. This refusal is what makes declared sizing honest: the tenant's
120
+ * VM memory was computed as the declared per-room heap times this number, so room N+1 is a room
121
+ * the machine was never built to hold. The message names the type, the limit and the field to
122
+ * change, because all three are things the developer controls.
123
+ */
124
+ E_TYPE_AT_CAPACITY: { code: 30, message: "{detail}" },
125
+ /**
126
+ * M5 part 3.5 (Part C): the project has used its egress allowance and forwarding has stopped.
127
+ *
128
+ * Its own code, and its own WebSocket close code ({@link CLOSE_EGRESS_WALL}), for one reason: a
129
+ * developer watching sockets close has to be able to tell a wall from a bug. `E_USAGE_CAP` is
130
+ * the polite refusal of *new* work at a cap control evaluated up to a minute ago; this is the
131
+ * box stopping traffic on its own, locally, the moment its leased budget ran out.
132
+ */
133
+ E_EGRESS_WALL: { code: 31, message: "{detail}" },
134
+ /**
135
+ * M5 part 3.5 (Part C): a free-tier shape limit — the relay client cap, or the concurrent
136
+ * awake-rooms cap. The message names the limit and the tier, because the fix is a card.
137
+ */
138
+ E_TIER_LIMIT: { code: 32, message: "{detail}" },
139
+ /**
140
+ * M5 part 7 (D67-i): the project's region has no server with room for another tenant VM.
141
+ *
142
+ * Sent by the router, fatal, with close code 1013 (RFC 6455 "try again later"). Before this
143
+ * code a full region looked like a slow start: the control plane re-ran placement on every
144
+ * lookup poll for thirty seconds and the client then saw `E_STARTING` followed by `E_INTERNAL`.
145
+ * The message names the region and the two fixes because both are an operator's, never the
146
+ * player's: the client does not retry this join on its own.
147
+ */
148
+ E_PLACEMENT: {
149
+ code: 33,
150
+ message: "no capacity in region {region}; add a server or raise maxVms"
151
+ },
152
+ // ---- M6 lane A: rooms API ----
153
+ /**
154
+ * M6 lane A (D68-d): an operator deleted this room, and its stored state is gone.
155
+ *
156
+ * Its own code rather than `E_KICKED` or `E_ROOM_CLOSED`, and the difference between the three is
157
+ * the reason it exists. `E_KICKED` is the room's own code deciding one player should leave, and
158
+ * a client's right answer is usually to show a message and stay in the game. `E_ROOM_CLOSED` is
159
+ * a room ending normally; rejoining makes a fresh one and nothing was lost. This is neither: the
160
+ * room's state has been deleted from outside the game, deliberately, by somebody with a rooms
161
+ * API credential. A client that reconnects gets an empty room rather than the one it was in.
162
+ * Different thing to tell a player, different thing for a client to do, different code.
163
+ *
164
+ * Fatal, which is load-bearing: `@irtio/client`'s fatal path leaves for good rather than
165
+ * reconnecting, and that is what stops a deleted room from being immediately recreated by the
166
+ * very clients that were in it. Close code {@link CLOSE_ROOM_DELETED}.
167
+ */
168
+ E_ROOM_DELETED: {
169
+ code: 34,
170
+ message: "room {roomId} was deleted by an operator; its stored state is gone"
171
+ }
172
+ // ---- end M6 lane A ----
78
173
  };
174
+ var CLOSE_TRY_AGAIN_LATER = 1013;
175
+ var CLOSE_EGRESS_WALL = 4290;
176
+ var CLOSE_ROOM_DELETED = 4291;
79
177
  var ERROR_CATALOGUE = Object.keys(ErrorCode).map((name) => ({ name, code: ErrorCode[name].code, message: ErrorCode[name].message }));
80
178
  var BY_CODE = new Map(
81
179
  ERROR_CATALOGUE.map((e) => [e.code, e])
@@ -101,6 +199,7 @@ function assertEof(r, what) {
101
199
  var HELLO_RESUME_BIT = 1 << 0;
102
200
  var HELLO_ROLE_BIT = 1 << 1;
103
201
  var HELLO_NAME_BIT = 1 << 2;
202
+ var HELLO_SCHEMA_SWAP_BIT = 1 << 3;
104
203
  function encodeHello(h) {
105
204
  if (h.schemaHash8.length !== 8) {
106
205
  throw new Error(`encodeHello: schemaHash8 must be 8 bytes, got ${h.schemaHash8.length}`);
@@ -114,7 +213,7 @@ function encodeHello(h) {
114
213
  w.u8(1);
115
214
  w.str(h.credential.token);
116
215
  } else {
117
- w.u8(2);
216
+ w.u8(h.credential.kind === "assertion" ? 3 : 2);
118
217
  w.str(h.credential.key);
119
218
  w.str(h.credential.token);
120
219
  }
@@ -124,6 +223,7 @@ function encodeHello(h) {
124
223
  if (h.resumeToken !== void 0) mask |= HELLO_RESUME_BIT;
125
224
  if (h.role !== void 0) mask |= HELLO_ROLE_BIT;
126
225
  if (h.name !== void 0) mask |= HELLO_NAME_BIT;
226
+ if (h.schemaSwap === true) mask |= HELLO_SCHEMA_SWAP_BIT;
127
227
  w.u8(mask);
128
228
  if (h.resumeToken !== void 0) w.str(h.resumeToken);
129
229
  if (h.role !== void 0) w.str(h.role);
@@ -138,6 +238,7 @@ function decodeHello(bytes) {
138
238
  if (credKind === 0) credential = { kind: "key", key: r.str() };
139
239
  else if (credKind === 1) credential = { kind: "token", token: r.str() };
140
240
  else if (credKind === 2) credential = { kind: "jwt", key: r.str(), token: r.str() };
241
+ else if (credKind === 3) credential = { kind: "assertion", key: r.str(), token: r.str() };
141
242
  else throw new Error(`decodeHello: unknown credential kind ${credKind}`);
142
243
  const roomId = r.str();
143
244
  const schemaHash8 = r.bytes(8);
@@ -145,13 +246,15 @@ function decodeHello(bytes) {
145
246
  const resumeToken = (mask & HELLO_RESUME_BIT) !== 0 ? r.str() : void 0;
146
247
  const role = (mask & HELLO_ROLE_BIT) !== 0 ? r.str() : void 0;
147
248
  const name = (mask & HELLO_NAME_BIT) !== 0 ? r.str() : void 0;
249
+ const schemaSwap = (mask & HELLO_SCHEMA_SWAP_BIT) !== 0;
148
250
  assertEof(r, "decodeHello");
149
251
  const hello = { protocolVersion, credential, roomId, schemaHash8 };
150
252
  return {
151
253
  ...hello,
152
254
  ...resumeToken !== void 0 ? { resumeToken } : {},
153
255
  ...role !== void 0 ? { role } : {},
154
- ...name !== void 0 ? { name } : {}
256
+ ...name !== void 0 ? { name } : {},
257
+ ...schemaSwap ? { schemaSwap: true } : {}
155
258
  };
156
259
  }
157
260
  function encodeWelcome(w0) {
@@ -162,7 +265,8 @@ function encodeWelcome(w0) {
162
265
  w.blob(w0.snapshot);
163
266
  w.str(w0.resumeToken);
164
267
  w.str(w0.roomId);
165
- w.u16(w0.tickIntervalMs);
268
+ w.u16(w0.tickRate);
269
+ w.u16(w0.maxClients);
166
270
  return w.finish();
167
271
  }
168
272
  function decodeWelcome(bytes) {
@@ -173,9 +277,10 @@ function decodeWelcome(bytes) {
173
277
  const snapshot = r.blob();
174
278
  const resumeToken = r.str();
175
279
  const roomId = r.str();
176
- const tickIntervalMs = r.eof ? 0 : r.u16();
280
+ const tickRate = r.eof ? 0 : r.u16();
281
+ const maxClients = r.eof ? 0 : r.u16();
177
282
  assertEof(r, "decodeWelcome");
178
- return { clientId, role, tick, snapshot, resumeToken, roomId, tickIntervalMs };
283
+ return { clientId, role, tick, snapshot, resumeToken, roomId, tickRate, maxClients };
179
284
  }
180
285
  function encodeErrorPayload(e) {
181
286
  const w = new ByteWriter();
@@ -245,6 +350,15 @@ function encodeWriteFrame(codecBytes) {
245
350
  function encodeCorrectFrame(codecBytes, clientTick, appliedTick) {
246
351
  return encodeFrame(FrameType.CORRECT, correctPayload(codecBytes, clientTick, appliedTick));
247
352
  }
353
+ function schemaPayload(canonical) {
354
+ return new TextEncoder().encode(canonical);
355
+ }
356
+ function readSchemaPayload(payload) {
357
+ return new TextDecoder("utf-8", { fatal: true }).decode(payload);
358
+ }
359
+ function encodeSchemaFrame(canonical) {
360
+ return encodeFrame(FrameType.SCHEMA, schemaPayload(canonical));
361
+ }
248
362
 
249
363
  // src/rpc.ts
250
364
  import { ByteReader as ByteReader2, ByteWriter as ByteWriter2, bool, server, str } from "@irtio/schema";
@@ -255,6 +369,7 @@ function encodeCall(c) {
255
369
  const w = new ByteWriter2();
256
370
  w.u32(c.reqId);
257
371
  w.u16(c.rpcId);
372
+ w.u32(c.clientTick ?? 0);
258
373
  w.bytes(c.params);
259
374
  return w.finish();
260
375
  }
@@ -262,8 +377,9 @@ function decodeCall(bytes) {
262
377
  const r = new ByteReader2(bytes);
263
378
  const reqId = r.u32();
264
379
  const rpcId = r.u16();
380
+ const clientTick = r.u32();
265
381
  const params = r.rest();
266
- return { reqId, rpcId, params };
382
+ return { reqId, rpcId, clientTick, params };
267
383
  }
268
384
  function encodeReply(r) {
269
385
  const w = new ByteWriter2();
@@ -323,36 +439,505 @@ function rpcByIdOf(schema, id) {
323
439
  return found;
324
440
  }
325
441
 
442
+ // src/profile.ts
443
+ import { ByteReader as ByteReader3, walkDelta, walkSnapshot } from "@irtio/schema";
444
+
445
+ // src/presence.ts
446
+ import { bool as bool2, defineSchema, entity, str as str2 } from "@irtio/schema";
447
+ var presenceEntity = entity(
448
+ { clientId: str2(32), role: str2(32), name: str2(32), connected: bool2 },
449
+ { serverOwned: true }
450
+ );
451
+ var PRESENCE_COLLECTION = "clients";
452
+ var extended = /* @__PURE__ */ new WeakMap();
453
+ function withBuiltins(schema) {
454
+ let ext = extended.get(schema);
455
+ if (!ext) {
456
+ ext = defineSchema(
457
+ { ...schema.defs, [PRESENCE_COLLECTION]: presenceEntity },
458
+ {
459
+ rpc: schema.rpc,
460
+ roles: schema.roles,
461
+ // D70: message shapes travel with the extension. The builder's hash is what HELLO carries
462
+ // and what a typed message is validated against, but the runtime reaches its schema
463
+ // through `ext` in several places, and an extension that quietly dropped `messages` would
464
+ // make a declared message decodable on one side of the room and not the other.
465
+ ...schema.messageDefs !== void 0 && Object.keys(schema.messageDefs).length > 0 ? { messages: schema.messageDefs } : {},
466
+ ...schema.project !== void 0 ? { project: schema.project } : {},
467
+ allowReservedNames: true
468
+ }
469
+ );
470
+ extended.set(schema, ext);
471
+ }
472
+ return ext;
473
+ }
474
+ var relaySchema = withBuiltins(defineSchema({}));
475
+ var RELAY_HASH8 = new Uint8Array(8);
476
+ function isRelayHash8(hash8) {
477
+ if (hash8.length !== 8) return false;
478
+ for (const b of hash8) if (b !== 0) return false;
479
+ return true;
480
+ }
481
+
482
+ // src/profile.ts
483
+ var PROFILE_KINDS = [
484
+ "field",
485
+ "presence",
486
+ "correction",
487
+ "write",
488
+ "churn",
489
+ "rpc",
490
+ "message",
491
+ "voice",
492
+ "join",
493
+ "overhead",
494
+ "control"
495
+ ];
496
+ var EMPTY_PROFILE = {
497
+ rows: [],
498
+ bytesIn: 0,
499
+ bytesOut: 0,
500
+ framesIn: 0,
501
+ framesOut: 0,
502
+ walks: 0
503
+ };
504
+ var CONTROL_KEYS = {
505
+ [FrameType.HELLO]: "hello",
506
+ [FrameType.WELCOME]: "welcome",
507
+ [FrameType.ERROR]: "error",
508
+ [FrameType.PING]: "ping",
509
+ [FrameType.PONG]: "pong",
510
+ [FrameType.LEAVE]: "leave",
511
+ [FrameType.SCHEMA]: "schema"
512
+ };
513
+ var MSG_KEYS = ["all", "client", "role", "server"];
514
+ var PENDING_CALLS_MAX = 512;
515
+ var ProfileLedger = class {
516
+ rows = /* @__PURE__ */ new Map();
517
+ memo = /* @__PURE__ */ new Map();
518
+ /** Peer and reqId to the rpc name its CALL carried, so the matching REPLY can be named too. */
519
+ pending = /* @__PURE__ */ new Map();
520
+ scratch = [];
521
+ bytesIn = 0;
522
+ bytesOut = 0;
523
+ framesIn = 0;
524
+ framesOut = 0;
525
+ walkCount = 0;
526
+ rpcSchema;
527
+ /**
528
+ * `schema` is the runtime-extended schema (`withBuiltins`), which is what delta and snapshot
529
+ * bodies are encoded against. `rpcSchema` is the one whose `rpcTable` the `rpcId` on the wire
530
+ * indexes; the two tables agree today (extending adds a collection, not an rpc), but the
531
+ * senders name them separately and so does this.
532
+ */
533
+ schema;
534
+ constructor(schema, rpcSchema) {
535
+ this.schema = schema;
536
+ this.rpcSchema = rpcSchema ?? schema;
537
+ }
538
+ /**
539
+ * D50: a session whose schema was swapped mid-flight keeps its ledger and its accumulated rows.
540
+ * Rows named after a field the new schema dropped simply stop growing, which is the honest
541
+ * reading — those bytes really were spent.
542
+ */
543
+ swapSchema(schema, rpcSchema) {
544
+ this.schema = schema;
545
+ this.rpcSchema = rpcSchema ?? schema;
546
+ this.memo.clear();
547
+ }
548
+ get walks() {
549
+ return this.walkCount;
550
+ }
551
+ /** Drops the shared-payload memo. Call at the top of each flush. */
552
+ newFlush() {
553
+ if (this.memo.size > 0) this.memo.clear();
554
+ }
555
+ reset() {
556
+ this.rows.clear();
557
+ this.memo.clear();
558
+ this.pending.clear();
559
+ this.bytesIn = 0;
560
+ this.bytesOut = 0;
561
+ this.framesIn = 0;
562
+ this.framesOut = 0;
563
+ this.walkCount = 0;
564
+ }
565
+ snapshot() {
566
+ const rows = [];
567
+ for (const kind of PROFILE_KINDS) {
568
+ const byKey = this.rows.get(kind);
569
+ if (!byKey) continue;
570
+ for (const [key, v] of byKey) rows.push({ kind, key, out: v.out, in: v.in });
571
+ }
572
+ rows.sort((a, b) => b.out + b.in - (a.out + a.in) || (a.key < b.key ? -1 : 1));
573
+ return {
574
+ rows,
575
+ bytesIn: this.bytesIn,
576
+ bytesOut: this.bytesOut,
577
+ framesIn: this.framesIn,
578
+ framesOut: this.framesOut,
579
+ walks: this.walkCount
580
+ };
581
+ }
582
+ /**
583
+ * Attributes one whole frame, envelope byte included. Never throws: a frame this ledger cannot
584
+ * read still contributes its full length, to `overhead/unattributed`.
585
+ */
586
+ attribute(dir, frame, options) {
587
+ if (dir === "in") {
588
+ this.framesIn++;
589
+ this.bytesIn += frame.length;
590
+ } else {
591
+ this.framesOut++;
592
+ this.bytesOut += frame.length;
593
+ }
594
+ const shared = options?.shared;
595
+ if (shared !== void 0) {
596
+ const cached = this.memo.get(shared);
597
+ if (cached) {
598
+ this.commit(dir, cached);
599
+ return;
600
+ }
601
+ }
602
+ const entries = shared !== void 0 ? [] : this.scratch;
603
+ entries.length = 0;
604
+ this.walkCount++;
605
+ this.describe(frame, entries, options);
606
+ const total = sum(entries);
607
+ if (total !== frame.length) {
608
+ entries.push({
609
+ kind: "overhead",
610
+ key: "unattributed",
611
+ bytes: frame.length - total
612
+ });
613
+ }
614
+ if (shared !== void 0) this.memo.set(shared, entries);
615
+ this.commit(dir, entries);
616
+ }
617
+ /**
618
+ * Attributes bare snapshot bytes under `join`. The room worker needs this: it hands the host
619
+ * snapshot bytes and the host wraps them in a `WELCOME`, so the room never sees that frame,
620
+ * but the bytes are still the room's egress and the join rows are the reason anyone profiles a
621
+ * join at all. A client, which does see the whole frame, uses `attribute` instead.
622
+ */
623
+ attributeSnapshot(dir, bytes) {
624
+ if (dir === "in") {
625
+ this.bytesIn += bytes.length;
626
+ } else {
627
+ this.bytesOut += bytes.length;
628
+ }
629
+ const entries = [];
630
+ this.walkCount++;
631
+ try {
632
+ this.snapshotBody(bytes, entries);
633
+ } catch {
634
+ entries.length = 0;
635
+ }
636
+ const total = sum(entries);
637
+ if (total !== bytes.length) {
638
+ entries.push({ kind: "overhead", key: "unattributed", bytes: bytes.length - total });
639
+ }
640
+ this.commit(dir, entries);
641
+ }
642
+ // -------------------------------------------------------------------------
643
+ commit(dir, entries) {
644
+ for (const e of entries) {
645
+ if (e.bytes === 0) continue;
646
+ let byKey = this.rows.get(e.kind);
647
+ if (!byKey) {
648
+ byKey = /* @__PURE__ */ new Map();
649
+ this.rows.set(e.kind, byKey);
650
+ }
651
+ let row = byKey.get(e.key);
652
+ if (!row) {
653
+ row = { out: 0, in: 0 };
654
+ byKey.set(e.key, row);
655
+ }
656
+ if (dir === "in") row.in += e.bytes;
657
+ else row.out += e.bytes;
658
+ }
659
+ }
660
+ describe(frame, out, options) {
661
+ if (frame.length === 0) return;
662
+ out.push({ kind: "overhead", key: "envelope", bytes: 1 });
663
+ const type = frame[0];
664
+ const payload = frame.subarray(1);
665
+ try {
666
+ switch (type) {
667
+ case FrameType.DELTA:
668
+ this.body(payload, out, "field", options?.churn, options?.spatialChurn === true);
669
+ return;
670
+ case FrameType.WRITE:
671
+ this.body(payload, out, "write", void 0);
672
+ return;
673
+ case FrameType.CORRECT: {
674
+ const r = new ByteReader3(payload);
675
+ this.body(r, out, "correction", void 0);
676
+ const tail = payload.length - r.pos;
677
+ if (tail > 0) out.push({ kind: "correction", key: "tail", bytes: tail });
678
+ return;
679
+ }
680
+ case FrameType.CALL: {
681
+ const r = new ByteReader3(payload);
682
+ const reqId = r.u32();
683
+ const rpcId = r.u16();
684
+ const name = this.rpcName(rpcId);
685
+ this.remember(options?.peer, reqId, name);
686
+ out.push({ kind: "rpc", key: name, bytes: payload.length });
687
+ return;
688
+ }
689
+ case FrameType.REPLY: {
690
+ const r = new ByteReader3(payload);
691
+ const reqId = r.u32();
692
+ out.push({ kind: "rpc", key: this.recall(options?.peer, reqId), bytes: payload.length });
693
+ return;
694
+ }
695
+ case FrameType.MSG: {
696
+ const kind = payload[0];
697
+ if (kind === 4) out.push({ kind: "voice", key: "signal", bytes: payload.length });
698
+ else if (kind !== void 0 && kind < MSG_KEYS.length) {
699
+ out.push({ kind: "message", key: MSG_KEYS[kind], bytes: payload.length });
700
+ }
701
+ return;
702
+ }
703
+ case FrameType.WELCOME:
704
+ this.welcome(payload, out);
705
+ return;
706
+ default: {
707
+ const key = CONTROL_KEYS[type];
708
+ if (key !== void 0) out.push({ kind: "control", key, bytes: payload.length });
709
+ return;
710
+ }
711
+ }
712
+ } catch {
713
+ }
714
+ }
715
+ /** WELCOME: the snapshot inside it is `join`; everything around it is `control/welcome`. */
716
+ welcome(payload, out) {
717
+ const r = new ByteReader3(payload);
718
+ skipStr(r);
719
+ skipStr(r);
720
+ r.u32();
721
+ const len = r.varint();
722
+ const before = r.pos;
723
+ if (r.remaining < len) throw new Error("welcome: short snapshot");
724
+ const consumed = this.snapshotBody(payload.subarray(before, before + len), out);
725
+ out.push({ kind: "control", key: "welcome", bytes: payload.length - consumed });
726
+ }
727
+ snapshotBody(bytes, out) {
728
+ const collect = this.collector(out, "join", "join", "header", void 0);
729
+ return walkSnapshot(this.schema, bytes, collect);
730
+ }
731
+ body(bytes, out, fieldKind, churn, spatialChurn = false) {
732
+ const collect = this.collector(out, fieldKind, "overhead", "delta-header", churn, spatialChurn);
733
+ walkDelta(this.schema, bytes, collect);
734
+ }
735
+ /**
736
+ * The walker's consumer. `op` latches: a churn op routes its own framing *and* every field
737
+ * that follows it into `churn`, because a synthetic add carries a whole record whose bytes are
738
+ * the cost of the entity crossing the boundary, not the cost of the fields changing.
739
+ */
740
+ collector(out, fieldKind, overheadKind, headerKey, churn, spatialChurn = false) {
741
+ let churnCollection;
742
+ return {
743
+ header: (bytes) => {
744
+ out.push({ kind: overheadKind, key: headerKey, bytes });
745
+ },
746
+ op: (c, kind, id, bytes) => {
747
+ const isChurn = kind !== "update" && (spatialChurn ? c.visibility === "spatial-grid" : churn !== void 0 && churn.get(c.name)?.has(id) === true);
748
+ churnCollection = isChurn ? c.name : void 0;
749
+ out.push(
750
+ churnCollection !== void 0 ? { kind: "churn", key: c.name, bytes } : { kind: overheadKind, key: overheadKind === "join" ? headerKey : "op", bytes }
751
+ );
752
+ },
753
+ field: (c, f, bytes) => {
754
+ if (churnCollection !== void 0) {
755
+ out.push({ kind: "churn", key: churnCollection, bytes });
756
+ return;
757
+ }
758
+ const key = `${c.name}.${f.name}`;
759
+ out.push({
760
+ kind: fieldKind === "field" && c.name === PRESENCE_COLLECTION ? "presence" : fieldKind,
761
+ key,
762
+ bytes
763
+ });
764
+ }
765
+ };
766
+ }
767
+ rpcName(rpcId) {
768
+ try {
769
+ return rpcByIdOf(this.rpcSchema, rpcId).name;
770
+ } catch {
771
+ return `rpc#${rpcId}`;
772
+ }
773
+ }
774
+ remember(peer, reqId, name) {
775
+ if (this.pending.size >= PENDING_CALLS_MAX) this.pending.clear();
776
+ this.pending.set(`${peer ?? ""}\0${reqId}`, name);
777
+ }
778
+ recall(peer, reqId) {
779
+ const k = `${peer ?? ""}\0${reqId}`;
780
+ const name = this.pending.get(k);
781
+ if (name === void 0) return "(unmatched reply)";
782
+ this.pending.delete(k);
783
+ return name;
784
+ }
785
+ };
786
+ function sum(entries) {
787
+ let n = 0;
788
+ for (const e of entries) n += e.bytes;
789
+ return n;
790
+ }
791
+ function skipStr(r) {
792
+ const n = r.varint();
793
+ if (r.remaining < n) throw new Error("unexpected end of buffer");
794
+ r.pos += n;
795
+ }
796
+ function diffProfiles(prev, next) {
797
+ const before = /* @__PURE__ */ new Map();
798
+ for (const r of prev.rows) before.set(`${r.kind} ${r.key}`, r);
799
+ const rows = [];
800
+ for (const r of next.rows) {
801
+ const b = before.get(`${r.kind} ${r.key}`);
802
+ const out = r.out - (b?.out ?? 0);
803
+ const inb = r.in - (b?.in ?? 0);
804
+ if (out !== 0 || inb !== 0) rows.push({ kind: r.kind, key: r.key, out, in: inb });
805
+ }
806
+ rows.sort((a, b) => b.out + b.in - (a.out + a.in) || (a.key < b.key ? -1 : 1));
807
+ return {
808
+ rows,
809
+ bytesIn: next.bytesIn - prev.bytesIn,
810
+ bytesOut: next.bytesOut - prev.bytesOut,
811
+ framesIn: next.framesIn - prev.framesIn,
812
+ framesOut: next.framesOut - prev.framesOut,
813
+ walks: next.walks - prev.walks
814
+ };
815
+ }
816
+ function topProfileRows(snap, n) {
817
+ return [...snap.rows].sort((a, b) => b.out + b.in - (a.out + a.in)).slice(0, n);
818
+ }
819
+ function scaleProfile(snap, n) {
820
+ if (n <= 1) return snap;
821
+ return {
822
+ rows: snap.rows.map((r) => ({ kind: r.kind, key: r.key, out: r.out / n, in: r.in / n })),
823
+ bytesIn: snap.bytesIn / n,
824
+ bytesOut: snap.bytesOut / n,
825
+ framesIn: snap.framesIn / n,
826
+ framesOut: snap.framesOut / n,
827
+ walks: snap.walks
828
+ };
829
+ }
830
+ function mergeProfiles(a, b) {
831
+ const byKey = /* @__PURE__ */ new Map();
832
+ for (const r of [...a.rows, ...b.rows]) {
833
+ const k = `${r.kind} ${r.key}`;
834
+ const row = byKey.get(k);
835
+ if (row) {
836
+ row.out += r.out;
837
+ row.in += r.in;
838
+ } else byKey.set(k, { kind: r.kind, key: r.key, out: r.out, in: r.in });
839
+ }
840
+ const rows = [...byKey.values()].sort(
841
+ (x, y) => y.out + y.in - (x.out + x.in) || (x.key < y.key ? -1 : 1)
842
+ );
843
+ return {
844
+ rows,
845
+ bytesIn: a.bytesIn + b.bytesIn,
846
+ bytesOut: a.bytesOut + b.bytesOut,
847
+ framesIn: a.framesIn + b.framesIn,
848
+ framesOut: a.framesOut + b.framesOut,
849
+ walks: a.walks + b.walks
850
+ };
851
+ }
852
+
326
853
  // src/msg.ts
327
- import { ByteReader as ByteReader3, ByteWriter as ByteWriter3 } from "@irtio/schema";
854
+ import { ByteReader as ByteReader4, ByteWriter as ByteWriter3 } from "@irtio/schema";
328
855
  var MSG_KIND_ALL = 0;
329
856
  var MSG_KIND_CLIENT = 1;
330
857
  var MSG_KIND_ROLE = 2;
331
858
  var MSG_KIND_SERVER = 3;
332
- function encodeMsg(m) {
333
- const w = new ByteWriter3();
334
- switch (m.target.kind) {
859
+ var MSG_KIND_VOICE = 4;
860
+ var MSG_KIND_TYPED = 5;
861
+ function isVoiceMsg(payload) {
862
+ return payload.length > 0 && payload[0] === MSG_KIND_VOICE;
863
+ }
864
+ function isTypedMsg(payload) {
865
+ return payload.length > 0 && payload[0] === MSG_KIND_TYPED;
866
+ }
867
+ function typedMsgFromClientOk(payload) {
868
+ if (payload.length < 4 || payload[0] !== MSG_KIND_TYPED) return false;
869
+ const inner = payload[3];
870
+ return inner === MSG_KIND_ALL || inner === MSG_KIND_CLIENT || inner === MSG_KIND_ROLE;
871
+ }
872
+ function writeTarget(w, target) {
873
+ switch (target.kind) {
335
874
  case "all":
336
875
  w.u8(MSG_KIND_ALL);
337
- break;
876
+ return;
338
877
  case "client":
339
878
  w.u8(MSG_KIND_CLIENT);
340
- w.str(m.target.clientId);
341
- break;
879
+ w.str(target.clientId);
880
+ return;
342
881
  case "role":
343
882
  w.u8(MSG_KIND_ROLE);
344
- w.str(m.target.role);
345
- break;
883
+ w.str(target.role);
884
+ return;
346
885
  case "server":
347
886
  w.u8(MSG_KIND_SERVER);
348
- break;
887
+ return;
888
+ case "voice":
889
+ w.u8(MSG_KIND_VOICE);
890
+ return;
349
891
  }
892
+ }
893
+ function readTarget(r) {
894
+ const kind = r.u8();
895
+ switch (kind) {
896
+ case MSG_KIND_ALL:
897
+ return { kind: "all" };
898
+ case MSG_KIND_CLIENT:
899
+ return { kind: "client", clientId: r.str() };
900
+ case MSG_KIND_ROLE:
901
+ return { kind: "role", role: r.str() };
902
+ case MSG_KIND_SERVER:
903
+ return { kind: "server" };
904
+ case MSG_KIND_VOICE:
905
+ return { kind: "voice" };
906
+ default:
907
+ throw new Error(`decodeMsg: unknown target discriminator ${kind}`);
908
+ }
909
+ }
910
+ function encodeMsg(m) {
911
+ const w = new ByteWriter3();
912
+ if (m.typed) {
913
+ if (m.target.kind === "voice") {
914
+ throw new Error("encodeMsg: a typed message cannot address voice");
915
+ }
916
+ const index = m.typed.index;
917
+ if (!Number.isInteger(index) || index < 0 || index > 65535) {
918
+ throw new Error(`encodeMsg: typed message index out of range: ${String(index)}`);
919
+ }
920
+ w.u8(MSG_KIND_TYPED);
921
+ w.u16(index);
922
+ writeTarget(w, m.target);
923
+ w.bytes(m.payload);
924
+ return w.finish();
925
+ }
926
+ writeTarget(w, m.target);
350
927
  w.bytes(m.payload);
351
928
  return w.finish();
352
929
  }
353
930
  function decodeMsg(bytes) {
354
- const r = new ByteReader3(bytes);
931
+ const r = new ByteReader4(bytes);
355
932
  const kind = r.u8();
933
+ if (kind === MSG_KIND_TYPED) {
934
+ const index = r.u16();
935
+ const target2 = readTarget(r);
936
+ if (target2.kind === "voice") {
937
+ throw new Error("decodeMsg: a typed message cannot address voice");
938
+ }
939
+ return { target: target2, payload: r.rest(), typed: { index } };
940
+ }
356
941
  let target;
357
942
  switch (kind) {
358
943
  case MSG_KIND_ALL:
@@ -367,6 +952,9 @@ function decodeMsg(bytes) {
367
952
  case MSG_KIND_SERVER:
368
953
  target = { kind: "server" };
369
954
  break;
955
+ case MSG_KIND_VOICE:
956
+ target = { kind: "voice" };
957
+ break;
370
958
  default:
371
959
  throw new Error(`decodeMsg: unknown target discriminator ${kind}`);
372
960
  }
@@ -374,36 +962,316 @@ function decodeMsg(bytes) {
374
962
  return { target, payload };
375
963
  }
376
964
 
377
- // src/presence.ts
378
- import { bool as bool2, defineSchema, entity, str as str2 } from "@irtio/schema";
379
- var presenceEntity = entity(
380
- { clientId: str2(32), role: str2(32), name: str2(32), connected: bool2 },
381
- { serverOwned: true }
382
- );
383
- var PRESENCE_COLLECTION = "clients";
384
- var extended = /* @__PURE__ */ new WeakMap();
385
- function withBuiltins(schema) {
386
- let ext = extended.get(schema);
387
- if (!ext) {
388
- ext = defineSchema(
389
- { ...schema.defs, [PRESENCE_COLLECTION]: presenceEntity },
390
- {
391
- rpc: schema.rpc,
392
- roles: schema.roles,
393
- ...schema.project !== void 0 ? { project: schema.project } : {},
394
- allowReservedNames: true
395
- }
396
- );
397
- extended.set(schema, ext);
965
+ // src/voice.ts
966
+ var ENC = new TextEncoder();
967
+ var DEC = new TextDecoder();
968
+ function encodeVoiceMessage(m) {
969
+ return ENC.encode(JSON.stringify(m));
970
+ }
971
+ function decodeVoiceMessage(bytes) {
972
+ try {
973
+ const parsed = JSON.parse(DEC.decode(bytes));
974
+ if (parsed === null || typeof parsed !== "object") return void 0;
975
+ if (typeof parsed.t !== "string") return void 0;
976
+ return parsed;
977
+ } catch {
978
+ return void 0;
398
979
  }
399
- return ext;
400
980
  }
401
- var relaySchema = withBuiltins(defineSchema({}));
402
- var RELAY_HASH8 = new Uint8Array(8);
403
- function isRelayHash8(hash8) {
404
- if (hash8.length !== 8) return false;
405
- for (const b of hash8) if (b !== 0) return false;
406
- return true;
981
+
982
+ // src/codes.ts
983
+ var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
984
+ var ROOM_TYPE_DELIMITER = ":";
985
+ var ROOM_TYPE_RE = /^[a-z0-9][a-z0-9_-]{0,23}$/;
986
+ var ROOM_ID_RE = /^(?:[a-z0-9][a-z0-9_-]{0,23}:)?[A-Za-z0-9_-]{1,32}$/;
987
+ var DEFAULT_ROOM_TYPE = "default";
988
+ function parseRoomId(raw) {
989
+ if (!ROOM_ID_RE.test(raw)) return void 0;
990
+ const at = raw.indexOf(ROOM_TYPE_DELIMITER);
991
+ if (at < 0) return { type: void 0, id: raw };
992
+ return { type: raw.slice(0, at), id: raw.slice(at + 1) };
993
+ }
994
+ function formatRoomId(type, id) {
995
+ return type === void 0 ? id : `${type}${ROOM_TYPE_DELIMITER}${id}`;
996
+ }
997
+ var MATCH_CODE_LENGTH = 10;
998
+
999
+ // src/bus.ts
1000
+ var BUS_LIMITS = {
1001
+ /**
1002
+ * Max UTF-8 bytes in one payload, for both verbs.
1003
+ *
1004
+ * 4 KiB is a quarter of the KV value limit, and it is sized for what the feature is for: a
1005
+ * match-ready notice, an invite, a bracket announcement — an id, a couple of names, a reason.
1006
+ * A payload is also a *store* cost for `send`, because a mailbox entry persists in the alarm
1007
+ * sidecar, so the ceiling on one room's mailbox is this times `mailboxDepth`, and both numbers
1008
+ * were picked together.
1009
+ */
1010
+ payloadBytes: 4 * 1024,
1011
+ /** Max UTF-8 bytes in a channel name. Names are vocabulary, not data. */
1012
+ channelBytes: 128,
1013
+ /** Max channels one room may hold a subscription to at once. */
1014
+ subscriptionsPerRoom: 32,
1015
+ /**
1016
+ * Max undelivered mailbox entries for one target room.
1017
+ *
1018
+ * This is a store bound as much as a memory one: entries live in the alarm sidecar, so at the
1019
+ * payload cap above, a full mailbox is 256 KiB of persisted object. Past this, a `send` is
1020
+ * refused at the sender with a named error rather than silently dropped at the target, because
1021
+ * a sender that is outrunning a receiver is a fact the sender can act on.
1022
+ */
1023
+ mailboxDepth: 64,
1024
+ /**
1025
+ * How many times one mailbox entry may be delivered before it is dead-lettered.
1026
+ *
1027
+ * The bound exists because at-least-once and the room crash threshold compose badly on purpose.
1028
+ * A handler that always throws surfaces as a crash, and the supervisor's window is 3 restarts in
1029
+ * 60 seconds; an entry that re-armed forever would close the room in four deliveries, on every
1030
+ * wake, for as long as the entry existed. Five attempts is enough to ride out a transient (a
1031
+ * deploy mid-flight, a wake that raced a hibernate) and few enough that a genuinely poisonous
1032
+ * message costs a room one bad minute rather than its life. What happens then is a log line and
1033
+ * a drop: honest for v1, and the docs say so rather than implying a dead-letter queue exists.
1034
+ */
1035
+ maxDeliveryAttempts: 5,
1036
+ /**
1037
+ * Bus operations one room may issue per second, averaged, and the burst it may take at once.
1038
+ *
1039
+ * This is the "one hot room cannot melt the supervisor" guardrail D59 asks for. It counts both
1040
+ * verbs together, because both cost the supervisor a fan-out or a store write, and a limiter
1041
+ * that only counted the cheap one would be a limiter a room could route around.
1042
+ */
1043
+ opsPerSecond: 50,
1044
+ opsBurst: 100,
1045
+ /**
1046
+ * How long a delivered-but-unacknowledged mailbox entry stays armed before it is delivered
1047
+ * again. A visibility timeout, and it is what makes at-least-once actually true rather than
1048
+ * nearly true.
1049
+ *
1050
+ * Posting a message to a worker is not the same as the worker running it: a room that dies
1051
+ * between the post and the handler (a crash, a restart, a hibernate that raced the delivery)
1052
+ * would otherwise lose the message with no trace, which is at-most-once wearing at-least-once's
1053
+ * name. So an entry is re-armed for this long at the moment it is posted, and cleared only when
1054
+ * the worker says the handler ran. 30 seconds is far longer than a handler takes and short
1055
+ * enough that a genuinely lost delivery is retried while anyone still cares.
1056
+ */
1057
+ redeliveryDelayMs: 3e4
1058
+ };
1059
+ var BUS_CHANNEL_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
1060
+ var BUS_ERRORS = {
1061
+ badChannel: "E_BUS_BAD_CHANNEL",
1062
+ payloadTooLarge: "E_BUS_PAYLOAD_TOO_LARGE",
1063
+ tooManySubscriptions: "E_BUS_TOO_MANY_SUBSCRIPTIONS",
1064
+ /** The named room does not exist in this tenant, or is a relay room and runs no handlers. */
1065
+ noSuchRoom: "E_BUS_NO_SUCH_ROOM",
1066
+ mailboxFull: "E_BUS_MAILBOX_FULL",
1067
+ rateLimited: "E_BUS_RATE_LIMITED"
1068
+ };
1069
+ function utf8Bytes(s) {
1070
+ return new TextEncoder().encode(s).length;
1071
+ }
1072
+ function busChannelProblem(channel) {
1073
+ if (typeof channel !== "string" || channel === "") {
1074
+ return { code: BUS_ERRORS.badChannel, message: "a channel name is required" };
1075
+ }
1076
+ if (utf8Bytes(channel) > BUS_LIMITS.channelBytes) {
1077
+ return {
1078
+ code: BUS_ERRORS.badChannel,
1079
+ message: `channel name is longer than ${BUS_LIMITS.channelBytes} bytes`
1080
+ };
1081
+ }
1082
+ if (!BUS_CHANNEL_RE.test(channel)) {
1083
+ return {
1084
+ code: BUS_ERRORS.badChannel,
1085
+ message: `illegal channel name ${JSON.stringify(channel)}: lowercase, starts alphanumeric, and made of letters, digits, dots, underscores and dashes`
1086
+ };
1087
+ }
1088
+ return void 0;
1089
+ }
1090
+ function busPayloadProblem(payload) {
1091
+ if (typeof payload !== "string") {
1092
+ return {
1093
+ code: BUS_ERRORS.payloadTooLarge,
1094
+ message: "a bus payload must be a string (serialise your own object)"
1095
+ };
1096
+ }
1097
+ const bytes = utf8Bytes(payload);
1098
+ if (bytes > BUS_LIMITS.payloadBytes) {
1099
+ return {
1100
+ code: BUS_ERRORS.payloadTooLarge,
1101
+ message: `payload is ${bytes} bytes; the limit is ${BUS_LIMITS.payloadBytes}`
1102
+ };
1103
+ }
1104
+ return void 0;
1105
+ }
1106
+ var BUS_MAILBOX_PREFIX = "__bus.";
1107
+ function isMailboxAlarm(name) {
1108
+ return name.startsWith(BUS_MAILBOX_PREFIX);
1109
+ }
1110
+ var BUS_OUTBOX_PREFIX = "__busout.";
1111
+ function isOutboxAlarm(name) {
1112
+ return name.startsWith(BUS_OUTBOX_PREFIX);
1113
+ }
1114
+ var BUS_OUTBOX = {
1115
+ firstRetryMs: 1e3,
1116
+ maxRetryMs: 6e4,
1117
+ ttlMs: 60 * 60 * 1e3
1118
+ };
1119
+ var BUS_SHARD_STARTING = "E_SHARD_STARTING";
1120
+
1121
+ // src/classes.ts
1122
+ var ROOM_MEMORY_MB_MIN = 32;
1123
+ var ROOM_MEMORY_MB_MAX = 1024;
1124
+ var ROOM_CLASS_MAX_MB = {
1125
+ small: 64,
1126
+ medium: 256,
1127
+ large: ROOM_MEMORY_MB_MAX
1128
+ };
1129
+ function roomClassFor(memoryMb) {
1130
+ if (memoryMb === void 0 || !Number.isFinite(memoryMb)) return "small";
1131
+ if (memoryMb <= ROOM_CLASS_MAX_MB.small) return "small";
1132
+ if (memoryMb <= ROOM_CLASS_MAX_MB.medium) return "medium";
1133
+ return "large";
1134
+ }
1135
+ function roomHoursSourceFor(cls) {
1136
+ return cls === "small" ? "room" : cls;
1137
+ }
1138
+ function roomClassOfSource(source) {
1139
+ if (source === "room") return "small";
1140
+ if (source === "medium" || source === "large") return source;
1141
+ return void 0;
1142
+ }
1143
+
1144
+ // src/sizing.ts
1145
+ var VM_MEM_MIB_MIN = 128;
1146
+ var VM_MEM_MIB_MAX = 4096;
1147
+ var SUPERVISOR_BASELINE_MIB = 54;
1148
+ var NATIVE_RESERVE_FRACTION = 8;
1149
+ var PHYSICS_HEADROOM_MIB = 112;
1150
+ var YOUNG_GEN_MB = 32;
1151
+ var DEFAULT_MEMORY_MB = 32;
1152
+ var DEFAULT_MAX_AWAKE = 1;
1153
+ var MAX_AWAKE_MAX = 256;
1154
+ function nativeReserveOf(vmMib) {
1155
+ return Math.floor(vmMib / NATIVE_RESERVE_FRACTION);
1156
+ }
1157
+ function typeFootprintMib(type) {
1158
+ const memoryMb = normalizeMemoryMb(type.memoryMb);
1159
+ const maxAwake = normalizeMaxAwake(type.maxAwake);
1160
+ return (memoryMb + YOUNG_GEN_MB) * maxAwake;
1161
+ }
1162
+ function normalizeMemoryMb(value) {
1163
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return DEFAULT_MEMORY_MB;
1164
+ return Math.floor(value);
1165
+ }
1166
+ function normalizeMaxAwake(value) {
1167
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_AWAKE;
1168
+ return Math.min(MAX_AWAKE_MAX, Math.floor(value));
1169
+ }
1170
+ function vmSizeFor(types, options = {}) {
1171
+ const baselineMib = options.baselineMib ?? SUPERVISOR_BASELINE_MIB;
1172
+ let workersMib = 0;
1173
+ let anyPhysics = false;
1174
+ for (const type of types) {
1175
+ workersMib += typeFootprintMib(type);
1176
+ if (type.physics === true) anyPhysics = true;
1177
+ }
1178
+ const physicsHeadroomMib = anyPhysics ? PHYSICS_HEADROOM_MIB : 0;
1179
+ const preReserveMib = workersMib + baselineMib + physicsHeadroomMib;
1180
+ const unclamped = Math.ceil(
1181
+ preReserveMib * NATIVE_RESERVE_FRACTION / (NATIVE_RESERVE_FRACTION - 1)
1182
+ );
1183
+ const vmMib = Math.min(VM_MEM_MIB_MAX, Math.max(VM_MEM_MIB_MIN, unclamped));
1184
+ const clamped = unclamped < VM_MEM_MIB_MIN ? "floor" : unclamped > VM_MEM_MIB_MAX ? "ceiling" : void 0;
1185
+ const parts = types.map(
1186
+ (t) => `${t.type} ${normalizeMemoryMb(t.memoryMb)}+${YOUNG_GEN_MB} MB x ${normalizeMaxAwake(t.maxAwake)}`
1187
+ ).join(", ");
1188
+ const detail = `${vmMib} MiB from ${parts || "no room types"}, supervisor baseline ${baselineMib} MiB` + (physicsHeadroomMib > 0 ? `, physics headroom ${physicsHeadroomMib} MiB` : "") + `, native reserve ${vmMib - preReserveMib} MiB` + (clamped === "floor" ? ` (raised to the ${VM_MEM_MIB_MIN} MiB floor)` : clamped === "ceiling" ? ` (lowered to the ${VM_MEM_MIB_MAX} MiB ceiling)` : "");
1189
+ return {
1190
+ vmMib,
1191
+ workersMib,
1192
+ baselineMib,
1193
+ physicsHeadroomMib,
1194
+ preReserveMib,
1195
+ nativeReserveMib: vmMib - preReserveMib,
1196
+ ...clamped !== void 0 ? { clamped } : {},
1197
+ detail
1198
+ };
1199
+ }
1200
+ var ROOMS_PER_VCPU = {
1201
+ small: 20,
1202
+ medium: 8,
1203
+ large: 5
1204
+ };
1205
+ var CPU_QUOTA_PCT_MIN = 10;
1206
+ var CPU_QUOTA_PCT_MAX = 100;
1207
+ function vmCpuFor(types) {
1208
+ if (types.length === 0) {
1209
+ return {
1210
+ quotaPct: CPU_QUOTA_PCT_MAX,
1211
+ rawPct: CPU_QUOTA_PCT_MAX,
1212
+ undeclared: true,
1213
+ detail: "no room types declared, so no CPU throttle"
1214
+ };
1215
+ }
1216
+ const declared = types.some(
1217
+ (t) => t.memoryMb !== void 0 || t.maxAwake !== void 0 && t.maxAwake > 0
1218
+ );
1219
+ if (!declared) {
1220
+ return {
1221
+ quotaPct: CPU_QUOTA_PCT_MAX,
1222
+ rawPct: CPU_QUOTA_PCT_MAX,
1223
+ undeclared: true,
1224
+ detail: `${types.length} room type(s), none declaring memoryMb or maxAwake, so no CPU throttle`
1225
+ };
1226
+ }
1227
+ let rawPct = 0;
1228
+ const parts = [];
1229
+ for (const type of types) {
1230
+ const cls = roomClassFor(normalizeMemoryMbForClass(type.memoryMb));
1231
+ const maxAwake = normalizeMaxAwake(type.maxAwake);
1232
+ const density = ROOMS_PER_VCPU[cls];
1233
+ const share = maxAwake * CPU_QUOTA_PCT_MAX / density;
1234
+ rawPct += share;
1235
+ parts.push(`${type.type} ${cls} x ${maxAwake} / ${density} per vCPU`);
1236
+ }
1237
+ const rounded = Math.ceil(rawPct);
1238
+ const quotaPct = Math.min(CPU_QUOTA_PCT_MAX, Math.max(CPU_QUOTA_PCT_MIN, rounded));
1239
+ const clamped = rounded < CPU_QUOTA_PCT_MIN ? "floor" : rounded > CPU_QUOTA_PCT_MAX ? "ceiling" : void 0;
1240
+ return {
1241
+ quotaPct,
1242
+ rawPct,
1243
+ undeclared: false,
1244
+ ...clamped !== void 0 ? { clamped } : {},
1245
+ detail: `CPUQuota ${quotaPct}% from ${parts.join(", ")}` + (clamped === "floor" ? ` (raised to the ${CPU_QUOTA_PCT_MIN}% floor)` : clamped === "ceiling" ? ` (lowered to the ${CPU_QUOTA_PCT_MAX}% ceiling from ${rounded}%)` : "")
1246
+ };
1247
+ }
1248
+ function normalizeMemoryMbForClass(value) {
1249
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return void 0;
1250
+ return Math.floor(value);
1251
+ }
1252
+ function declarationsFit(vmMemMib, types, options = {}) {
1253
+ const required = vmSizeFor(types, options);
1254
+ return { fits: required.vmMib <= vmMemMib, required };
1255
+ }
1256
+
1257
+ // src/retention.ts
1258
+ var RETENTION_RE = /^[1-9][0-9]{0,4}(m|h|d)$/;
1259
+ var RETENTION_MIN_MS = 6e4;
1260
+ var RETENTION_MAX_MS = 3650 * 24 * 60 * 60 * 1e3;
1261
+ var UNIT_MS = {
1262
+ m: 6e4,
1263
+ h: 60 * 60 * 1e3,
1264
+ d: 24 * 60 * 60 * 1e3
1265
+ };
1266
+ function parseRetention(raw) {
1267
+ if (typeof raw !== "string") return void 0;
1268
+ if (!RETENTION_RE.test(raw)) return void 0;
1269
+ const unit = raw.slice(-1);
1270
+ const scale = UNIT_MS[unit];
1271
+ if (scale === void 0) return void 0;
1272
+ const ms = Number(raw.slice(0, -1)) * scale;
1273
+ if (ms < RETENTION_MIN_MS || ms > RETENTION_MAX_MS) return void 0;
1274
+ return ms;
407
1275
  }
408
1276
 
409
1277
  // src/origin.ts
@@ -429,14 +1297,56 @@ function parseOriginList(raw) {
429
1297
  return raw.split(",").map((o) => o.trim()).filter((o) => o.length > 0);
430
1298
  }
431
1299
  export {
1300
+ BUS_CHANNEL_RE,
1301
+ BUS_ERRORS,
1302
+ BUS_LIMITS,
1303
+ BUS_MAILBOX_PREFIX,
1304
+ BUS_OUTBOX,
1305
+ BUS_OUTBOX_PREFIX,
1306
+ BUS_SHARD_STARTING,
1307
+ CLOSE_EGRESS_WALL,
1308
+ CLOSE_ROOM_DELETED,
1309
+ CLOSE_TRY_AGAIN_LATER,
1310
+ CODE_ALPHABET,
1311
+ CPU_QUOTA_PCT_MAX,
1312
+ CPU_QUOTA_PCT_MIN,
1313
+ DEFAULT_MAX_AWAKE,
1314
+ DEFAULT_MEMORY_MB,
1315
+ DEFAULT_ROOM_TYPE,
1316
+ EMPTY_PROFILE,
432
1317
  ERROR_CATALOGUE,
433
1318
  ErrorCode,
434
1319
  FrameType,
1320
+ HELLO_SCHEMA_SWAP_BIT,
1321
+ MATCH_CODE_LENGTH,
1322
+ MAX_AWAKE_MAX,
1323
+ MSG_KIND_TYPED,
1324
+ NATIVE_RESERVE_FRACTION,
1325
+ PHYSICS_HEADROOM_MIB,
435
1326
  PRESENCE_COLLECTION,
1327
+ PROFILE_KINDS,
436
1328
  PROTOCOL_VERSION,
1329
+ ProfileLedger,
437
1330
  RELAY_HASH8,
1331
+ RETENTION_MAX_MS,
1332
+ RETENTION_MIN_MS,
1333
+ RETENTION_RE,
1334
+ ROOMS_PER_VCPU,
1335
+ ROOM_CLASS_MAX_MB,
1336
+ ROOM_ID_RE,
1337
+ ROOM_MEMORY_MB_MAX,
1338
+ ROOM_MEMORY_MB_MIN,
1339
+ ROOM_TYPE_DELIMITER,
1340
+ ROOM_TYPE_RE,
1341
+ SUPERVISOR_BASELINE_MIB,
1342
+ VM_MEM_MIB_MAX,
1343
+ VM_MEM_MIB_MIN,
1344
+ YOUNG_GEN_MB,
438
1345
  builtinRpcs,
1346
+ busChannelProblem,
1347
+ busPayloadProblem,
439
1348
  correctPayload,
1349
+ declarationsFit,
440
1350
  decodeCall,
441
1351
  decodeErrorPayload,
442
1352
  decodeFrame,
@@ -445,8 +1355,10 @@ export {
445
1355
  decodePing,
446
1356
  decodePong,
447
1357
  decodeReply,
1358
+ decodeVoiceMessage,
448
1359
  decodeWelcome,
449
1360
  deltaPayload,
1361
+ diffProfiles,
450
1362
  encodeCall,
451
1363
  encodeCorrectFrame,
452
1364
  encodeDeltaFrame,
@@ -457,23 +1369,45 @@ export {
457
1369
  encodePing,
458
1370
  encodePong,
459
1371
  encodeReply,
1372
+ encodeSchemaFrame,
1373
+ encodeVoiceMessage,
460
1374
  encodeWelcome,
461
1375
  encodeWriteFrame,
462
1376
  errorByCode,
463
1377
  formatError,
1378
+ formatRoomId,
464
1379
  isFrameType,
465
1380
  isLocalhostOrigin,
1381
+ isMailboxAlarm,
1382
+ isOutboxAlarm,
466
1383
  isRelayHash8,
1384
+ isTypedMsg,
1385
+ isVoiceMsg,
1386
+ mergeProfiles,
1387
+ nativeReserveOf,
467
1388
  originAllowed,
468
1389
  parseOriginList,
1390
+ parseRetention,
1391
+ parseRoomId,
469
1392
  presenceEntity,
470
1393
  readCorrectAppliedTick,
471
1394
  readCorrectClientTick,
1395
+ readSchemaPayload,
472
1396
  relaySchema,
473
1397
  requestOwnership,
1398
+ roomClassFor,
1399
+ roomClassOfSource,
1400
+ roomHoursSourceFor,
474
1401
  rpcByIdOf,
475
1402
  rpcIdOf,
476
1403
  rpcTable,
1404
+ scaleProfile,
1405
+ schemaPayload,
1406
+ topProfileRows,
1407
+ typeFootprintMib,
1408
+ typedMsgFromClientOk,
1409
+ vmCpuFor,
1410
+ vmSizeFor,
477
1411
  withBuiltins,
478
1412
  writePayload
479
1413
  };