@irtio/client 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.
package/dist/index.js CHANGED
@@ -2,12 +2,16 @@ import {
2
2
  ClientStore,
3
3
  E_CONNECT_FAILED,
4
4
  MAX_PREDICTED_BODIES,
5
+ MAX_PROXY_BODIES,
5
6
  PREDICTION_EPSILON,
6
7
  RESIM_DEPTH,
7
8
  SMOOTHING_HALF_LIFE_MS,
8
9
  SMOOTHING_SNAP_UNITS,
9
10
  emptyPredictionStats
10
- } from "./chunk-7UTJ7RSF.js";
11
+ } from "./chunk-XWVXZRBS.js";
12
+
13
+ // src/index.ts
14
+ import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
11
15
 
12
16
  // src/endpoint.ts
13
17
  var DEFAULT_REGION = "eu";
@@ -112,10 +116,408 @@ function linkForUrl(wsUrl, roomId) {
112
116
  }
113
117
  }
114
118
 
119
+ // src/identity.ts
120
+ var ACCOUNT_STORAGE_KEY = "irtio.account";
121
+ function identityStorageKey(project) {
122
+ return `irtio.identity.${project}`;
123
+ }
124
+ var DEFAULT_CONTROL_URL = "https://irt.io";
125
+ function browserStorage() {
126
+ try {
127
+ const ls = globalThis.localStorage;
128
+ if (!ls) return void 0;
129
+ ls.getItem("irtio.identity.probe");
130
+ return ls;
131
+ } catch {
132
+ return void 0;
133
+ }
134
+ }
135
+ var IdentityError = class extends Error {
136
+ constructor(code, message, retryAfterMs) {
137
+ super(message);
138
+ this.code = code;
139
+ this.retryAfterMs = retryAfterMs;
140
+ }
141
+ code;
142
+ name = "IdentityError";
143
+ /**
144
+ * How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
145
+ * on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
146
+ * tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
147
+ */
148
+ retryAfterMs;
149
+ };
150
+ var E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
151
+ var MAX_IDENTITY_RETRY_WAIT_MS = 6e4;
152
+ function retryAfterMsFrom(res, body) {
153
+ const header = res.headers?.get?.("retry-after");
154
+ const fromHeader = header === null || header === void 0 ? Number.NaN : Number(header);
155
+ if (Number.isFinite(fromHeader) && fromHeader >= 0) return fromHeader * 1e3;
156
+ const fromBody = typeof body?.retryAfter === "number" ? body.retryAfter : Number.NaN;
157
+ if (Number.isFinite(fromBody) && fromBody >= 0) return fromBody * 1e3;
158
+ return 6e4;
159
+ }
160
+ var sleep = (ms) => new Promise((resolve) => {
161
+ setTimeout(resolve, ms);
162
+ });
163
+ var Identity = class {
164
+ project;
165
+ controlUrl;
166
+ fetchImpl;
167
+ storage;
168
+ token;
169
+ accountId;
170
+ cached;
171
+ inFlight;
172
+ constructor(options) {
173
+ this.project = options.project;
174
+ this.controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
175
+ this.fetchImpl = options.fetch ?? fetch;
176
+ this.storage = options.storage === null ? void 0 : options.storage ?? browserStorage() ?? void 0;
177
+ this.token = this.load();
178
+ }
179
+ /**
180
+ * Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
181
+ *
182
+ * Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
183
+ * whose id is the identity's own id, so the credential that was this player on this project is
184
+ * already this player's account credential everywhere. Moving it to the origin-wide key is what
185
+ * makes the same person on the same browser one player across the games on that origin.
186
+ *
187
+ * The old key is removed after a successful adoption. If the write fails (a storage that reads
188
+ * but will not write), the old value is used anyway and adoption is retried next time, which is
189
+ * the same degrade-quietly rule the rest of this module follows.
190
+ */
191
+ load() {
192
+ try {
193
+ const current = this.storage?.getItem(ACCOUNT_STORAGE_KEY);
194
+ if (typeof current === "string" && current !== "") return current;
195
+ } catch {
196
+ return void 0;
197
+ }
198
+ let legacy;
199
+ try {
200
+ legacy = this.storage?.getItem(identityStorageKey(this.project));
201
+ } catch {
202
+ return void 0;
203
+ }
204
+ if (typeof legacy !== "string" || legacy === "") return void 0;
205
+ try {
206
+ this.storage?.setItem(ACCOUNT_STORAGE_KEY, legacy);
207
+ this.storage?.removeItem(identityStorageKey(this.project));
208
+ } catch {
209
+ }
210
+ return legacy;
211
+ }
212
+ /** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
213
+ get stored() {
214
+ return this.token;
215
+ }
216
+ /** The player id the room will see, once an assertion has been fetched. */
217
+ get playerId() {
218
+ return this.cached?.playerId;
219
+ }
220
+ /**
221
+ * Mints an identity if this browser has none, and returns the token.
222
+ *
223
+ * A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
224
+ * that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
225
+ * a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
226
+ * `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
227
+ * "gone" (bug #36).
228
+ */
229
+ async ensure() {
230
+ if (this.token !== void 0) return this.token;
231
+ try {
232
+ return await this.mint();
233
+ } catch (err) {
234
+ if (!(err instanceof IdentityError) || err.code !== E_IDENTITY_RATE_LIMITED) throw err;
235
+ const wait = err.retryAfterMs ?? 6e4;
236
+ if (wait > MAX_IDENTITY_RETRY_WAIT_MS) throw err;
237
+ await sleep(wait);
238
+ return await this.mint();
239
+ }
240
+ }
241
+ async mint() {
242
+ const res = await this.post("/v1/identity", { project: this.project });
243
+ const body = await res.json();
244
+ if (typeof body.identity !== "string") {
245
+ throw new IdentityError("E_IDENTITY_MINT_FAILED", "the control plane returned no identity");
246
+ }
247
+ this.token = body.identity;
248
+ this.accountId = typeof body.account === "string" ? body.account : void 0;
249
+ try {
250
+ this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
251
+ } catch {
252
+ }
253
+ return body.identity;
254
+ }
255
+ /**
256
+ * A currently-valid assertion for this project, exchanging one if the cached one is gone or
257
+ * within thirty seconds of expiry.
258
+ *
259
+ * The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
260
+ * Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
261
+ */
262
+ async assertion(now = Date.now()) {
263
+ const cached = this.cached;
264
+ if (cached && cached.expiresAtMs - 3e4 > now) return cached.assertion;
265
+ if (this.inFlight) return this.inFlight;
266
+ this.inFlight = this.exchange().finally(() => {
267
+ this.inFlight = void 0;
268
+ });
269
+ return this.inFlight;
270
+ }
271
+ async exchange() {
272
+ const identity = await this.ensure();
273
+ let res;
274
+ try {
275
+ res = await this.post("/v1/identity/assertion", { identity, project: this.project });
276
+ } catch (err) {
277
+ if (err instanceof IdentityError && err.code === "E_IDENTITY_INVALID") {
278
+ this.forget();
279
+ const fresh = await this.ensure();
280
+ res = await this.post("/v1/identity/assertion", { identity: fresh, project: this.project });
281
+ } else {
282
+ throw err;
283
+ }
284
+ }
285
+ const body = await res.json();
286
+ if (typeof body.assertion !== "string" || typeof body.playerId !== "string") {
287
+ throw new IdentityError(
288
+ "E_IDENTITY_EXCHANGE_FAILED",
289
+ "the control plane returned no assertion"
290
+ );
291
+ }
292
+ const ttl = typeof body.expiresInMs === "number" ? body.expiresInMs : 6e4;
293
+ this.cached = {
294
+ assertion: body.assertion,
295
+ playerId: body.playerId,
296
+ expiresAtMs: Date.now() + ttl
297
+ };
298
+ return body.assertion;
299
+ }
300
+ /** Drops the stored identity. The next `ensure()` mints a new player. */
301
+ forget() {
302
+ this.token = void 0;
303
+ this.accountId = void 0;
304
+ this.cached = void 0;
305
+ try {
306
+ this.storage?.removeItem(ACCOUNT_STORAGE_KEY);
307
+ } catch {
308
+ }
309
+ }
310
+ // -------------------------------------------------------------------------
311
+ // D61: linking a second device, and managing the ones already on the account
312
+ //
313
+ // Every call below presents the stored device credential in the `x-irt-identity` header, to the
314
+ // control plane, over HTTPS, and to nothing else. That is the same rule the module's header
315
+ // states about the credential generally, and these methods are the reason it needs restating:
316
+ // they are the first surface other than the exchange that the credential is sent to at all.
317
+ // -------------------------------------------------------------------------
318
+ /**
319
+ * Asks the control plane for a link code to read out on another device.
320
+ *
321
+ * The code is short and typable because a person carries it between two screens. Show it, do
322
+ * not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
323
+ * one thing about this that a player controls.
324
+ */
325
+ async linkCode() {
326
+ const res = await this.accountFetch("POST", "/v1/account/link-code", {});
327
+ const body = await res.json();
328
+ if (typeof body.code !== "string") {
329
+ throw new IdentityError("E_LINK_CODE_FAILED", "the control plane returned no link code");
330
+ }
331
+ return {
332
+ code: body.code,
333
+ expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 10 * 6e4
334
+ };
335
+ }
336
+ /**
337
+ * Redeems a code read off another device, and **replaces** this browser's credential with a new
338
+ * one on that code's account.
339
+ *
340
+ * Two things happen in that order and both matter. The new credential is stored first, so a
341
+ * failure between the two leaves the player linked rather than credential-less. Then whatever
342
+ * this browser used to be is retired on the account it is leaving.
343
+ *
344
+ * **That retirement deletes the old account when this was its only device**, rather than
345
+ * revoking the credential and walking away. Revoking the last device leaves an account nothing
346
+ * can ever authenticate as, whose board rows and saved games are then unreachable by the player
347
+ * and undeletable by anyone. An account with other devices only loses this one.
348
+ *
349
+ * So this call can destroy the progress held on THIS browser. Warn the player first; the docs
350
+ * page has wording for it. The retirement is best-effort: if it fails the link still stands,
351
+ * because failing a link that already worked is the worse direction.
352
+ */
353
+ async redeemLinkCode(code) {
354
+ const previous = this.token;
355
+ let previousDeviceId;
356
+ let previousWasOnlyDevice = false;
357
+ if (previous !== void 0) {
358
+ try {
359
+ const before = await this.devices();
360
+ previousDeviceId = before.self;
361
+ previousWasOnlyDevice = before.devices.length === 1;
362
+ } catch {
363
+ previousDeviceId = void 0;
364
+ }
365
+ }
366
+ const res = await this.post("/v1/account/link", { code }, `?project=${enc(this.project)}`);
367
+ const body = await res.json();
368
+ if (typeof body.identity !== "string" || typeof body.account !== "string") {
369
+ throw new IdentityError("E_LINK_FAILED", "the control plane returned no linked identity");
370
+ }
371
+ this.token = body.identity;
372
+ this.accountId = body.account;
373
+ this.cached = void 0;
374
+ try {
375
+ this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
376
+ } catch {
377
+ }
378
+ if (previous !== void 0 && previousDeviceId !== void 0) {
379
+ try {
380
+ const url = previousWasOnlyDevice ? `${this.controlUrl}/v1/account?project=${enc(this.project)}` : `${this.controlUrl}/v1/account/devices/${enc(previousDeviceId)}?project=${enc(this.project)}`;
381
+ await this.fetchImpl(url, {
382
+ method: "DELETE",
383
+ headers: { "x-irt-identity": previous }
384
+ });
385
+ } catch {
386
+ }
387
+ }
388
+ return { account: body.account };
389
+ }
390
+ /** The devices on this account: ids and timestamps, plus which one this browser is. */
391
+ async devices() {
392
+ const res = await this.accountFetch("GET", "/v1/account/devices");
393
+ const body = await res.json();
394
+ if (typeof body.account !== "string" || !Array.isArray(body.devices)) {
395
+ throw new IdentityError("E_ACCOUNT_READ_FAILED", "the control plane returned no device list");
396
+ }
397
+ this.accountId = body.account;
398
+ return {
399
+ account: body.account,
400
+ self: typeof body.self === "string" ? body.self : "",
401
+ devices: body.devices
402
+ };
403
+ }
404
+ /**
405
+ * Revokes one device on this account. Revoking the device this browser IS leaves this instance
406
+ * holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
407
+ * mints a fresh player rather than looping on a refused exchange.
408
+ */
409
+ async revokeDevice(deviceId) {
410
+ let self;
411
+ try {
412
+ self = (await this.devices()).self;
413
+ } catch {
414
+ self = void 0;
415
+ }
416
+ await this.accountFetch("DELETE", `/v1/account/devices/${enc(deviceId)}`);
417
+ if (self !== void 0 && self === deviceId) this.forget();
418
+ }
419
+ /**
420
+ * Deletes this account and everything keyed on it, in every project it played, and forgets the
421
+ * credential. There is no undo and the control plane does not keep a copy.
422
+ */
423
+ async deleteAccount() {
424
+ await this.accountFetch("DELETE", "/v1/account");
425
+ this.forget();
426
+ }
427
+ /** The account id, once anything has told us what it is. Opaque; never parse it. */
428
+ get account() {
429
+ return this.accountId;
430
+ }
431
+ /** One authenticated account-route call: credential in the header, project in the query. */
432
+ async accountFetch(method, path, body) {
433
+ const identity = await this.ensure();
434
+ const url = `${this.controlUrl}${path}?project=${enc(this.project)}`;
435
+ let res;
436
+ try {
437
+ res = await this.fetchImpl(url, {
438
+ method,
439
+ headers: {
440
+ "content-type": "application/json",
441
+ // The credential goes here and nowhere else. Not in the URL: a query string reaches
442
+ // access logs, `Referer` headers and browser history, and this one is long-lived.
443
+ "x-irt-identity": identity
444
+ },
445
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
446
+ });
447
+ } catch (err) {
448
+ throw new IdentityError(
449
+ "E_IDENTITY_UNREACHABLE",
450
+ `cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
451
+ );
452
+ }
453
+ if (res.ok) return res;
454
+ throw await errorFrom(res);
455
+ }
456
+ async post(path, body, query = "") {
457
+ let res;
458
+ try {
459
+ res = await this.fetchImpl(`${this.controlUrl}${path}${query}`, {
460
+ method: "POST",
461
+ headers: { "content-type": "application/json" },
462
+ body: JSON.stringify(body)
463
+ });
464
+ } catch (err) {
465
+ throw new IdentityError(
466
+ "E_IDENTITY_UNREACHABLE",
467
+ `cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
468
+ );
469
+ }
470
+ if (res.ok) return res;
471
+ throw await errorFrom(res);
472
+ }
473
+ };
474
+ function enc(value) {
475
+ return encodeURIComponent(value);
476
+ }
477
+ async function errorFrom(res) {
478
+ const text = await res.text().catch(() => "");
479
+ let code = `E_HTTP_${res.status}`;
480
+ let message = text || `request failed with status ${res.status}`;
481
+ let parsed;
482
+ try {
483
+ parsed = JSON.parse(text);
484
+ if (typeof parsed.code === "string") code = parsed.code;
485
+ if (typeof parsed.message === "string") message = parsed.message;
486
+ } catch {
487
+ }
488
+ if (res.status === 429) {
489
+ const retryAfterMs = retryAfterMsFrom(res, parsed);
490
+ return new IdentityError(
491
+ E_IDENTITY_RATE_LIMITED,
492
+ `${message} (rate limited; retry in ${Math.round(retryAfterMs / 1e3)}s)`,
493
+ retryAfterMs
494
+ );
495
+ }
496
+ return new IdentityError(code, message);
497
+ }
498
+
499
+ // src/messages.ts
500
+ import { encodeFields } from "@irtio/schema";
501
+ function buildMessages(schema, session) {
502
+ const out = {};
503
+ for (const desc of schema?.messages ?? []) {
504
+ out[desc.name] = {
505
+ send(target, value) {
506
+ session.typedMessage(desc.index, target, encodeFields(desc.fields, value));
507
+ },
508
+ on(cb) {
509
+ return session.onTypedMessage(desc.index, cb);
510
+ }
511
+ };
512
+ }
513
+ return out;
514
+ }
515
+
115
516
  // src/session.ts
116
517
  import {
117
518
  FrameType,
118
519
  PROTOCOL_VERSION,
520
+ ProfileLedger,
119
521
  RELAY_HASH8,
120
522
  decodeCall,
121
523
  decodeErrorPayload,
@@ -134,21 +536,26 @@ import {
134
536
  formatError,
135
537
  readCorrectAppliedTick,
136
538
  readCorrectClientTick,
539
+ readSchemaPayload,
137
540
  relaySchema,
138
541
  rpcTable,
139
542
  withBuiltins
140
543
  } from "@irtio/protocol";
544
+ import { EMPTY_PROFILE, diffProfiles, scaleProfile } from "@irtio/protocol";
141
545
  import {
142
546
  ByteReader,
143
547
  decodeDelta,
144
548
  decodeDeltaFrom,
145
549
  decodeFields,
146
- encodeFields
550
+ encodeFields as encodeFields2,
551
+ schemaFromCanonical
147
552
  } from "@irtio/schema";
148
553
 
149
554
  // src/render.ts
150
555
  var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
151
556
  var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
557
+ var BASE_DRIFT_MS_PER_S = 2;
558
+ var SAME_T_EPSILON = 0.01;
152
559
  function cloneRecord(value) {
153
560
  const out = {};
154
561
  for (const [k, v] of Object.entries(value)) {
@@ -176,29 +583,61 @@ function lerpRecord(desc, a, b, alpha) {
176
583
  return out;
177
584
  }
178
585
  var RenderStore = class {
179
- constructor(ext, store, meOf, now, delayMs) {
586
+ constructor(ext, store, meOf, now, delayMs, intervalMs) {
180
587
  this.ext = ext;
181
588
  this.store = store;
182
589
  this.meOf = meOf;
183
590
  this.now = now;
184
591
  this.delayMs = delayMs;
592
+ this.intervalMs = intervalMs;
185
593
  for (const c of ext.collections) this.descs.set(c.name, c);
186
- this.view = this.buildView();
594
+ this.view = {};
595
+ this.buildViewInto(this.view);
187
596
  }
188
597
  ext;
189
598
  store;
190
599
  meOf;
191
600
  now;
192
601
  delayMs;
602
+ intervalMs;
193
603
  /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
194
604
  buffers = /* @__PURE__ */ new Map();
195
605
  descs = /* @__PURE__ */ new Map();
606
+ /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
607
+ facades = /* @__PURE__ */ new Map();
196
608
  /** The object handed out as `room.render`; identity survives a resync. */
197
609
  view;
198
610
  /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
199
611
  starved = 0;
200
612
  /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
201
613
  predictor;
614
+ /**
615
+ * The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
616
+ * is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
617
+ */
618
+ base;
619
+ /** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
620
+ baseAt = 0;
621
+ /**
622
+ * D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
623
+ * it, exactly as `ClientStore.swapSchema` does.
624
+ *
625
+ * Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
626
+ * out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
627
+ * keyframe against a new one under the new field list is precisely the silent misread this
628
+ * whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
629
+ * re-seeds one keyframe per entity, which is the same state the buffers would have started
630
+ * from on a reconnect.
631
+ */
632
+ swapSchema(newExt) {
633
+ this.ext = newExt;
634
+ this.descs.clear();
635
+ for (const c of newExt.collections) this.descs.set(c.name, c);
636
+ this.buffers.clear();
637
+ this.starved = 0;
638
+ this.base = void 0;
639
+ this.buildViewInto(this.view);
640
+ }
202
641
  // -- ingest ---------------------------------------------------------------
203
642
  /**
204
643
  * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
@@ -207,6 +646,7 @@ var RenderStore = class {
207
646
  */
208
647
  seedSnapshot() {
209
648
  this.buffers.clear();
649
+ this.base = void 0;
210
650
  const t = this.now() - this.delayMs();
211
651
  for (const c of this.ext.collections) {
212
652
  if (c.kind !== "entity" || !c.interpolate) continue;
@@ -219,9 +659,39 @@ var RenderStore = class {
219
659
  }
220
660
  }
221
661
  }
222
- /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
662
+ /**
663
+ * Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
664
+ * and the caller must fall back to arrival stamping.
665
+ *
666
+ * The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
667
+ * delayed delta seen so far is the best evidence of where the server's tick clock sits, and
668
+ * every later delta is that plus its own queueing delay. So a better sample is taken
669
+ * immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
670
+ * render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
671
+ * clock rather than a late frame and snaps.
672
+ */
673
+ stampFor(tick) {
674
+ const interval = this.intervalMs();
675
+ if (!(interval > 0)) return void 0;
676
+ const now = this.now();
677
+ const cand = now - tick * interval;
678
+ if (this.base === void 0 || cand < this.base || Math.abs(cand - this.base) > this.snapMs()) {
679
+ this.base = cand;
680
+ } else {
681
+ this.base = Math.min(cand, this.base + (now - this.baseAt) * (BASE_DRIFT_MS_PER_S / 1e3));
682
+ }
683
+ this.baseAt = now;
684
+ return Math.min(this.base + tick * interval, now);
685
+ }
686
+ snapMs() {
687
+ return Math.max(250, 4 * this.delayMs());
688
+ }
689
+ /**
690
+ * Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
691
+ * burstiness cannot modulate the drawn velocity. Call after apply.
692
+ */
223
693
  recordDelta(delta) {
224
- const t = this.now();
694
+ const t = this.stampFor(delta.tick) ?? this.now();
225
695
  for (const dc of delta.collections) {
226
696
  const desc = this.descs.get(dc.name);
227
697
  if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
@@ -236,10 +706,26 @@ var RenderStore = class {
236
706
  if (value === void 0) continue;
237
707
  const buffer = this.bufferFor(dc.name, op.id);
238
708
  buffer.removedAt = void 0;
239
- buffer.frames.push({ t, value: cloneRecord(value) });
709
+ this.pushFrame(buffer, t, cloneRecord(value));
240
710
  }
241
711
  }
242
712
  }
713
+ /**
714
+ * Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
715
+ * depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
716
+ * a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
717
+ * appends; and an offset that just snapped backwards must not stamp behind what is buffered.
718
+ */
719
+ pushFrame(buffer, t, value) {
720
+ const newest = buffer.frames[buffer.frames.length - 1];
721
+ if (newest && t <= newest.t) {
722
+ if (t >= newest.t - SAME_T_EPSILON)
723
+ buffer.frames[buffer.frames.length - 1] = { t: newest.t, value };
724
+ else buffer.frames.push({ t: newest.t + SAME_T_EPSILON, value });
725
+ return;
726
+ }
727
+ buffer.frames.push({ t, value });
728
+ }
243
729
  bufferFor(collection, id) {
244
730
  let byId = this.buffers.get(collection);
245
731
  if (!byId) {
@@ -270,7 +756,7 @@ var RenderStore = class {
270
756
  if (value !== void 0) return this.predictor.read(desc, id, { ...value });
271
757
  }
272
758
  }
273
- if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
759
+ if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
274
760
  return this.store.instance(desc, id);
275
761
  }
276
762
  if (!desc.interpolate) return this.store.instance(desc, id);
@@ -292,14 +778,68 @@ var RenderStore = class {
292
778
  if (renderT > a.t) this.starved++;
293
779
  return cloneRecord(a.value);
294
780
  }
295
- const alpha = (renderT - a.t) / (b.t - a.t);
781
+ const span = b.t - a.t;
782
+ const alpha = span > 0 ? (renderT - a.t) / span : 1;
296
783
  return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
297
784
  }
785
+ /**
786
+ * M6 lane D (D71): the body-channel values the renderer is drawing for one instance right now,
787
+ * written into `into`. `true` when there is a pose to draw at all.
788
+ *
789
+ * This is what a kinematic proxy is moved to before every local step, and it deliberately does
790
+ * **not** go through `get`. `get` asks the predictor first — which would recurse, since it calls
791
+ * `frame()` — and for an owned or predicted instance it answers out of the local world, which is
792
+ * the one answer a proxy must never be given (a proxy driven by the local world is a body driving
793
+ * itself). So this is the D20 buffer and nothing else: the authoritative pose interpolated at
794
+ * `interpDelayMs` behind arrival, held at the newest delta and never extrapolated past it.
795
+ *
796
+ * Allocation-free, and only the physics channels: it runs once per proxy per frame.
797
+ *
798
+ * Two things `get` does are left out on purpose. `starved` is not counted, because that number
799
+ * belongs to the render path and a physics read landing in it would double it. And a buffer whose
800
+ * entity has aged out is not `gc`'d here, because a read that drives physics must not decide when
801
+ * the render path's buffers die.
802
+ */
803
+ drawn(desc, id, into) {
804
+ const physics = desc.physics;
805
+ if (!physics) return false;
806
+ const coll = this.store.plain[desc.name];
807
+ const fill = (value) => {
808
+ if (value === void 0) return false;
809
+ for (const [, field] of physics.channels) {
810
+ const v = value[field];
811
+ if (typeof v === "number") into[field] = v;
812
+ }
813
+ return true;
814
+ };
815
+ if (!desc.interpolate) return fill(coll.get(id));
816
+ const buffer = this.buffers.get(desc.name)?.get(id);
817
+ if (!buffer || buffer.frames.length === 0) return fill(coll.get(id));
818
+ const renderT = this.renderTime();
819
+ if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
820
+ this.prune(buffer, renderT);
821
+ const frames = buffer.frames;
822
+ const a = frames[0];
823
+ if (renderT < a.t) return false;
824
+ const b = frames[1];
825
+ if (!b) return fill(a.value);
826
+ const span = b.t - a.t;
827
+ const alpha = span > 0 ? Math.min(1, Math.max(0, (renderT - a.t) / span)) : 1;
828
+ for (const [, field] of physics.channels) {
829
+ const av = a.value[field];
830
+ if (typeof av !== "number") continue;
831
+ const bv = b.value[field];
832
+ into[field] = typeof bv === "number" ? av + (bv - av) * alpha : av;
833
+ }
834
+ return true;
835
+ }
298
836
  /** Is `collection[id]` visible at the render clock? */
299
837
  has(desc, id) {
300
838
  const coll = this.store.plain[desc.name];
301
839
  if (this.predictor && desc.physics && this.predictor.has(desc.name, id)) return coll.has(id);
302
- if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) return true;
840
+ if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
841
+ return true;
842
+ }
303
843
  if (!desc.interpolate) return coll.has(id);
304
844
  const buffer = this.buffers.get(desc.name)?.get(id);
305
845
  if (!buffer || buffer.frames.length === 0) return coll.has(id);
@@ -345,20 +885,39 @@ var RenderStore = class {
345
885
  if (byId && byId.size === 0) this.buffers.delete(collection);
346
886
  }
347
887
  // -- view -----------------------------------------------------------------
348
- buildView() {
349
- const view = {};
888
+ /** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
889
+ buildViewInto(view) {
890
+ const names = /* @__PURE__ */ new Set();
350
891
  for (const c of this.ext.collections) {
892
+ names.add(c.name);
351
893
  if (c.kind === "entity") {
352
- const facade = withRenderIndexSugar(new RenderCollectionImpl(this, c));
353
- Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
894
+ const existing = this.facades.get(c.name);
895
+ if (existing) {
896
+ existing.retarget(c);
897
+ continue;
898
+ }
899
+ const impl = new RenderCollectionImpl(this, c);
900
+ this.facades.set(c.name, impl);
901
+ const facade = withRenderIndexSugar(impl);
902
+ Object.defineProperty(view, c.name, {
903
+ get: () => facade,
904
+ enumerable: true,
905
+ configurable: true
906
+ });
354
907
  } else {
355
908
  Object.defineProperty(view, c.name, {
356
909
  get: () => this.store.view[c.name],
357
- enumerable: true
910
+ enumerable: true,
911
+ configurable: true
358
912
  });
359
913
  }
360
914
  }
361
- return view;
915
+ for (const name of Object.keys(view)) {
916
+ if (!names.has(name)) {
917
+ delete view[name];
918
+ this.facades.delete(name);
919
+ }
920
+ }
362
921
  }
363
922
  };
364
923
  var RenderCollectionImpl = class {
@@ -368,6 +927,10 @@ var RenderCollectionImpl = class {
368
927
  }
369
928
  render;
370
929
  desc;
930
+ /** D50: point this facade at the same collection in a rebuilt schema. */
931
+ retarget(desc) {
932
+ this.desc = desc;
933
+ }
371
934
  get(id) {
372
935
  return this.render.get(this.desc, id);
373
936
  }
@@ -497,6 +1060,7 @@ var webSocketTransport = {
497
1060
  };
498
1061
 
499
1062
  // src/session.ts
1063
+ var PROFILE_WINDOW_MS = 1e3;
500
1064
  var DEFAULT_WRITE_INTERVAL_MS = 50;
501
1065
  var PING_INTERVAL_MS = 2e3;
502
1066
  var RTT_ALPHA = 0.3;
@@ -513,9 +1077,11 @@ function nameOfCode(code) {
513
1077
  return "E_INTERNAL";
514
1078
  }
515
1079
  }
1080
+ var INTERNAL_SESSION = /* @__PURE__ */ Symbol("irtio.session");
516
1081
  var Session = class {
517
1082
  constructor(options) {
518
1083
  this.options = options;
1084
+ this.schema = options.schema;
519
1085
  this.ext = options.schema ? withBuiltins(options.schema) : relaySchema;
520
1086
  this.rpcSchema = options.schema ?? relaySchema;
521
1087
  this.transport = options.transport ?? webSocketTransport;
@@ -524,52 +1090,95 @@ var Session = class {
524
1090
  this.impls = new Map(Object.entries(options.rpc ?? {}));
525
1091
  this.roomId = options.roomId;
526
1092
  this.role = options.role ?? "";
1093
+ this.ledger = options.profile === true ? new ProfileLedger(this.ext, this.rpcSchema) : void 0;
527
1094
  this.store = new ClientStore(this.ext, () => this.me);
528
1095
  this.render = new RenderStore(
529
1096
  this.ext,
530
1097
  this.store,
531
1098
  () => this.me,
532
1099
  () => this.scheduler.now(),
533
- () => this.interpDelayMs
1100
+ () => this.interpDelayMs,
1101
+ () => this.tickIntervalMs
534
1102
  );
535
1103
  const hasPhysics = this.ext.collections.some(
536
1104
  (c) => c.physics !== void 0
537
1105
  );
538
- if (options.physics && hasPhysics) {
539
- const physics = options.physics;
1106
+ const attach = (predictor) => {
1107
+ if (this.left) return;
1108
+ this.predictor = predictor;
1109
+ this.render.attachPredictor(predictor);
1110
+ predictor.setDrawnReader((desc, id, into) => this.render.drawn(desc, id, into));
1111
+ if (this.joined) {
1112
+ predictor.reset();
1113
+ void predictor.start();
1114
+ }
1115
+ };
1116
+ const physics = options.physics;
1117
+ const physics2d = options.physics2d;
1118
+ if (physics && hasPhysics) {
540
1119
  this.predictionRequested = true;
541
- void import("./physics-H2VDQLAU.js").then(({ PhysicsPredictor }) => {
1120
+ void import("./physics-RT5T36P5.js").then(({ PhysicsPredictor }) => {
542
1121
  if (this.left) return;
543
- const predictor = new PhysicsPredictor(
544
- this.ext,
545
- this.store,
546
- physics,
547
- () => this.me,
548
- () => this.rtt,
549
- () => this.tickIntervalMs
1122
+ attach(
1123
+ new PhysicsPredictor(
1124
+ this.ext,
1125
+ this.store,
1126
+ physics,
1127
+ () => this.me,
1128
+ () => this.rtt,
1129
+ () => this.tickIntervalMs
1130
+ )
1131
+ );
1132
+ });
1133
+ } else if (physics2d && hasPhysics) {
1134
+ this.predictionRequested = true;
1135
+ void import("./physics2d-HOFMWPZV.js").then(({ Physics2dPredictor }) => {
1136
+ if (this.left) return;
1137
+ attach(
1138
+ new Physics2dPredictor(
1139
+ this.ext,
1140
+ this.store,
1141
+ physics2d,
1142
+ () => this.me,
1143
+ () => this.rtt,
1144
+ () => this.tickIntervalMs
1145
+ )
550
1146
  );
551
- this.predictor = predictor;
552
- this.render.attachPredictor(predictor);
553
- if (this.joined) {
554
- predictor.reset();
555
- void predictor.start();
556
- }
557
1147
  });
558
1148
  }
559
1149
  }
560
1150
  options;
1151
+ /**
1152
+ * D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
1153
+ * new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
1154
+ * single assignment below is what actually moves the session onto the new schema.
1155
+ */
561
1156
  ext;
562
1157
  store;
563
1158
  render;
564
- /** `true` when the join passed `physics` and the schema has body-backed collections. */
1159
+ /** `true` when the join passed an engine option and the schema has body-backed collections. */
565
1160
  predictionRequested = false;
566
1161
  /**
567
- * Present once `./physics.js` has loaded (dynamic import the predictor code, like the
568
- * engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
1162
+ * Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
1163
+ * `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
1164
+ * engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
1165
+ * interpolation until then.
569
1166
  */
570
1167
  predictor;
571
- /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
1168
+ /** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
572
1169
  rpcSchema;
1170
+ /**
1171
+ * The BUILDER schema this session is currently on: `options.schema` at construction, then
1172
+ * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
1173
+ * swapped session announces its *current* hash when it reconnects rather than the one it was
1174
+ * born with — otherwise a resume after a swap would be refused as a mismatch.
1175
+ *
1176
+ * D70 made it internally readable rather than private: `buildMessages` reads the declared
1177
+ * shapes off it when the room object is built. Still not on the public `Room` type.
1178
+ */
1179
+ schema;
1180
+ /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
1181
+ schemaSwaps = 0;
573
1182
  transport;
574
1183
  scheduler;
575
1184
  writeIntervalMs;
@@ -587,7 +1196,10 @@ var Session = class {
587
1196
  */
588
1197
  writeTick = 0;
589
1198
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
1199
+ /** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
590
1200
  tickIntervalMs = 0;
1201
+ /** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
1202
+ maxClients = 0;
591
1203
  rtt = 0;
592
1204
  status = "connecting";
593
1205
  socket;
@@ -612,6 +1224,34 @@ var Session = class {
612
1224
  pending = /* @__PURE__ */ new Map();
613
1225
  listeners = /* @__PURE__ */ new Map();
614
1226
  messageListeners = /* @__PURE__ */ new Set();
1227
+ /**
1228
+ * D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
1229
+ *
1230
+ * The isolation clause has a client half as well as a server half. The supervisor guarantees that
1231
+ * a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
1232
+ * *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
1233
+ * receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
1234
+ * the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
1235
+ * and no ordering between two callbacks to get wrong.
1236
+ *
1237
+ * Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
1238
+ * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
1239
+ */
1240
+ voiceListeners = /* @__PURE__ */ new Set();
1241
+ /**
1242
+ * D70: typed-message listeners, by wire index, in their own map for the same structural reason
1243
+ * `voiceListeners` is its own set: a raw `onMessage` callback must never be handed a typed
1244
+ * frame and a typed callback must never be handed raw bytes. Splitting the registries makes
1245
+ * that a fact about the shape of the code rather than a filter somewhere in the fan-out.
1246
+ */
1247
+ typedListeners = /* @__PURE__ */ new Map();
1248
+ /** D70: one warning per session for dropped typed messages — a flood must stay one line. */
1249
+ typedDropWarned = false;
1250
+ /**
1251
+ * D70: what `room.stats.messages` reads. `dropped` is the one worth watching: it counts typed
1252
+ * frames this client could not decode, which is a peer on a schema this one does not have.
1253
+ */
1254
+ messageCounts = { sent: 0, received: 0, dropped: 0 };
615
1255
  cancelFlush;
616
1256
  cancelPing;
617
1257
  cancelRetry;
@@ -680,6 +1320,19 @@ var Session = class {
680
1320
  }
681
1321
  async sendHello(socket) {
682
1322
  let token;
1323
+ let assertion;
1324
+ const suppliedAssertion = this.options.assertion;
1325
+ if (suppliedAssertion !== void 0) {
1326
+ try {
1327
+ assertion = await suppliedAssertion();
1328
+ } catch (err) {
1329
+ this.transportFailed(
1330
+ err instanceof Error ? err : new Error(`identity provider failed: ${String(err)}`)
1331
+ );
1332
+ return;
1333
+ }
1334
+ if (this.socket !== socket || !this.socketOpen || this.left) return;
1335
+ }
683
1336
  const supplied = this.options.token;
684
1337
  if (supplied !== void 0) {
685
1338
  try {
@@ -692,15 +1345,20 @@ var Session = class {
692
1345
  }
693
1346
  if (this.socket !== socket || !this.socketOpen || this.left) return;
694
1347
  }
695
- const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
1348
+ const hash8 = this.schema ? this.schema.hash8 : RELAY_HASH8;
696
1349
  const hello = encodeHello({
697
1350
  protocolVersion: PROTOCOL_VERSION,
698
- credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
1351
+ credential: assertion !== void 0 ? { kind: "assertion", key: this.options.key, token: assertion } : token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
699
1352
  roomId: this.roomId,
700
1353
  schemaHash8: hash8,
701
1354
  ...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
702
1355
  ...this.options.role !== void 0 ? { role: this.options.role } : {},
703
- ...this.options.name !== void 0 ? { name: this.options.name } : {}
1356
+ ...this.options.name !== void 0 ? { name: this.options.name } : {},
1357
+ // D50: this client can rebuild its codec from a SCHEMA frame. Announced unconditionally for
1358
+ // a schema'd session — there is nothing to opt out of, the server still decides, and a
1359
+ // server that predates the bit ignores it (it reads the mask it knows and stops).
1360
+ // A relay session has no schema to swap, so it does not ask.
1361
+ ...this.schema !== void 0 ? { schemaSwap: true } : {}
704
1362
  });
705
1363
  this.send(FrameType.HELLO, hello);
706
1364
  }
@@ -765,10 +1423,43 @@ var Session = class {
765
1423
  // -------------------------------------------------------------------------
766
1424
  // Frames
767
1425
  // -------------------------------------------------------------------------
1426
+ /**
1427
+ * D65: the session's bandwidth ledger, present only when the join asked for one. A second
1428
+ * consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
1429
+ * compose rather than compete.
1430
+ */
1431
+ ledger;
1432
+ /** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
1433
+ profileWindow;
1434
+ /**
1435
+ * D65: bytes per second over a rolling window of about a second, measured on the scheduler's
1436
+ * clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
1437
+ *
1438
+ * The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
1439
+ * one-second window, and a reader that never calls costs nothing. A read sooner than a second
1440
+ * after the last one is extrapolated from the partial window rather than returning zeros.
1441
+ */
1442
+ profilePerSecond() {
1443
+ const ledger = this.ledger;
1444
+ if (!ledger) return EMPTY_PROFILE;
1445
+ const now = this.scheduler.now();
1446
+ const snap = ledger.snapshot();
1447
+ const previous = this.profileWindow;
1448
+ if (!previous) {
1449
+ this.profileWindow = { at: now, snap };
1450
+ return EMPTY_PROFILE;
1451
+ }
1452
+ const elapsed = now - previous.at;
1453
+ if (elapsed <= 0) return EMPTY_PROFILE;
1454
+ const window = diffProfiles(previous.snap, snap);
1455
+ if (elapsed >= PROFILE_WINDOW_MS) this.profileWindow = { at: now, snap };
1456
+ return scaleProfile(window, elapsed / PROFILE_WINDOW_MS);
1457
+ }
768
1458
  send(type, payload) {
769
1459
  if (!this.socketOpen || !this.socket) return;
770
1460
  const frame = encodeFrame(type, payload);
771
1461
  this.options.onFrame?.("out", type, frame);
1462
+ this.ledger?.attribute("out", frame, { spatialChurn: true });
772
1463
  this.socket.send(frame);
773
1464
  }
774
1465
  onFrame(bytes) {
@@ -783,6 +1474,7 @@ var Session = class {
783
1474
  return;
784
1475
  }
785
1476
  this.options.onFrame?.("in", type, bytes);
1477
+ this.ledger?.attribute("in", bytes, { spatialChurn: true });
786
1478
  try {
787
1479
  switch (type) {
788
1480
  case FrameType.WELCOME:
@@ -806,6 +1498,9 @@ var Session = class {
806
1498
  case FrameType.REPLY:
807
1499
  this.onReply(payload);
808
1500
  return;
1501
+ case FrameType.SCHEMA:
1502
+ this.onSchema(payload);
1503
+ return;
809
1504
  default:
810
1505
  if (type === FrameType.MSG) this.onMsg(payload);
811
1506
  return;
@@ -822,6 +1517,72 @@ var Session = class {
822
1517
  fatal: false
823
1518
  });
824
1519
  }
1520
+ /**
1521
+ * D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
1522
+ * the old worker has stopped sending and before the resync WELCOME, so this session can stay
1523
+ * open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
1524
+ *
1525
+ * A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
1526
+ * the session on the old schema with a resync WELCOME about to arrive under the new one, which
1527
+ * it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
1528
+ * it anyway under the wrong descriptor, and a misdecode is silent.
1529
+ */
1530
+ onSchema(payload) {
1531
+ const canonical = readSchemaPayload(payload);
1532
+ this.swapSchema(canonical);
1533
+ }
1534
+ /**
1535
+ * The one entry point for a mid-session schema change. Internal: not part of the public client
1536
+ * API this release, and not called from anywhere but `onSchema`.
1537
+ *
1538
+ * **What is rebuilt.** Every schema-derived handle in the client, top down:
1539
+ *
1540
+ * - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
1541
+ * `decodeSnapshot` and rpc-id lookup reads through;
1542
+ * - `descsByName`, the correction path's collection index, invalidated so it re-derives;
1543
+ * - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
1544
+ * facades behind `room.state` — retargeted, not replaced;
1545
+ * - `RenderStore`: the same, plus every interpolation buffer;
1546
+ * - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
1547
+ *
1548
+ * **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
1549
+ * by the old descriptors and has no correct reading under the new ones. The resync WELCOME
1550
+ * arriving immediately after re-establishes all of it, which is what makes dropping cheap:
1551
+ *
1552
+ * - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
1553
+ * - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
1554
+ * resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
1555
+ * indexed against descriptors that no longer exist;
1556
+ * - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
1557
+ * pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
1558
+ * the WELCOME;
1559
+ * - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
1560
+ *
1561
+ * **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
1562
+ * captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
1563
+ * was produced by a room that had those params in hand. So a call issued before the swap keeps
1564
+ * decoding its reply under the schema it was issued with, and resolves normally afterwards. This
1565
+ * costs nothing to implement — the descriptor is already captured per call rather than looked up
1566
+ * at reply time — and it is the honest semantics: the call did happen, under the old contract.
1567
+ *
1568
+ * The narrow case this leaves is a REPLY whose *shape* changed additively between the two
1569
+ * schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
1570
+ * which is exactly what an appended field means. A breaking change to an RPC never reaches here:
1571
+ * it classifies breaking and the session is closed instead.
1572
+ */
1573
+ swapSchema(canonical) {
1574
+ const next = schemaFromCanonical(canonical);
1575
+ if (this.schema && next.hash === this.schema.hash) return;
1576
+ this.schema = next;
1577
+ this.ext = withBuiltins(next);
1578
+ this.rpcSchema = next;
1579
+ this.descsByName = void 0;
1580
+ this.store.swapSchema(this.ext);
1581
+ this.render.swapSchema(this.ext);
1582
+ this.ledger?.swapSchema(this.ext, this.rpcSchema);
1583
+ this.predictor?.swapSchema(this.ext);
1584
+ this.schemaSwaps++;
1585
+ }
825
1586
  onWelcome(payload) {
826
1587
  const welcome = decodeWelcome(payload);
827
1588
  const pending = this.store.capturePendingWrites();
@@ -829,11 +1590,13 @@ var Session = class {
829
1590
  this.role = welcome.role;
830
1591
  this.tick = welcome.tick;
831
1592
  this.resumeToken = welcome.resumeToken;
832
- this.tickIntervalMs = welcome.tickIntervalMs;
1593
+ this.tickIntervalMs = welcome.tickRate > 0 ? 1e3 / welcome.tickRate : 0;
1594
+ this.maxClients = welcome.maxClients;
833
1595
  if (welcome.roomId !== "") this.roomId = welcome.roomId;
834
1596
  this.store.loadSnapshot(welcome.snapshot);
835
1597
  this.store.applyPendingWrites(pending);
836
1598
  this.render.seedSnapshot();
1599
+ this.emit("clients", this.clients);
837
1600
  if (this.predictor) {
838
1601
  this.predictor.reset();
839
1602
  void this.predictor.start();
@@ -859,6 +1622,9 @@ var Session = class {
859
1622
  this.predictor.noteAuthority(delta.tick);
860
1623
  this.predictor.frame(this.scheduler.now());
861
1624
  }
1625
+ if (delta.collections.some((dc) => dc.name === "clients")) {
1626
+ this.emit("clients", this.clients);
1627
+ }
862
1628
  }
863
1629
  onCorrect(payload) {
864
1630
  const r = new ByteReader(payload);
@@ -897,6 +1663,9 @@ var Session = class {
897
1663
  this.predictor.noteAuthority(delta.tick);
898
1664
  this.predictor.frame(this.scheduler.now());
899
1665
  }
1666
+ if (delta.collections.some((dc) => dc.name === "clients")) {
1667
+ this.emit("clients", this.clients);
1668
+ }
900
1669
  }
901
1670
  descsByName;
902
1671
  descOfCollection(name) {
@@ -942,14 +1711,66 @@ var Session = class {
942
1711
  const pong = decodePong(payload);
943
1712
  const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
944
1713
  this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
1714
+ this.emit("rtt", this.rtt);
945
1715
  if (pong.serverTick > this.tick) this.tick = pong.serverTick;
946
1716
  }
947
1717
  onMsg(payload) {
948
- const msg = decodeMsg(payload);
949
- const from = msg.target.kind === "client" ? msg.target.clientId : "server";
1718
+ let msg;
1719
+ try {
1720
+ msg = decodeMsg(payload);
1721
+ } catch (err) {
1722
+ this.dropTypedMessage(err);
1723
+ return;
1724
+ }
950
1725
  const bytes = msg.payload.slice();
1726
+ if (msg.target.kind === "voice") {
1727
+ for (const cb of [...this.voiceListeners]) cb(bytes);
1728
+ return;
1729
+ }
1730
+ const from = msg.target.kind === "client" ? msg.target.clientId : "server";
1731
+ if (msg.typed) {
1732
+ this.deliverTyped(msg.typed.index, from, bytes);
1733
+ return;
1734
+ }
1735
+ this.messageCounts.received++;
951
1736
  for (const cb of [...this.messageListeners]) cb(from, bytes);
952
1737
  }
1738
+ /**
1739
+ * D70: decode one typed payload against the current schema and hand it to that shape's
1740
+ * listeners.
1741
+ *
1742
+ * Everything a hostile peer can do here ends in the same place: a counter and a dropped frame.
1743
+ * An index past the schema's list, a truncated payload, an oversize `str`, an over-max `list`,
1744
+ * an unknown enum member, or a well-formed value of the wrong shape — each is caught, none
1745
+ * throws out of the frame loop, none closes the socket, and the next frame is delivered
1746
+ * normally. The `console.warn` fires once per session so a flood stays one line.
1747
+ */
1748
+ deliverTyped(index, from, bytes) {
1749
+ const desc = this.schema?.messages?.[index];
1750
+ if (!desc) {
1751
+ this.dropTypedMessage(new Error(`no message with index ${index} in this schema`));
1752
+ return;
1753
+ }
1754
+ let value;
1755
+ try {
1756
+ value = decodeFields(desc.fields, bytes);
1757
+ } catch (err) {
1758
+ this.dropTypedMessage(err);
1759
+ return;
1760
+ }
1761
+ this.messageCounts.received++;
1762
+ const set = this.typedListeners.get(index);
1763
+ if (!set) return;
1764
+ for (const cb of [...set]) cb(from, value);
1765
+ }
1766
+ dropTypedMessage(err) {
1767
+ this.messageCounts.dropped++;
1768
+ if (this.typedDropWarned) return;
1769
+ this.typedDropWarned = true;
1770
+ console.warn(
1771
+ `irtio: dropped a typed message this client could not read (${err instanceof Error ? err.message : String(err)}). Read room.stats.messages.dropped for the count; further drops are silent.`
1772
+ );
1773
+ }
953
1774
  // -------------------------------------------------------------------------
954
1775
  // Timers
955
1776
  // -------------------------------------------------------------------------
@@ -1020,7 +1841,7 @@ var Session = class {
1020
1841
  let encoded;
1021
1842
  try {
1022
1843
  desc = this.descOf(name);
1023
- encoded = encodeFields(desc.params, params);
1844
+ encoded = encodeFields2(desc.params, params);
1024
1845
  } catch (err) {
1025
1846
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1026
1847
  }
@@ -1041,7 +1862,10 @@ var Session = class {
1041
1862
  reject,
1042
1863
  cancelTimeout
1043
1864
  });
1044
- this.send(FrameType.CALL, encodeCall({ reqId, rpcId: desc.index, params: encoded }));
1865
+ this.send(
1866
+ FrameType.CALL,
1867
+ encodeCall({ reqId, rpcId: desc.index, clientTick: this.tick, params: encoded })
1868
+ );
1045
1869
  });
1046
1870
  }
1047
1871
  async requestOwnership(entity, id) {
@@ -1115,7 +1939,7 @@ var Session = class {
1115
1939
  replyOk(desc, reqId, result) {
1116
1940
  let bytes;
1117
1941
  try {
1118
- bytes = desc.returns ? encodeFields(desc.returns, result ?? {}) : new Uint8Array(0);
1942
+ bytes = desc.returns ? encodeFields2(desc.returns, result ?? {}) : new Uint8Array(0);
1119
1943
  } catch (err) {
1120
1944
  this.replyError(reqId, err instanceof Error ? err.message : String(err));
1121
1945
  return;
@@ -1137,6 +1961,7 @@ var Session = class {
1137
1961
  // -------------------------------------------------------------------------
1138
1962
  message(target, bytes) {
1139
1963
  const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
1964
+ this.messageCounts.sent++;
1140
1965
  this.send(FrameType.MSG, encodeMsg({ target: wire, payload: bytes }));
1141
1966
  }
1142
1967
  onMessage(cb) {
@@ -1145,6 +1970,46 @@ var Session = class {
1145
1970
  this.messageListeners.delete(cb);
1146
1971
  };
1147
1972
  }
1973
+ /**
1974
+ * D70: send one typed message. `payload` is already `encodeFields`'d by the caller
1975
+ * (`buildMessages`), which is where the shape and the throw on a bad value belong.
1976
+ */
1977
+ typedMessage(index, target, payload) {
1978
+ const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
1979
+ this.messageCounts.sent++;
1980
+ this.send(FrameType.MSG, encodeMsg({ target: wire, payload, typed: { index } }));
1981
+ }
1982
+ /** D70: subscribe to one message shape by wire index. Raw listeners never see these frames. */
1983
+ onTypedMessage(index, cb) {
1984
+ let set = this.typedListeners.get(index);
1985
+ if (!set) {
1986
+ set = /* @__PURE__ */ new Set();
1987
+ this.typedListeners.set(index, set);
1988
+ }
1989
+ set.add(cb);
1990
+ return () => {
1991
+ set?.delete(cb);
1992
+ };
1993
+ }
1994
+ /**
1995
+ * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
1996
+ * so it is not on the `Room` type and a game cannot call it.
1997
+ *
1998
+ * This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
1999
+ * purpose (that is the third of the three isolation mechanisms), and widening it so that this
2000
+ * method could share the mapping would delete the mechanism to save four lines. Building the
2001
+ * `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
2002
+ */
2003
+ sendVoice(bytes) {
2004
+ this.send(FrameType.MSG, encodeMsg({ target: { kind: "voice" }, payload: bytes }));
2005
+ }
2006
+ /** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
2007
+ onVoice(cb) {
2008
+ this.voiceListeners.add(cb);
2009
+ return () => {
2010
+ this.voiceListeners.delete(cb);
2011
+ };
2012
+ }
1148
2013
  get clients() {
1149
2014
  const coll = this.store.plain.clients;
1150
2015
  if (!coll) return [];
@@ -1187,6 +2052,458 @@ function resolveKey(explicit, schema, url) {
1187
2052
  );
1188
2053
  }
1189
2054
 
2055
+ // src/match.ts
2056
+ var MatchError = class extends Error {
2057
+ constructor(code, message) {
2058
+ super(message);
2059
+ this.code = code;
2060
+ }
2061
+ code;
2062
+ name = "MatchError";
2063
+ };
2064
+ async function findMatch(project, options = {}) {
2065
+ const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
2066
+ const fetchImpl = options.fetch ?? fetch;
2067
+ let res;
2068
+ try {
2069
+ res = await fetchImpl(`${controlUrl}/match`, {
2070
+ method: "POST",
2071
+ headers: { "content-type": "application/json" },
2072
+ body: JSON.stringify({
2073
+ project,
2074
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2075
+ ...options.party !== void 0 ? { party: options.party } : {},
2076
+ ...options.identity !== void 0 ? { identity: options.identity } : {},
2077
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
2078
+ })
2079
+ });
2080
+ } catch (err) {
2081
+ throw new MatchError(
2082
+ "E_MATCH_UNREACHABLE",
2083
+ `cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
2084
+ );
2085
+ }
2086
+ const text = await res.text().catch(() => "");
2087
+ let body = {};
2088
+ try {
2089
+ body = JSON.parse(text);
2090
+ } catch {
2091
+ }
2092
+ if (!res.ok) {
2093
+ throw new MatchError(
2094
+ typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
2095
+ typeof body.message === "string" ? body.message : `match failed with status ${res.status}`
2096
+ );
2097
+ }
2098
+ if (body.status === "timeout") {
2099
+ throw new MatchError(
2100
+ "E_NO_MATCH",
2101
+ `no match in the ${String(body.queue)} queue: ${String(body.waiting)} of ${String(
2102
+ body.size
2103
+ )} players were waiting. Try again, or bring a friend.`
2104
+ );
2105
+ }
2106
+ if (body.status !== "matched" || typeof body.room !== "string") {
2107
+ throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
2108
+ }
2109
+ return {
2110
+ room: body.room,
2111
+ queue: typeof body.queue === "string" ? body.queue : "default",
2112
+ seat: typeof body.seat === "number" ? body.seat : 0,
2113
+ size: typeof body.size === "number" ? body.size : 0,
2114
+ backfill: body.backfill === true
2115
+ };
2116
+ }
2117
+ async function createParty(project, options) {
2118
+ const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
2119
+ const fetchImpl = options.fetch ?? fetch;
2120
+ let res;
2121
+ try {
2122
+ res = await fetchImpl(`${controlUrl}/match/party`, {
2123
+ method: "POST",
2124
+ headers: { "content-type": "application/json" },
2125
+ body: JSON.stringify({ project, size: options.size })
2126
+ });
2127
+ } catch (err) {
2128
+ throw new MatchError(
2129
+ "E_MATCH_UNREACHABLE",
2130
+ `cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
2131
+ );
2132
+ }
2133
+ const text = await res.text().catch(() => "");
2134
+ let body = {};
2135
+ try {
2136
+ body = JSON.parse(text);
2137
+ } catch {
2138
+ }
2139
+ if (!res.ok) {
2140
+ throw new MatchError(
2141
+ typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
2142
+ typeof body.message === "string" ? body.message : `party failed with status ${res.status}`
2143
+ );
2144
+ }
2145
+ if (typeof body.party !== "string") {
2146
+ throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
2147
+ }
2148
+ return {
2149
+ party: body.party,
2150
+ expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 0
2151
+ };
2152
+ }
2153
+ async function matchRoom(schema, options = {}) {
2154
+ const project = schema.project;
2155
+ const key = options.key ?? (typeof project === "string" ? project : void 0);
2156
+ if (key === void 0 || key === "") {
2157
+ throw new MatchError(
2158
+ "E_MATCH_NO_PROJECT",
2159
+ "matchRoom needs a project key: build your schema with `irtio` so it carries one, or pass { key }"
2160
+ );
2161
+ }
2162
+ const identity = options.identity === true ? new Identity({
2163
+ project: key,
2164
+ controlUrl: options.controlUrl,
2165
+ fetch: options.fetch
2166
+ }) : void 0;
2167
+ const ticket = await findMatch(key, {
2168
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2169
+ ...options.party !== void 0 ? { party: options.party } : {},
2170
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
2171
+ ...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
2172
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {},
2173
+ ...identity !== void 0 ? { identity: await identity.ensure() } : {}
2174
+ });
2175
+ try {
2176
+ return await joinRoom(schema, { ...options, room: ticket.room });
2177
+ } catch (err) {
2178
+ if (!ticket.backfill || !isRoomFull(err)) throw err;
2179
+ const second = await findMatch(key, {
2180
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2181
+ ...options.party !== void 0 ? { party: options.party } : {},
2182
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
2183
+ ...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
2184
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {},
2185
+ ...identity !== void 0 ? { identity: await identity.ensure() } : {}
2186
+ });
2187
+ return joinRoom(schema, { ...options, room: second.room });
2188
+ }
2189
+ }
2190
+ function isRoomFull(err) {
2191
+ const message = err instanceof Error ? err.message : String(err);
2192
+ return message.startsWith("E_ROOM_FULL");
2193
+ }
2194
+
2195
+ // src/voice.ts
2196
+ import {
2197
+ decodeVoiceMessage,
2198
+ encodeVoiceMessage
2199
+ } from "@irtio/protocol";
2200
+ var INTERNAL_VOICE_TRACKS = /* @__PURE__ */ Symbol.for("irtio.voice.tracks");
2201
+ function browserFactory() {
2202
+ return {
2203
+ createDevice: async () => {
2204
+ const mod = await import("mediasoup-client");
2205
+ return new mod.Device();
2206
+ },
2207
+ getMicTrack: async () => {
2208
+ const media = globalThis.navigator?.mediaDevices;
2209
+ if (!media?.getUserMedia) {
2210
+ throw new Error("irtio voice: getUserMedia is unavailable (needs a secure context)");
2211
+ }
2212
+ const stream = await media.getUserMedia({
2213
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
2214
+ video: false
2215
+ });
2216
+ const track = stream.getAudioTracks()[0];
2217
+ if (!track) throw new Error("irtio voice: no audio track from getUserMedia");
2218
+ return track;
2219
+ },
2220
+ attach: (peerId, track) => {
2221
+ const doc = globalThis.document;
2222
+ if (!doc) {
2223
+ return {
2224
+ detach: () => track.stop(),
2225
+ setVolume: () => void 0,
2226
+ setMuted: () => void 0
2227
+ };
2228
+ }
2229
+ const el = doc.createElement("audio");
2230
+ el.autoplay = true;
2231
+ el.setAttribute("playsinline", "");
2232
+ el.dataset.irtioVoicePeer = peerId;
2233
+ el.style.display = "none";
2234
+ el.srcObject = new MediaStream([track]);
2235
+ doc.body.appendChild(el);
2236
+ return {
2237
+ detach: () => {
2238
+ el.srcObject = null;
2239
+ el.remove();
2240
+ track.stop();
2241
+ },
2242
+ setVolume: (volume) => {
2243
+ el.volume = volume;
2244
+ },
2245
+ setMuted: (muted) => {
2246
+ el.muted = muted;
2247
+ }
2248
+ };
2249
+ }
2250
+ };
2251
+ }
2252
+ function asPlayback(result) {
2253
+ return typeof result === "function" ? { detach: result, setVolume: () => void 0, setMuted: () => void 0 } : result;
2254
+ }
2255
+ async function joinVoice(room, options = {}) {
2256
+ const internals = {
2257
+ ...browserFactory(),
2258
+ ...options.__transportFactory
2259
+ };
2260
+ const session = room[INTERNAL_SESSION];
2261
+ if (!session) {
2262
+ throw new Error("irtio voice: joinVoice needs a Room from joinRoom()");
2263
+ }
2264
+ if (options.positional === true) {
2265
+ console.warn(
2266
+ "irtio voice: positional audio is not in this release; joinVoice({ positional: true }) produces ordinary flat audio. The option is accepted so calling code keeps working."
2267
+ );
2268
+ }
2269
+ const listeners = /* @__PURE__ */ new Map();
2270
+ const emit = (event, value) => {
2271
+ for (const cb of [...listeners.get(event) ?? []]) cb(value);
2272
+ };
2273
+ let changeQueued = false;
2274
+ const changed = () => {
2275
+ if (changeQueued || closed) return;
2276
+ changeQueued = true;
2277
+ queueMicrotask(() => {
2278
+ changeQueued = false;
2279
+ if (!closed) emit("peers-changed", void 0);
2280
+ });
2281
+ };
2282
+ const waiters = [];
2283
+ const peers = /* @__PURE__ */ new Map();
2284
+ const playbacks = /* @__PURE__ */ new Map();
2285
+ const localPrefs = /* @__PURE__ */ new Map();
2286
+ const prefsFor = (peerId) => {
2287
+ let p = localPrefs.get(peerId);
2288
+ if (!p) {
2289
+ p = { mutedLocally: false, volume: 1 };
2290
+ localPrefs.set(peerId, p);
2291
+ }
2292
+ return p;
2293
+ };
2294
+ const consumed = /* @__PURE__ */ new Set();
2295
+ let closed = false;
2296
+ let muted = false;
2297
+ let device;
2298
+ let sendTransport;
2299
+ let recvTransport;
2300
+ let micTrack;
2301
+ let producer;
2302
+ const signal = (m) => session.sendVoice(encodeVoiceMessage(m));
2303
+ function expect(match) {
2304
+ return new Promise((resolve, reject) => {
2305
+ waiters.push({ match, resolve, reject });
2306
+ });
2307
+ }
2308
+ const unsubscribe = session.onVoice((bytes) => {
2309
+ const m = decodeVoiceMessage(bytes);
2310
+ if (!m) return;
2311
+ if (m.t === "error") {
2312
+ const err = new Error(`irtio voice: ${m.code} ${m.message}`);
2313
+ for (const w of waiters.splice(0)) w.reject(err);
2314
+ emit("error", { code: m.code, message: m.message });
2315
+ return;
2316
+ }
2317
+ for (let i = 0; i < waiters.length; i++) {
2318
+ const w = waiters[i];
2319
+ if (w.match(m)) {
2320
+ waiters.splice(i, 1);
2321
+ w.resolve(m);
2322
+ return;
2323
+ }
2324
+ }
2325
+ switch (m.t) {
2326
+ case "peer-joined": {
2327
+ const known = peers.has(m.peer.peerId);
2328
+ peers.set(m.peer.peerId, {
2329
+ muted: m.peer.muted,
2330
+ ...m.peer.producerId !== void 0 ? { producerId: m.peer.producerId } : {}
2331
+ });
2332
+ if (!known) emit("peer-joined", m.peer.peerId);
2333
+ changed();
2334
+ if (m.peer.producerId !== void 0) void consumePeer(m.peer.producerId);
2335
+ return;
2336
+ }
2337
+ case "peer-left": {
2338
+ peers.delete(m.peerId);
2339
+ playbacks.get(m.peerId)?.playback.detach();
2340
+ playbacks.delete(m.peerId);
2341
+ emit("peer-left", m.peerId);
2342
+ changed();
2343
+ return;
2344
+ }
2345
+ case "peer-muted": {
2346
+ const p = peers.get(m.peerId);
2347
+ if (p) p.muted = m.muted;
2348
+ emit("peer-muted", { peerId: m.peerId, muted: m.muted });
2349
+ changed();
2350
+ return;
2351
+ }
2352
+ default:
2353
+ return;
2354
+ }
2355
+ });
2356
+ async function consumePeer(producerId) {
2357
+ if (closed || consumed.has(producerId) || !recvTransport || !device) return;
2358
+ consumed.add(producerId);
2359
+ try {
2360
+ signal({ t: "consume", producerId, rtpCapabilities: device.rtpCapabilities });
2361
+ const reply = await expect(
2362
+ (m) => m.t === "consumed" && m.producerId === producerId
2363
+ );
2364
+ if (closed) return;
2365
+ const consumer = await recvTransport.consume({
2366
+ id: reply.consumerId,
2367
+ producerId: reply.producerId,
2368
+ kind: "audio",
2369
+ rtpParameters: reply.rtpParameters
2370
+ });
2371
+ const peerId = reply.peerId || producerId;
2372
+ playbacks.get(peerId)?.playback.detach();
2373
+ const playback = asPlayback(internals.attach(peerId, consumer.track));
2374
+ playbacks.set(peerId, { playback, track: consumer.track });
2375
+ const prefs = prefsFor(peerId);
2376
+ playback.setVolume(prefs.volume);
2377
+ playback.setMuted(prefs.mutedLocally);
2378
+ signal({ t: "resume", consumerId: reply.consumerId });
2379
+ changed();
2380
+ } catch (err) {
2381
+ consumed.delete(producerId);
2382
+ emit("error", {
2383
+ code: "E_VOICE_CONSUME",
2384
+ message: err instanceof Error ? err.message : String(err)
2385
+ });
2386
+ }
2387
+ }
2388
+ async function teardown() {
2389
+ if (closed) return;
2390
+ closed = true;
2391
+ for (const w of waiters.splice(0)) w.reject(new Error("irtio voice: left the call"));
2392
+ for (const p of playbacks.values()) p.playback.detach();
2393
+ playbacks.clear();
2394
+ peers.clear();
2395
+ producer?.close();
2396
+ micTrack?.stop();
2397
+ sendTransport?.close();
2398
+ recvTransport?.close();
2399
+ unsubscribe();
2400
+ }
2401
+ try {
2402
+ micTrack = await internals.getMicTrack();
2403
+ device = await internals.createDevice();
2404
+ signal({ t: "join", rtpCapabilities: {} });
2405
+ const joined = await expect(
2406
+ (m) => m.t === "joined"
2407
+ );
2408
+ await device.load({ routerRtpCapabilities: joined.routerRtpCapabilities });
2409
+ sendTransport = device.createSendTransport(joined.sendTransport);
2410
+ recvTransport = device.createRecvTransport(joined.recvTransport);
2411
+ for (const t of [sendTransport, recvTransport]) {
2412
+ const transport = t;
2413
+ transport.on("connect", ({ dtlsParameters }, callback, errback) => {
2414
+ signal({ t: "connect", transportId: transport.id, dtlsParameters });
2415
+ expect((m) => m.t === "connected" && m.transportId === transport.id).then(
2416
+ () => callback(),
2417
+ (e) => errback(e)
2418
+ );
2419
+ });
2420
+ }
2421
+ sendTransport.on("produce", ({ rtpParameters }, callback, errback) => {
2422
+ signal({
2423
+ t: "produce",
2424
+ transportId: sendTransport.id,
2425
+ kind: "audio",
2426
+ rtpParameters
2427
+ });
2428
+ expect((m) => m.t === "produced").then(
2429
+ (m) => callback({ id: m.producerId }),
2430
+ (e) => errback(e)
2431
+ );
2432
+ });
2433
+ producer = await sendTransport.produce({ track: micTrack });
2434
+ for (const peer of joined.peers) {
2435
+ peers.set(peer.peerId, {
2436
+ muted: peer.muted,
2437
+ ...peer.producerId !== void 0 ? { producerId: peer.producerId } : {}
2438
+ });
2439
+ if (peer.producerId !== void 0) void consumePeer(peer.producerId);
2440
+ }
2441
+ changed();
2442
+ } catch (err) {
2443
+ await teardown();
2444
+ throw err instanceof Error ? err : new Error(String(err));
2445
+ }
2446
+ const handle = {
2447
+ mute(next) {
2448
+ if (closed || next === muted) return;
2449
+ muted = next;
2450
+ signal({ t: "mute", muted: next });
2451
+ changed();
2452
+ },
2453
+ setPeerVolume(peerId, volume) {
2454
+ if (closed) return;
2455
+ const v = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1;
2456
+ const prefs = prefsFor(peerId);
2457
+ if (prefs.volume === v) return;
2458
+ prefs.volume = v;
2459
+ playbacks.get(peerId)?.playback.setVolume(v);
2460
+ changed();
2461
+ },
2462
+ mutePeer(peerId, mutedLocally) {
2463
+ if (closed) return;
2464
+ const prefs = prefsFor(peerId);
2465
+ if (prefs.mutedLocally === mutedLocally) return;
2466
+ prefs.mutedLocally = mutedLocally;
2467
+ playbacks.get(peerId)?.playback.setMuted(mutedLocally);
2468
+ changed();
2469
+ },
2470
+ peerState(peerId) {
2471
+ const peer = peers.get(peerId);
2472
+ if (!peer) return void 0;
2473
+ const prefs = prefsFor(peerId);
2474
+ return { muted: peer.muted, mutedLocally: prefs.mutedLocally, volume: prefs.volume };
2475
+ },
2476
+ get muted() {
2477
+ return muted;
2478
+ },
2479
+ get peers() {
2480
+ return [...peers.keys()];
2481
+ },
2482
+ async leave() {
2483
+ if (closed) return;
2484
+ signal({ t: "leave" });
2485
+ await teardown();
2486
+ },
2487
+ on(event, cb) {
2488
+ let set = listeners.get(event);
2489
+ if (!set) {
2490
+ set = /* @__PURE__ */ new Set();
2491
+ listeners.set(event, set);
2492
+ }
2493
+ set.add(cb);
2494
+ return () => {
2495
+ set.delete(cb);
2496
+ };
2497
+ }
2498
+ };
2499
+ const tracks = {
2500
+ peerTrack: (peerId) => playbacks.get(peerId)?.track,
2501
+ micTrack: () => micTrack
2502
+ };
2503
+ Object.defineProperty(handle, INTERNAL_VOICE_TRACKS, { value: tracks, enumerable: false });
2504
+ return handle;
2505
+ }
2506
+
1190
2507
  // src/index.ts
1191
2508
  function callProxy(session) {
1192
2509
  const cache = /* @__PURE__ */ new Map();
@@ -1203,11 +2520,22 @@ function callProxy(session) {
1203
2520
  });
1204
2521
  }
1205
2522
  async function joinRoom(schema, options = {}) {
2523
+ if (options.physics !== void 0 && options.physics2d !== void 0) {
2524
+ throw new Error(
2525
+ "irtio: joinRoom was given both { physics } and { physics2d }. A room runs one engine and the client predicts with that one, so pass the option matching the engine your room config declares."
2526
+ );
2527
+ }
1206
2528
  const url = resolveUrl(options.url, options.region);
1207
2529
  const key = resolveKey(options.key, schema, url);
1208
2530
  const explicitRoom = options.room !== void 0;
1209
2531
  const fromLocation = !explicitRoom && currentLocation() !== void 0;
1210
2532
  const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
2533
+ const identity = options.identity === true ? new Identity({ project: key, controlUrl: options.controlUrl }) : options.identity instanceof Identity ? options.identity : void 0;
2534
+ if (identity !== void 0 && options.token !== void 0) {
2535
+ throw new Error(
2536
+ "irtio: joinRoom was given both { token } and { identity }. A join asserts one identity \u2014 pass your own JWT, or the platform identity, not both."
2537
+ );
2538
+ }
1211
2539
  const session = new Session({
1212
2540
  schema,
1213
2541
  url,
@@ -1217,13 +2545,16 @@ async function joinRoom(schema, options = {}) {
1217
2545
  role: options.role,
1218
2546
  name: options.name,
1219
2547
  token: options.token,
2548
+ ...identity !== void 0 ? { assertion: () => identity.assertion() } : {},
1220
2549
  rpc: options.rpc,
1221
2550
  writeIntervalMs: options.writeIntervalMs,
1222
2551
  interpDelayMs: options.interpDelayMs,
1223
2552
  physics: options.physics,
2553
+ physics2d: options.physics2d,
1224
2554
  transport: options.transport,
1225
2555
  scheduler: options.scheduler,
1226
2556
  onFrame: options.onFrame,
2557
+ profile: options.profile,
1227
2558
  onStatus: options.onStatus
1228
2559
  });
1229
2560
  await session.start();
@@ -1231,6 +2562,8 @@ async function joinRoom(schema, options = {}) {
1231
2562
  }
1232
2563
  function makeRoom(session) {
1233
2564
  const call = callProxy(session);
2565
+ const messages = buildMessages(session.schema, session);
2566
+ const stats = { messages: session.messageCounts };
1234
2567
  const room = {
1235
2568
  get me() {
1236
2569
  return session.me;
@@ -1244,6 +2577,9 @@ function makeRoom(session) {
1244
2577
  get tick() {
1245
2578
  return session.tick;
1246
2579
  },
2580
+ get maxClients() {
2581
+ return session.maxClients;
2582
+ },
1247
2583
  get status() {
1248
2584
  return session.status;
1249
2585
  },
@@ -1259,12 +2595,20 @@ function makeRoom(session) {
1259
2595
  get clients() {
1260
2596
  return session.clients;
1261
2597
  },
2598
+ ...session.ledger ? {
2599
+ profile: {
2600
+ total: () => session.ledger?.snapshot() ?? EMPTY_PROFILE2,
2601
+ perSecond: () => session.profilePerSecond()
2602
+ }
2603
+ } : {},
1262
2604
  ...session.predictionRequested ? {
1263
2605
  prediction: {
1264
2606
  get active() {
1265
2607
  return session.predictor?.ready ?? false;
1266
2608
  },
1267
2609
  predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
2610
+ // ---- M6 lane D: proxies ----
2611
+ proxied: (collection, id) => session.predictor?.hasProxy(collection, id) ?? false,
1268
2612
  get stats() {
1269
2613
  return session.predictor?.stats ?? emptyPredictionStats();
1270
2614
  }
@@ -1274,10 +2618,13 @@ function makeRoom(session) {
1274
2618
  requestOwnership: (entity, id) => session.requestOwnership(entity, id),
1275
2619
  message: (target, bytes) => session.message(target, bytes),
1276
2620
  onMessage: (cb) => session.onMessage(cb),
2621
+ messages,
2622
+ stats,
1277
2623
  on: (event, cb) => session.on(event, cb),
1278
2624
  flush: () => session.flush(),
1279
2625
  leave: () => session.leave()
1280
2626
  };
2627
+ Object.defineProperty(room, INTERNAL_SESSION, { value: session, enumerable: false });
1281
2628
  return room;
1282
2629
  }
1283
2630
  async function joinRelay(options = {}) {
@@ -1286,7 +2633,11 @@ async function joinRelay(options = {}) {
1286
2633
  const fromLocation = !explicitRoom && currentLocation() !== void 0;
1287
2634
  const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
1288
2635
  const session = new Session({
1289
- schema: void 0,
2636
+ // ---- M6 lane C: typed messages ----
2637
+ // D70: a relay join may now bring the project's deployed schema. This is a type change and a
2638
+ // hash change, not a transport change: the session already chose between the builder's schema
2639
+ // and `relaySchema`, and already put `schema.hash8` or `RELAY_HASH8` in the HELLO.
2640
+ schema: options.schema,
1290
2641
  url,
1291
2642
  key: options.key ?? resolveKey(void 0, void 0, url),
1292
2643
  roomId,
@@ -1306,6 +2657,9 @@ async function joinRelay(options = {}) {
1306
2657
  get id() {
1307
2658
  return session.roomId;
1308
2659
  },
2660
+ get maxClients() {
2661
+ return session.maxClients;
2662
+ },
1309
2663
  get link() {
1310
2664
  return session.link;
1311
2665
  },
@@ -1320,18 +2674,29 @@ async function joinRelay(options = {}) {
1320
2674
  },
1321
2675
  message: (target, bytes) => session.message(target, bytes),
1322
2676
  onMessage: (cb) => session.onMessage(cb),
2677
+ messages: buildMessages(session.schema, session),
2678
+ stats: { messages: session.messageCounts },
1323
2679
  on: (event, cb) => session.on(event, cb),
1324
2680
  leave: () => session.leave()
1325
2681
  };
1326
2682
  }
1327
2683
  export {
2684
+ ACCOUNT_STORAGE_KEY,
1328
2685
  CALL_TIMEOUT_MS,
1329
2686
  ClientStore,
2687
+ DEFAULT_CONTROL_URL,
1330
2688
  DEFAULT_REGION,
1331
2689
  DEFAULT_WRITE_INTERVAL_MS,
1332
2690
  DEV_PORT,
1333
2691
  E_CONNECT_FAILED,
2692
+ E_IDENTITY_RATE_LIMITED,
2693
+ INTERNAL_VOICE_TRACKS,
2694
+ Identity,
2695
+ IdentityError,
2696
+ MAX_IDENTITY_RETRY_WAIT_MS,
1334
2697
  MAX_PREDICTED_BODIES,
2698
+ MAX_PROXY_BODIES,
2699
+ MatchError,
1335
2700
  PING_INTERVAL_MS,
1336
2701
  PREDICTION_EPSILON,
1337
2702
  REGION_RE,
@@ -1339,10 +2704,15 @@ export {
1339
2704
  SMOOTHING_HALF_LIFE_MS,
1340
2705
  SMOOTHING_SNAP_UNITS,
1341
2706
  Session,
2707
+ createParty,
1342
2708
  defaultScheduler,
2709
+ findMatch,
2710
+ identityStorageKey,
1343
2711
  joinRelay,
1344
2712
  joinRoom,
2713
+ joinVoice,
1345
2714
  linkForUrl,
2715
+ matchRoom,
1346
2716
  resolveUrl,
1347
2717
  roomIdFrom,
1348
2718
  webSocketTransport