@irtio/runtime 0.11.0 → 3.0.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.
@@ -1,13 +1,8 @@
1
- import { L as LogLevel, T as TimelineDump, p as RoomStats, c as HostCall, d as HostCallResult, i as RoomCoreApi, n as RoomHost } from '../contract-Hk4SkDtW.js';
2
- import { ErrorCodeName, ProfileSnapshot } from '@irtio/protocol';
1
+ import { L as LogLevel, t as RpcTraceDump, T as TimelineDump, r as RoomStats, c as HostCall, d as HostCallResult, k as RoomCoreApi, p as RoomHost } from '../contract-BoCNAPZT.js';
2
+ import { ErrorCodeName, ProfileSnapshot, PlatformNotice } from '@irtio/protocol';
3
3
  import { NpcConfig, LeaveReason } from '@irtio/server';
4
4
  import '@irtio/schema';
5
5
 
6
- /**
7
- * Supervisor ↔ room-worker `postMessage` protocol. Every `Uint8Array` is transferred.
8
- * Keep it additive: deployed supervisors speak exactly this.
9
- */
10
-
11
6
  /**
12
7
  * One deployment in a wake-time migration chain. The worker imports each
13
8
  * `migrationUrl` and runs `up` inside its own sandbox — migrations are tenant code and never run
@@ -40,6 +35,11 @@ type ToWorker = {
40
35
  * existing supervisor already sends, and a room without it pays nothing.
41
36
  */
42
37
  profile?: boolean;
38
+ /**
39
+ * M7 pf4: run this room with an RPC trace ring. Absent means off — the shape every existing
40
+ * supervisor already sends — and a room without it pays nothing. `irtio dev --trace-rpc`.
41
+ */
42
+ traceRpc?: boolean;
43
43
  } | {
44
44
  t: 'join';
45
45
  clientId: string;
@@ -51,6 +51,9 @@ type ToWorker = {
51
51
  reconnecting?: boolean;
52
52
  /** D44: this join is a scripted NPC's loopback session. */
53
53
  npc?: boolean;
54
+ /** M8 lane 5: the verified subject, absent for a key join. See `JoinOptions.subject` — it is
55
+ * deliberately narrower than `playerId`, which always has a value. */
56
+ subject?: string;
54
57
  } | {
55
58
  t: 'leave';
56
59
  clientId: string;
@@ -63,12 +66,23 @@ type ToWorker = {
63
66
  clientId: string;
64
67
  bytes: Uint8Array;
65
68
  }
66
- /** `forSave` (D24) asks for the bytes only: no `onSleep`, no timers cleared, nothing
67
- * rejected — the room keeps running. Absent means hibernation, as it always did. */
69
+ /**
70
+ * `forSave` (D24) asks for the bytes only: no `onSleep`, no timers cleared, nothing rejected —
71
+ * the room keeps running. `false` is the departure: `onSleep` runs, timers are cleared, pending
72
+ * work is rejected, and the worker is about to be terminated.
73
+ *
74
+ * **Required, deliberately** (bugs.md #82). It was optional, defaulting to the departure, and
75
+ * the periodic `snapshotEveryMs` snapshot of a *running* event-mode room simply omitted it — so
76
+ * every such room ran `onSleep` and lost its armed timers every thirty seconds while it kept
77
+ * running. Only the caller knows which of the two it meant, so the protocol makes it say. Host
78
+ * and worker are one shipping unit (`@irtio/supervisor` is private and depends on
79
+ * `@irtio/runtime` by `workspace:*`, and this module is not on `@irtio/runtime`'s export map),
80
+ * so there is no mixed-version pair for the missing field to have to mean anything to.
81
+ */
68
82
  | {
69
83
  t: 'serialize';
70
84
  reqId: number;
71
- forSave?: boolean;
85
+ forSave: boolean;
72
86
  } | {
73
87
  t: 'inspect';
74
88
  reqId: number;
@@ -132,6 +146,17 @@ type ToWorker = {
132
146
  name: string;
133
147
  from: string;
134
148
  payload: string;
149
+ }
150
+ /**
151
+ * M7 D-P2: the platform has something to tell this room's players, and the room gets the first
152
+ * look. Carries a `reqId` because the answer decides whether the supervisor broadcasts, so this
153
+ * is a request and not a notification — a rare thing on this side of the wire, and the reason is
154
+ * that a veto only means anything before the frame goes out.
155
+ */
156
+ | {
157
+ t: 'platformNotice';
158
+ reqId: number;
159
+ notice: PlatformNotice;
135
160
  } | {
136
161
  t: 'stop';
137
162
  };
@@ -204,6 +229,17 @@ type FromWorker = {
204
229
  t: 'serialized';
205
230
  reqId: number;
206
231
  bytes: Uint8Array;
232
+ /**
233
+ * The earliest pending `room.setTimeout`/`setInterval` deadline the departure serialize
234
+ * just cleared, in **wall-clock ms** (`Date.now()`'s frame, translated in the worker the
235
+ * same way `setAlarm`'s `atMs` is — the room's own clock is `performance.now()` and means
236
+ * nothing to a supervisor that outlives the worker).
237
+ *
238
+ * Absent when the room had no timers armed, and always absent for `forSave`, which clears
239
+ * nothing and therefore loses nothing. The supervisor arms an internal wake for it so the
240
+ * room is up again by the time its timer would have fired.
241
+ */
242
+ nextTimerAt?: number;
207
243
  } | {
208
244
  t: 'inspected';
209
245
  reqId: number;
@@ -217,6 +253,13 @@ type FromWorker = {
217
253
  }[];
218
254
  /** Worker process RSS in bytes. */
219
255
  rss: number;
256
+ /**
257
+ * M7 pf4: the RPC trace and the per-client stamp-lag table, present only when this room was
258
+ * armed with `traceRpc`. It rides `inspected` rather than a message of its own because the
259
+ * dev page already polls `inspect` once a second and a second round trip would buy nothing
260
+ * — and because a new worker message is a wider change than an additive field on one.
261
+ */
262
+ rpcTrace?: RpcTraceDump;
220
263
  }
221
264
  /** D41: the recorder is armed. `ok: false` when there is no room core to arm. */
222
265
  | {
@@ -224,6 +267,17 @@ type FromWorker = {
224
267
  reqId: number;
225
268
  ok: boolean;
226
269
  }
270
+ /**
271
+ * M7 D-P2: the room's answer to a `platformNotice`. `broadcast: false` only when the handler
272
+ * returned exactly `false`. A handler that threw, a room with no handler, and a room whose core
273
+ * is not up all answer `true`, because the veto is a room saying "I will tell them myself" and
274
+ * none of those three said anything.
275
+ */
276
+ | {
277
+ t: 'platformNoticeResult';
278
+ reqId: number;
279
+ broadcast: boolean;
280
+ }
227
281
  /** D41: the recorded timeline. `dump` absent when nobody armed the recorder. */
228
282
  | {
229
283
  t: 'timeline';
@@ -368,5 +422,17 @@ interface WorkerState {
368
422
  * killing the worker.
369
423
  */
370
424
  declare function handleMessage(state: WorkerState, host: RoomHost, post: (msg: FromWorker, transfer?: ArrayBuffer[]) => void, msg: ToWorker): Promise<void>;
425
+ /**
426
+ * Replaces `Math.random` with sfc32 seeded from 16 host-supplied bytes.
427
+ *
428
+ * Box day 2 (2026-09-08) measured why this exists: two VMs restored from one golden snapshot
429
+ * handed their room workers **bit-for-bit identical `Math.random` streams** — V8 seeds a new
430
+ * isolate's PRNG from process state that a memory snapshot clones, and the kernel's VMGenID
431
+ * reseed does not reach it. `Math.random` is the only random primitive room code has (the
432
+ * sandbox refuses `node:crypto`), so an unseeded clone gives two different projects the same
433
+ * dice. The seed comes from the supervisor's clone-safe entropy mixer via `workerData`, so
434
+ * every worker — cold boot or restore, same VM or sibling clones — rolls its own.
435
+ */
436
+ declare function seedMathRandom(seedHex: string): void;
371
437
 
372
- export { type FromWorker, type MigrationChainStep, type ToWorker, type WorkerState, createWorkerHost, handleMessage, transferList };
438
+ export { type FromWorker, type MigrationChainStep, type ToWorker, type WorkerState, createWorkerHost, handleMessage, seedMathRandom, transferList };
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  migrateSnapshot
3
- } from "../chunk-ACC3SDIO.js";
3
+ } from "../chunk-3BCTP4YS.js";
4
4
  import {
5
+ AdmissionError,
5
6
  RoomCore,
6
7
  RoomFullError,
7
8
  initMatter,
@@ -9,11 +10,11 @@ import {
9
10
  initRapier2d,
10
11
  onFirstRapierStep,
11
12
  rapierHasStepped
12
- } from "../chunk-GYPUNTQN.js";
13
+ } from "../chunk-NPRIYYNM.js";
13
14
 
14
15
  // src/worker/index.ts
15
16
  import { getHeapStatistics } from "v8";
16
- import { isMainThread, parentPort } from "worker_threads";
17
+ import { isMainThread, parentPort, workerData } from "worker_threads";
17
18
  import { isRoomDefinition } from "@irtio/server";
18
19
 
19
20
  // src/worker/protocol.ts
@@ -196,7 +197,8 @@ async function handleMessage(state, host, post, msg) {
196
197
  roomId: msg.roomId,
197
198
  ...msg.seed !== void 0 ? { seed: msg.seed } : {},
198
199
  ...msg.publicUrl !== void 0 ? { publicUrl: msg.publicUrl } : {},
199
- ...msg.profile === true ? { profile: true } : {}
200
+ ...msg.profile === true ? { profile: true } : {},
201
+ ...msg.traceRpc === true ? { traceRpc: true } : {}
200
202
  };
201
203
  const core = snapshot ? RoomCore.restore(def, snapshot, host, options) : new RoomCore(def, host, options);
202
204
  state.core = core;
@@ -259,7 +261,10 @@ async function handleMessage(state, host, post, msg) {
259
261
  ...msg.name !== void 0 ? { name: msg.name } : {},
260
262
  ...msg.playerId !== void 0 ? { playerId: msg.playerId } : {},
261
263
  ...msg.reconnecting !== void 0 ? { reconnecting: msg.reconnecting } : {},
262
- ...msg.npc === true ? { npc: true } : {}
264
+ ...msg.npc === true ? { npc: true } : {},
265
+ // M8 lane 5: absent for a key join, and absent is the answer `onAdmit` and `room.ban`
266
+ // both need to be able to see.
267
+ ...msg.subject !== void 0 ? { subject: msg.subject } : {}
263
268
  };
264
269
  const result = core.join(msg.clientId, joinOptions);
265
270
  const snapshot = ownBuffer(result.snapshot);
@@ -276,6 +281,13 @@ async function handleMessage(state, host, post, msg) {
276
281
  } catch (err) {
277
282
  if (err instanceof RoomFullError) {
278
283
  post({ t: "joinRejected", clientId: msg.clientId, code: "E_ROOM_FULL" });
284
+ } else if (err instanceof AdmissionError) {
285
+ post({
286
+ t: "joinRejected",
287
+ clientId: msg.clientId,
288
+ code: "E_ADMISSION",
289
+ reason: err.reason
290
+ });
279
291
  } else {
280
292
  post({
281
293
  t: "joinRejected",
@@ -302,8 +314,17 @@ async function handleMessage(state, host, post, msg) {
302
314
  case "serialize": {
303
315
  const core = state.core;
304
316
  if (!core) return;
305
- const bytes = ownBuffer(msg.forSave === true ? core.snapshot() : core.serialize());
306
- post({ t: "serialized", reqId: msg.reqId, bytes }, [bytes.buffer]);
317
+ const bytes = ownBuffer(msg.forSave ? core.snapshot() : core.serialize());
318
+ const timerAt = msg.forSave ? void 0 : core.lastSerializeTimerAt;
319
+ post(
320
+ {
321
+ t: "serialized",
322
+ reqId: msg.reqId,
323
+ bytes,
324
+ ...timerAt === void 0 ? {} : { nextTimerAt: Date.now() + (timerAt - performance.now()) }
325
+ },
326
+ [bytes.buffer]
327
+ );
307
328
  return;
308
329
  }
309
330
  case "inspect": {
@@ -316,7 +337,13 @@ async function handleMessage(state, host, post, msg) {
316
337
  tick: ins.tick,
317
338
  stateJson: ins.state,
318
339
  recent: ins.recent,
319
- rss: typeof process !== "undefined" ? process.memoryUsage().rss : 0
340
+ rss: typeof process !== "undefined" ? process.memoryUsage().rss : 0,
341
+ // M7 pf4: absent unless this room was armed, which is what makes `undefined` mean
342
+ // "nobody asked" rather than "nobody called".
343
+ ...(() => {
344
+ const trace = core.rpcTrace();
345
+ return trace === void 0 ? {} : { rpcTrace: trace };
346
+ })()
320
347
  });
321
348
  return;
322
349
  }
@@ -352,6 +379,11 @@ async function handleMessage(state, host, post, msg) {
352
379
  state.core?.fireAlarm(msg.name);
353
380
  return;
354
381
  }
382
+ case "platformNotice": {
383
+ const broadcast = state.core?.deliverPlatformNotice(msg.notice) ?? true;
384
+ post({ t: "platformNoticeResult", reqId: msg.reqId, broadcast });
385
+ return;
386
+ }
355
387
  case "busEvent": {
356
388
  state.core?.deliverBusEvent(msg.channel, msg.from, msg.payload);
357
389
  return;
@@ -390,7 +422,32 @@ async function handleMessage(state, host, post, msg) {
390
422
  });
391
423
  }
392
424
  }
425
+ function seedMathRandom(seedHex) {
426
+ if (!/^[0-9a-f]{32}$/.test(seedHex)) return;
427
+ const u32 = (i) => Number.parseInt(seedHex.slice(i * 8, i * 8 + 8), 16) >>> 0;
428
+ let a = u32(0);
429
+ let b = u32(1);
430
+ let c = u32(2);
431
+ let d = u32(3);
432
+ const sfc32 = () => {
433
+ a >>>= 0;
434
+ b >>>= 0;
435
+ c >>>= 0;
436
+ d >>>= 0;
437
+ const t = a + b + d >>> 0;
438
+ d = d + 1 >>> 0;
439
+ a = b ^ b >>> 9;
440
+ b = c + (c << 3) >>> 0;
441
+ c = (c << 21 | c >>> 11) >>> 0;
442
+ c = c + t >>> 0;
443
+ return t / 4294967296;
444
+ };
445
+ for (let i = 0; i < 12; i++) sfc32();
446
+ Math.random = sfc32;
447
+ }
393
448
  if (!isMainThread && parentPort) {
449
+ const seed = workerData?.mathRandomSeed;
450
+ if (typeof seed === "string") seedMathRandom(seed);
394
451
  const port = parentPort;
395
452
  const post = (msg, transfer) => {
396
453
  port.postMessage(msg, transfer ?? transferList(msg));
@@ -404,5 +461,6 @@ if (!isMainThread && parentPort) {
404
461
  export {
405
462
  createWorkerHost,
406
463
  handleMessage,
464
+ seedMathRandom,
407
465
  transferList
408
466
  };
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@irtio/runtime",
3
- "version": "0.11.0",
3
+ "version": "3.0.0",
4
4
  "description": "irtio room runtime: RoomCore, worker_threads host, in-process test harness",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alex-irt/irtio.git",
9
+ "directory": "packages/runtime"
10
+ },
6
11
  "publishConfig": {
7
12
  "access": "public"
8
13
  },
@@ -31,9 +36,9 @@
31
36
  "@dimforge/rapier2d-compat": "0.20.0",
32
37
  "@dimforge/rapier3d-compat": "0.20.0",
33
38
  "matter-js": "0.20.0",
34
- "@irtio/protocol": "0.11.0",
35
- "@irtio/schema": "0.11.0",
36
- "@irtio/server": "0.11.0"
39
+ "@irtio/protocol": "3.0.0",
40
+ "@irtio/schema": "3.0.0",
41
+ "@irtio/server": "3.0.0"
37
42
  },
38
43
  "devDependencies": {
39
44
  "@types/matter-js": "0.20.2"