@irtio/client 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,7 +7,10 @@ import {
7
7
  SMOOTHING_HALF_LIFE_MS,
8
8
  SMOOTHING_SNAP_UNITS,
9
9
  emptyPredictionStats
10
- } from "./chunk-7UTJ7RSF.js";
10
+ } from "./chunk-6J6PKFUS.js";
11
+
12
+ // src/index.ts
13
+ import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
11
14
 
12
15
  // src/endpoint.ts
13
16
  var DEFAULT_REGION = "eu";
@@ -112,10 +115,391 @@ function linkForUrl(wsUrl, roomId) {
112
115
  }
113
116
  }
114
117
 
118
+ // src/identity.ts
119
+ var ACCOUNT_STORAGE_KEY = "irtio.account";
120
+ function identityStorageKey(project) {
121
+ return `irtio.identity.${project}`;
122
+ }
123
+ var DEFAULT_CONTROL_URL = "https://irt.io";
124
+ function browserStorage() {
125
+ try {
126
+ const ls = globalThis.localStorage;
127
+ if (!ls) return void 0;
128
+ ls.getItem("irtio.identity.probe");
129
+ return ls;
130
+ } catch {
131
+ return void 0;
132
+ }
133
+ }
134
+ var IdentityError = class extends Error {
135
+ constructor(code, message, retryAfterMs) {
136
+ super(message);
137
+ this.code = code;
138
+ this.retryAfterMs = retryAfterMs;
139
+ }
140
+ code;
141
+ name = "IdentityError";
142
+ /**
143
+ * How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
144
+ * on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
145
+ * tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
146
+ */
147
+ retryAfterMs;
148
+ };
149
+ var E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
150
+ var MAX_IDENTITY_RETRY_WAIT_MS = 6e4;
151
+ function retryAfterMsFrom(res, body) {
152
+ const header = res.headers?.get?.("retry-after");
153
+ const fromHeader = header === null || header === void 0 ? Number.NaN : Number(header);
154
+ if (Number.isFinite(fromHeader) && fromHeader >= 0) return fromHeader * 1e3;
155
+ const fromBody = typeof body?.retryAfter === "number" ? body.retryAfter : Number.NaN;
156
+ if (Number.isFinite(fromBody) && fromBody >= 0) return fromBody * 1e3;
157
+ return 6e4;
158
+ }
159
+ var sleep = (ms) => new Promise((resolve) => {
160
+ setTimeout(resolve, ms);
161
+ });
162
+ var Identity = class {
163
+ project;
164
+ controlUrl;
165
+ fetchImpl;
166
+ storage;
167
+ token;
168
+ accountId;
169
+ cached;
170
+ inFlight;
171
+ constructor(options) {
172
+ this.project = options.project;
173
+ this.controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
174
+ this.fetchImpl = options.fetch ?? fetch;
175
+ this.storage = options.storage === null ? void 0 : options.storage ?? browserStorage() ?? void 0;
176
+ this.token = this.load();
177
+ }
178
+ /**
179
+ * Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
180
+ *
181
+ * Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
182
+ * whose id is the identity's own id, so the credential that was this player on this project is
183
+ * already this player's account credential everywhere. Moving it to the origin-wide key is what
184
+ * makes the same person on the same browser one player across the games on that origin.
185
+ *
186
+ * The old key is removed after a successful adoption. If the write fails (a storage that reads
187
+ * but will not write), the old value is used anyway and adoption is retried next time, which is
188
+ * the same degrade-quietly rule the rest of this module follows.
189
+ */
190
+ load() {
191
+ try {
192
+ const current = this.storage?.getItem(ACCOUNT_STORAGE_KEY);
193
+ if (typeof current === "string" && current !== "") return current;
194
+ } catch {
195
+ return void 0;
196
+ }
197
+ let legacy;
198
+ try {
199
+ legacy = this.storage?.getItem(identityStorageKey(this.project));
200
+ } catch {
201
+ return void 0;
202
+ }
203
+ if (typeof legacy !== "string" || legacy === "") return void 0;
204
+ try {
205
+ this.storage?.setItem(ACCOUNT_STORAGE_KEY, legacy);
206
+ this.storage?.removeItem(identityStorageKey(this.project));
207
+ } catch {
208
+ }
209
+ return legacy;
210
+ }
211
+ /** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
212
+ get stored() {
213
+ return this.token;
214
+ }
215
+ /** The player id the room will see, once an assertion has been fetched. */
216
+ get playerId() {
217
+ return this.cached?.playerId;
218
+ }
219
+ /**
220
+ * Mints an identity if this browser has none, and returns the token.
221
+ *
222
+ * A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
223
+ * that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
224
+ * a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
225
+ * `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
226
+ * "gone" (bug #36).
227
+ */
228
+ async ensure() {
229
+ if (this.token !== void 0) return this.token;
230
+ try {
231
+ return await this.mint();
232
+ } catch (err) {
233
+ if (!(err instanceof IdentityError) || err.code !== E_IDENTITY_RATE_LIMITED) throw err;
234
+ const wait = err.retryAfterMs ?? 6e4;
235
+ if (wait > MAX_IDENTITY_RETRY_WAIT_MS) throw err;
236
+ await sleep(wait);
237
+ return await this.mint();
238
+ }
239
+ }
240
+ async mint() {
241
+ const res = await this.post("/v1/identity", { project: this.project });
242
+ const body = await res.json();
243
+ if (typeof body.identity !== "string") {
244
+ throw new IdentityError("E_IDENTITY_MINT_FAILED", "the control plane returned no identity");
245
+ }
246
+ this.token = body.identity;
247
+ this.accountId = typeof body.account === "string" ? body.account : void 0;
248
+ try {
249
+ this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
250
+ } catch {
251
+ }
252
+ return body.identity;
253
+ }
254
+ /**
255
+ * A currently-valid assertion for this project, exchanging one if the cached one is gone or
256
+ * within thirty seconds of expiry.
257
+ *
258
+ * The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
259
+ * Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
260
+ */
261
+ async assertion(now = Date.now()) {
262
+ const cached = this.cached;
263
+ if (cached && cached.expiresAtMs - 3e4 > now) return cached.assertion;
264
+ if (this.inFlight) return this.inFlight;
265
+ this.inFlight = this.exchange().finally(() => {
266
+ this.inFlight = void 0;
267
+ });
268
+ return this.inFlight;
269
+ }
270
+ async exchange() {
271
+ const identity = await this.ensure();
272
+ let res;
273
+ try {
274
+ res = await this.post("/v1/identity/assertion", { identity, project: this.project });
275
+ } catch (err) {
276
+ if (err instanceof IdentityError && err.code === "E_IDENTITY_INVALID") {
277
+ this.forget();
278
+ const fresh = await this.ensure();
279
+ res = await this.post("/v1/identity/assertion", { identity: fresh, project: this.project });
280
+ } else {
281
+ throw err;
282
+ }
283
+ }
284
+ const body = await res.json();
285
+ if (typeof body.assertion !== "string" || typeof body.playerId !== "string") {
286
+ throw new IdentityError(
287
+ "E_IDENTITY_EXCHANGE_FAILED",
288
+ "the control plane returned no assertion"
289
+ );
290
+ }
291
+ const ttl = typeof body.expiresInMs === "number" ? body.expiresInMs : 6e4;
292
+ this.cached = {
293
+ assertion: body.assertion,
294
+ playerId: body.playerId,
295
+ expiresAtMs: Date.now() + ttl
296
+ };
297
+ return body.assertion;
298
+ }
299
+ /** Drops the stored identity. The next `ensure()` mints a new player. */
300
+ forget() {
301
+ this.token = void 0;
302
+ this.accountId = void 0;
303
+ this.cached = void 0;
304
+ try {
305
+ this.storage?.removeItem(ACCOUNT_STORAGE_KEY);
306
+ } catch {
307
+ }
308
+ }
309
+ // -------------------------------------------------------------------------
310
+ // D61: linking a second device, and managing the ones already on the account
311
+ //
312
+ // Every call below presents the stored device credential in the `x-irt-identity` header, to the
313
+ // control plane, over HTTPS, and to nothing else. That is the same rule the module's header
314
+ // states about the credential generally, and these methods are the reason it needs restating:
315
+ // they are the first surface other than the exchange that the credential is sent to at all.
316
+ // -------------------------------------------------------------------------
317
+ /**
318
+ * Asks the control plane for a link code to read out on another device.
319
+ *
320
+ * The code is short and typable because a person carries it between two screens. Show it, do
321
+ * not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
322
+ * one thing about this that a player controls.
323
+ */
324
+ async linkCode() {
325
+ const res = await this.accountFetch("POST", "/v1/account/link-code", {});
326
+ const body = await res.json();
327
+ if (typeof body.code !== "string") {
328
+ throw new IdentityError("E_LINK_CODE_FAILED", "the control plane returned no link code");
329
+ }
330
+ return {
331
+ code: body.code,
332
+ expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 10 * 6e4
333
+ };
334
+ }
335
+ /**
336
+ * Redeems a code read off another device, and **replaces** this browser's credential with a new
337
+ * one on that code's account.
338
+ *
339
+ * Two things happen in that order and both matter. The new credential is stored first, so a
340
+ * failure between the two leaves the player linked rather than credential-less. Then whatever
341
+ * this browser used to be is retired on the account it is leaving.
342
+ *
343
+ * **That retirement deletes the old account when this was its only device**, rather than
344
+ * revoking the credential and walking away. Revoking the last device leaves an account nothing
345
+ * can ever authenticate as, whose board rows and saved games are then unreachable by the player
346
+ * and undeletable by anyone. An account with other devices only loses this one.
347
+ *
348
+ * So this call can destroy the progress held on THIS browser. Warn the player first; the docs
349
+ * page has wording for it. The retirement is best-effort: if it fails the link still stands,
350
+ * because failing a link that already worked is the worse direction.
351
+ */
352
+ async redeemLinkCode(code) {
353
+ const previous = this.token;
354
+ let previousDeviceId;
355
+ let previousWasOnlyDevice = false;
356
+ if (previous !== void 0) {
357
+ try {
358
+ const before = await this.devices();
359
+ previousDeviceId = before.self;
360
+ previousWasOnlyDevice = before.devices.length === 1;
361
+ } catch {
362
+ previousDeviceId = void 0;
363
+ }
364
+ }
365
+ const res = await this.post("/v1/account/link", { code }, `?project=${enc(this.project)}`);
366
+ const body = await res.json();
367
+ if (typeof body.identity !== "string" || typeof body.account !== "string") {
368
+ throw new IdentityError("E_LINK_FAILED", "the control plane returned no linked identity");
369
+ }
370
+ this.token = body.identity;
371
+ this.accountId = body.account;
372
+ this.cached = void 0;
373
+ try {
374
+ this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
375
+ } catch {
376
+ }
377
+ if (previous !== void 0 && previousDeviceId !== void 0) {
378
+ try {
379
+ const url = previousWasOnlyDevice ? `${this.controlUrl}/v1/account?project=${enc(this.project)}` : `${this.controlUrl}/v1/account/devices/${enc(previousDeviceId)}?project=${enc(this.project)}`;
380
+ await this.fetchImpl(url, {
381
+ method: "DELETE",
382
+ headers: { "x-irt-identity": previous }
383
+ });
384
+ } catch {
385
+ }
386
+ }
387
+ return { account: body.account };
388
+ }
389
+ /** The devices on this account: ids and timestamps, plus which one this browser is. */
390
+ async devices() {
391
+ const res = await this.accountFetch("GET", "/v1/account/devices");
392
+ const body = await res.json();
393
+ if (typeof body.account !== "string" || !Array.isArray(body.devices)) {
394
+ throw new IdentityError("E_ACCOUNT_READ_FAILED", "the control plane returned no device list");
395
+ }
396
+ this.accountId = body.account;
397
+ return {
398
+ account: body.account,
399
+ self: typeof body.self === "string" ? body.self : "",
400
+ devices: body.devices
401
+ };
402
+ }
403
+ /**
404
+ * Revokes one device on this account. Revoking the device this browser IS leaves this instance
405
+ * holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
406
+ * mints a fresh player rather than looping on a refused exchange.
407
+ */
408
+ async revokeDevice(deviceId) {
409
+ let self;
410
+ try {
411
+ self = (await this.devices()).self;
412
+ } catch {
413
+ self = void 0;
414
+ }
415
+ await this.accountFetch("DELETE", `/v1/account/devices/${enc(deviceId)}`);
416
+ if (self !== void 0 && self === deviceId) this.forget();
417
+ }
418
+ /**
419
+ * Deletes this account and everything keyed on it, in every project it played, and forgets the
420
+ * credential. There is no undo and the control plane does not keep a copy.
421
+ */
422
+ async deleteAccount() {
423
+ await this.accountFetch("DELETE", "/v1/account");
424
+ this.forget();
425
+ }
426
+ /** The account id, once anything has told us what it is. Opaque; never parse it. */
427
+ get account() {
428
+ return this.accountId;
429
+ }
430
+ /** One authenticated account-route call: credential in the header, project in the query. */
431
+ async accountFetch(method, path, body) {
432
+ const identity = await this.ensure();
433
+ const url = `${this.controlUrl}${path}?project=${enc(this.project)}`;
434
+ let res;
435
+ try {
436
+ res = await this.fetchImpl(url, {
437
+ method,
438
+ headers: {
439
+ "content-type": "application/json",
440
+ // The credential goes here and nowhere else. Not in the URL: a query string reaches
441
+ // access logs, `Referer` headers and browser history, and this one is long-lived.
442
+ "x-irt-identity": identity
443
+ },
444
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
445
+ });
446
+ } catch (err) {
447
+ throw new IdentityError(
448
+ "E_IDENTITY_UNREACHABLE",
449
+ `cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
450
+ );
451
+ }
452
+ if (res.ok) return res;
453
+ throw await errorFrom(res);
454
+ }
455
+ async post(path, body, query = "") {
456
+ let res;
457
+ try {
458
+ res = await this.fetchImpl(`${this.controlUrl}${path}${query}`, {
459
+ method: "POST",
460
+ headers: { "content-type": "application/json" },
461
+ body: JSON.stringify(body)
462
+ });
463
+ } catch (err) {
464
+ throw new IdentityError(
465
+ "E_IDENTITY_UNREACHABLE",
466
+ `cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
467
+ );
468
+ }
469
+ if (res.ok) return res;
470
+ throw await errorFrom(res);
471
+ }
472
+ };
473
+ function enc(value) {
474
+ return encodeURIComponent(value);
475
+ }
476
+ async function errorFrom(res) {
477
+ const text = await res.text().catch(() => "");
478
+ let code = `E_HTTP_${res.status}`;
479
+ let message = text || `request failed with status ${res.status}`;
480
+ let parsed;
481
+ try {
482
+ parsed = JSON.parse(text);
483
+ if (typeof parsed.code === "string") code = parsed.code;
484
+ if (typeof parsed.message === "string") message = parsed.message;
485
+ } catch {
486
+ }
487
+ if (res.status === 429) {
488
+ const retryAfterMs = retryAfterMsFrom(res, parsed);
489
+ return new IdentityError(
490
+ E_IDENTITY_RATE_LIMITED,
491
+ `${message} (rate limited; retry in ${Math.round(retryAfterMs / 1e3)}s)`,
492
+ retryAfterMs
493
+ );
494
+ }
495
+ return new IdentityError(code, message);
496
+ }
497
+
115
498
  // src/session.ts
116
499
  import {
117
500
  FrameType,
118
501
  PROTOCOL_VERSION,
502
+ ProfileLedger,
119
503
  RELAY_HASH8,
120
504
  decodeCall,
121
505
  decodeErrorPayload,
@@ -134,21 +518,26 @@ import {
134
518
  formatError,
135
519
  readCorrectAppliedTick,
136
520
  readCorrectClientTick,
521
+ readSchemaPayload,
137
522
  relaySchema,
138
523
  rpcTable,
139
524
  withBuiltins
140
525
  } from "@irtio/protocol";
526
+ import { EMPTY_PROFILE, diffProfiles, scaleProfile } from "@irtio/protocol";
141
527
  import {
142
528
  ByteReader,
143
529
  decodeDelta,
144
530
  decodeDeltaFrom,
145
531
  decodeFields,
146
- encodeFields
532
+ encodeFields,
533
+ schemaFromCanonical
147
534
  } from "@irtio/schema";
148
535
 
149
536
  // src/render.ts
150
537
  var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
151
538
  var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
539
+ var BASE_DRIFT_MS_PER_S = 2;
540
+ var SAME_T_EPSILON = 0.01;
152
541
  function cloneRecord(value) {
153
542
  const out = {};
154
543
  for (const [k, v] of Object.entries(value)) {
@@ -176,29 +565,61 @@ function lerpRecord(desc, a, b, alpha) {
176
565
  return out;
177
566
  }
178
567
  var RenderStore = class {
179
- constructor(ext, store, meOf, now, delayMs) {
568
+ constructor(ext, store, meOf, now, delayMs, intervalMs) {
180
569
  this.ext = ext;
181
570
  this.store = store;
182
571
  this.meOf = meOf;
183
572
  this.now = now;
184
573
  this.delayMs = delayMs;
574
+ this.intervalMs = intervalMs;
185
575
  for (const c of ext.collections) this.descs.set(c.name, c);
186
- this.view = this.buildView();
576
+ this.view = {};
577
+ this.buildViewInto(this.view);
187
578
  }
188
579
  ext;
189
580
  store;
190
581
  meOf;
191
582
  now;
192
583
  delayMs;
584
+ intervalMs;
193
585
  /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
194
586
  buffers = /* @__PURE__ */ new Map();
195
587
  descs = /* @__PURE__ */ new Map();
588
+ /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
589
+ facades = /* @__PURE__ */ new Map();
196
590
  /** The object handed out as `room.render`; identity survives a resync. */
197
591
  view;
198
592
  /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
199
593
  starved = 0;
200
594
  /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
201
595
  predictor;
596
+ /**
597
+ * The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
598
+ * is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
599
+ */
600
+ base;
601
+ /** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
602
+ baseAt = 0;
603
+ /**
604
+ * D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
605
+ * it, exactly as `ClientStore.swapSchema` does.
606
+ *
607
+ * Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
608
+ * out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
609
+ * keyframe against a new one under the new field list is precisely the silent misread this
610
+ * whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
611
+ * re-seeds one keyframe per entity, which is the same state the buffers would have started
612
+ * from on a reconnect.
613
+ */
614
+ swapSchema(newExt) {
615
+ this.ext = newExt;
616
+ this.descs.clear();
617
+ for (const c of newExt.collections) this.descs.set(c.name, c);
618
+ this.buffers.clear();
619
+ this.starved = 0;
620
+ this.base = void 0;
621
+ this.buildViewInto(this.view);
622
+ }
202
623
  // -- ingest ---------------------------------------------------------------
203
624
  /**
204
625
  * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
@@ -207,6 +628,7 @@ var RenderStore = class {
207
628
  */
208
629
  seedSnapshot() {
209
630
  this.buffers.clear();
631
+ this.base = void 0;
210
632
  const t = this.now() - this.delayMs();
211
633
  for (const c of this.ext.collections) {
212
634
  if (c.kind !== "entity" || !c.interpolate) continue;
@@ -219,9 +641,39 @@ var RenderStore = class {
219
641
  }
220
642
  }
221
643
  }
222
- /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
644
+ /**
645
+ * Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
646
+ * and the caller must fall back to arrival stamping.
647
+ *
648
+ * The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
649
+ * delayed delta seen so far is the best evidence of where the server's tick clock sits, and
650
+ * every later delta is that plus its own queueing delay. So a better sample is taken
651
+ * immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
652
+ * render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
653
+ * clock rather than a late frame and snaps.
654
+ */
655
+ stampFor(tick) {
656
+ const interval = this.intervalMs();
657
+ if (!(interval > 0)) return void 0;
658
+ const now = this.now();
659
+ const cand = now - tick * interval;
660
+ if (this.base === void 0 || cand < this.base || Math.abs(cand - this.base) > this.snapMs()) {
661
+ this.base = cand;
662
+ } else {
663
+ this.base = Math.min(cand, this.base + (now - this.baseAt) * (BASE_DRIFT_MS_PER_S / 1e3));
664
+ }
665
+ this.baseAt = now;
666
+ return Math.min(this.base + tick * interval, now);
667
+ }
668
+ snapMs() {
669
+ return Math.max(250, 4 * this.delayMs());
670
+ }
671
+ /**
672
+ * Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
673
+ * burstiness cannot modulate the drawn velocity. Call after apply.
674
+ */
223
675
  recordDelta(delta) {
224
- const t = this.now();
676
+ const t = this.stampFor(delta.tick) ?? this.now();
225
677
  for (const dc of delta.collections) {
226
678
  const desc = this.descs.get(dc.name);
227
679
  if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
@@ -236,10 +688,26 @@ var RenderStore = class {
236
688
  if (value === void 0) continue;
237
689
  const buffer = this.bufferFor(dc.name, op.id);
238
690
  buffer.removedAt = void 0;
239
- buffer.frames.push({ t, value: cloneRecord(value) });
691
+ this.pushFrame(buffer, t, cloneRecord(value));
240
692
  }
241
693
  }
242
694
  }
695
+ /**
696
+ * Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
697
+ * depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
698
+ * a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
699
+ * appends; and an offset that just snapped backwards must not stamp behind what is buffered.
700
+ */
701
+ pushFrame(buffer, t, value) {
702
+ const newest = buffer.frames[buffer.frames.length - 1];
703
+ if (newest && t <= newest.t) {
704
+ if (t >= newest.t - SAME_T_EPSILON)
705
+ buffer.frames[buffer.frames.length - 1] = { t: newest.t, value };
706
+ else buffer.frames.push({ t: newest.t + SAME_T_EPSILON, value });
707
+ return;
708
+ }
709
+ buffer.frames.push({ t, value });
710
+ }
243
711
  bufferFor(collection, id) {
244
712
  let byId = this.buffers.get(collection);
245
713
  if (!byId) {
@@ -270,7 +738,7 @@ var RenderStore = class {
270
738
  if (value !== void 0) return this.predictor.read(desc, id, { ...value });
271
739
  }
272
740
  }
273
- if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
741
+ if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
274
742
  return this.store.instance(desc, id);
275
743
  }
276
744
  if (!desc.interpolate) return this.store.instance(desc, id);
@@ -292,14 +760,17 @@ var RenderStore = class {
292
760
  if (renderT > a.t) this.starved++;
293
761
  return cloneRecord(a.value);
294
762
  }
295
- const alpha = (renderT - a.t) / (b.t - a.t);
763
+ const span = b.t - a.t;
764
+ const alpha = span > 0 ? (renderT - a.t) / span : 1;
296
765
  return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
297
766
  }
298
767
  /** Is `collection[id]` visible at the render clock? */
299
768
  has(desc, id) {
300
769
  const coll = this.store.plain[desc.name];
301
770
  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;
771
+ if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
772
+ return true;
773
+ }
303
774
  if (!desc.interpolate) return coll.has(id);
304
775
  const buffer = this.buffers.get(desc.name)?.get(id);
305
776
  if (!buffer || buffer.frames.length === 0) return coll.has(id);
@@ -345,20 +816,39 @@ var RenderStore = class {
345
816
  if (byId && byId.size === 0) this.buffers.delete(collection);
346
817
  }
347
818
  // -- view -----------------------------------------------------------------
348
- buildView() {
349
- const view = {};
819
+ /** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
820
+ buildViewInto(view) {
821
+ const names = /* @__PURE__ */ new Set();
350
822
  for (const c of this.ext.collections) {
823
+ names.add(c.name);
351
824
  if (c.kind === "entity") {
352
- const facade = withRenderIndexSugar(new RenderCollectionImpl(this, c));
353
- Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
825
+ const existing = this.facades.get(c.name);
826
+ if (existing) {
827
+ existing.retarget(c);
828
+ continue;
829
+ }
830
+ const impl = new RenderCollectionImpl(this, c);
831
+ this.facades.set(c.name, impl);
832
+ const facade = withRenderIndexSugar(impl);
833
+ Object.defineProperty(view, c.name, {
834
+ get: () => facade,
835
+ enumerable: true,
836
+ configurable: true
837
+ });
354
838
  } else {
355
839
  Object.defineProperty(view, c.name, {
356
840
  get: () => this.store.view[c.name],
357
- enumerable: true
841
+ enumerable: true,
842
+ configurable: true
358
843
  });
359
844
  }
360
845
  }
361
- return view;
846
+ for (const name of Object.keys(view)) {
847
+ if (!names.has(name)) {
848
+ delete view[name];
849
+ this.facades.delete(name);
850
+ }
851
+ }
362
852
  }
363
853
  };
364
854
  var RenderCollectionImpl = class {
@@ -368,6 +858,10 @@ var RenderCollectionImpl = class {
368
858
  }
369
859
  render;
370
860
  desc;
861
+ /** D50: point this facade at the same collection in a rebuilt schema. */
862
+ retarget(desc) {
863
+ this.desc = desc;
864
+ }
371
865
  get(id) {
372
866
  return this.render.get(this.desc, id);
373
867
  }
@@ -497,6 +991,7 @@ var webSocketTransport = {
497
991
  };
498
992
 
499
993
  // src/session.ts
994
+ var PROFILE_WINDOW_MS = 1e3;
500
995
  var DEFAULT_WRITE_INTERVAL_MS = 50;
501
996
  var PING_INTERVAL_MS = 2e3;
502
997
  var RTT_ALPHA = 0.3;
@@ -513,9 +1008,11 @@ function nameOfCode(code) {
513
1008
  return "E_INTERNAL";
514
1009
  }
515
1010
  }
1011
+ var INTERNAL_SESSION = /* @__PURE__ */ Symbol("irtio.session");
516
1012
  var Session = class {
517
1013
  constructor(options) {
518
1014
  this.options = options;
1015
+ this.schema = options.schema;
519
1016
  this.ext = options.schema ? withBuiltins(options.schema) : relaySchema;
520
1017
  this.rpcSchema = options.schema ?? relaySchema;
521
1018
  this.transport = options.transport ?? webSocketTransport;
@@ -524,52 +1021,91 @@ var Session = class {
524
1021
  this.impls = new Map(Object.entries(options.rpc ?? {}));
525
1022
  this.roomId = options.roomId;
526
1023
  this.role = options.role ?? "";
1024
+ this.ledger = options.profile === true ? new ProfileLedger(this.ext, this.rpcSchema) : void 0;
527
1025
  this.store = new ClientStore(this.ext, () => this.me);
528
1026
  this.render = new RenderStore(
529
1027
  this.ext,
530
1028
  this.store,
531
1029
  () => this.me,
532
1030
  () => this.scheduler.now(),
533
- () => this.interpDelayMs
1031
+ () => this.interpDelayMs,
1032
+ () => this.tickIntervalMs
534
1033
  );
535
1034
  const hasPhysics = this.ext.collections.some(
536
1035
  (c) => c.physics !== void 0
537
1036
  );
538
- if (options.physics && hasPhysics) {
539
- const physics = options.physics;
1037
+ const attach = (predictor) => {
1038
+ if (this.left) return;
1039
+ this.predictor = predictor;
1040
+ this.render.attachPredictor(predictor);
1041
+ if (this.joined) {
1042
+ predictor.reset();
1043
+ void predictor.start();
1044
+ }
1045
+ };
1046
+ const physics = options.physics;
1047
+ const physics2d = options.physics2d;
1048
+ if (physics && hasPhysics) {
540
1049
  this.predictionRequested = true;
541
- void import("./physics-H2VDQLAU.js").then(({ PhysicsPredictor }) => {
1050
+ void import("./physics-S5LHL3HK.js").then(({ PhysicsPredictor }) => {
542
1051
  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
1052
+ attach(
1053
+ new PhysicsPredictor(
1054
+ this.ext,
1055
+ this.store,
1056
+ physics,
1057
+ () => this.me,
1058
+ () => this.rtt,
1059
+ () => this.tickIntervalMs
1060
+ )
1061
+ );
1062
+ });
1063
+ } else if (physics2d && hasPhysics) {
1064
+ this.predictionRequested = true;
1065
+ void import("./physics2d-ZV2IRRRZ.js").then(({ Physics2dPredictor }) => {
1066
+ if (this.left) return;
1067
+ attach(
1068
+ new Physics2dPredictor(
1069
+ this.ext,
1070
+ this.store,
1071
+ physics2d,
1072
+ () => this.me,
1073
+ () => this.rtt,
1074
+ () => this.tickIntervalMs
1075
+ )
550
1076
  );
551
- this.predictor = predictor;
552
- this.render.attachPredictor(predictor);
553
- if (this.joined) {
554
- predictor.reset();
555
- void predictor.start();
556
- }
557
1077
  });
558
1078
  }
559
1079
  }
560
1080
  options;
1081
+ /**
1082
+ * D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
1083
+ * new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
1084
+ * single assignment below is what actually moves the session onto the new schema.
1085
+ */
561
1086
  ext;
562
1087
  store;
563
1088
  render;
564
- /** `true` when the join passed `physics` and the schema has body-backed collections. */
1089
+ /** `true` when the join passed an engine option and the schema has body-backed collections. */
565
1090
  predictionRequested = false;
566
1091
  /**
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.
1092
+ * Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
1093
+ * `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
1094
+ * engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
1095
+ * interpolation until then.
569
1096
  */
570
1097
  predictor;
571
- /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
1098
+ /** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
572
1099
  rpcSchema;
1100
+ /**
1101
+ * The BUILDER schema this session is currently on: `options.schema` at construction, then
1102
+ * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
1103
+ * swapped session announces its *current* hash when it reconnects rather than the one it was
1104
+ * born with — otherwise a resume after a swap would be refused as a mismatch.
1105
+ */
1106
+ schema;
1107
+ /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
1108
+ schemaSwaps = 0;
573
1109
  transport;
574
1110
  scheduler;
575
1111
  writeIntervalMs;
@@ -587,7 +1123,10 @@ var Session = class {
587
1123
  */
588
1124
  writeTick = 0;
589
1125
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
1126
+ /** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
590
1127
  tickIntervalMs = 0;
1128
+ /** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
1129
+ maxClients = 0;
591
1130
  rtt = 0;
592
1131
  status = "connecting";
593
1132
  socket;
@@ -612,6 +1151,20 @@ var Session = class {
612
1151
  pending = /* @__PURE__ */ new Map();
613
1152
  listeners = /* @__PURE__ */ new Map();
614
1153
  messageListeners = /* @__PURE__ */ new Set();
1154
+ /**
1155
+ * D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
1156
+ *
1157
+ * The isolation clause has a client half as well as a server half. The supervisor guarantees that
1158
+ * a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
1159
+ * *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
1160
+ * receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
1161
+ * the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
1162
+ * and no ordering between two callbacks to get wrong.
1163
+ *
1164
+ * Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
1165
+ * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
1166
+ */
1167
+ voiceListeners = /* @__PURE__ */ new Set();
615
1168
  cancelFlush;
616
1169
  cancelPing;
617
1170
  cancelRetry;
@@ -680,6 +1233,19 @@ var Session = class {
680
1233
  }
681
1234
  async sendHello(socket) {
682
1235
  let token;
1236
+ let assertion;
1237
+ const suppliedAssertion = this.options.assertion;
1238
+ if (suppliedAssertion !== void 0) {
1239
+ try {
1240
+ assertion = await suppliedAssertion();
1241
+ } catch (err) {
1242
+ this.transportFailed(
1243
+ err instanceof Error ? err : new Error(`identity provider failed: ${String(err)}`)
1244
+ );
1245
+ return;
1246
+ }
1247
+ if (this.socket !== socket || !this.socketOpen || this.left) return;
1248
+ }
683
1249
  const supplied = this.options.token;
684
1250
  if (supplied !== void 0) {
685
1251
  try {
@@ -692,15 +1258,20 @@ var Session = class {
692
1258
  }
693
1259
  if (this.socket !== socket || !this.socketOpen || this.left) return;
694
1260
  }
695
- const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
1261
+ const hash8 = this.schema ? this.schema.hash8 : RELAY_HASH8;
696
1262
  const hello = encodeHello({
697
1263
  protocolVersion: PROTOCOL_VERSION,
698
- credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
1264
+ 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
1265
  roomId: this.roomId,
700
1266
  schemaHash8: hash8,
701
1267
  ...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
702
1268
  ...this.options.role !== void 0 ? { role: this.options.role } : {},
703
- ...this.options.name !== void 0 ? { name: this.options.name } : {}
1269
+ ...this.options.name !== void 0 ? { name: this.options.name } : {},
1270
+ // D50: this client can rebuild its codec from a SCHEMA frame. Announced unconditionally for
1271
+ // a schema'd session — there is nothing to opt out of, the server still decides, and a
1272
+ // server that predates the bit ignores it (it reads the mask it knows and stops).
1273
+ // A relay session has no schema to swap, so it does not ask.
1274
+ ...this.schema !== void 0 ? { schemaSwap: true } : {}
704
1275
  });
705
1276
  this.send(FrameType.HELLO, hello);
706
1277
  }
@@ -765,10 +1336,43 @@ var Session = class {
765
1336
  // -------------------------------------------------------------------------
766
1337
  // Frames
767
1338
  // -------------------------------------------------------------------------
1339
+ /**
1340
+ * D65: the session's bandwidth ledger, present only when the join asked for one. A second
1341
+ * consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
1342
+ * compose rather than compete.
1343
+ */
1344
+ ledger;
1345
+ /** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
1346
+ profileWindow;
1347
+ /**
1348
+ * D65: bytes per second over a rolling window of about a second, measured on the scheduler's
1349
+ * clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
1350
+ *
1351
+ * The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
1352
+ * one-second window, and a reader that never calls costs nothing. A read sooner than a second
1353
+ * after the last one is extrapolated from the partial window rather than returning zeros.
1354
+ */
1355
+ profilePerSecond() {
1356
+ const ledger = this.ledger;
1357
+ if (!ledger) return EMPTY_PROFILE;
1358
+ const now = this.scheduler.now();
1359
+ const snap = ledger.snapshot();
1360
+ const previous = this.profileWindow;
1361
+ if (!previous) {
1362
+ this.profileWindow = { at: now, snap };
1363
+ return EMPTY_PROFILE;
1364
+ }
1365
+ const elapsed = now - previous.at;
1366
+ if (elapsed <= 0) return EMPTY_PROFILE;
1367
+ const window = diffProfiles(previous.snap, snap);
1368
+ if (elapsed >= PROFILE_WINDOW_MS) this.profileWindow = { at: now, snap };
1369
+ return scaleProfile(window, elapsed / PROFILE_WINDOW_MS);
1370
+ }
768
1371
  send(type, payload) {
769
1372
  if (!this.socketOpen || !this.socket) return;
770
1373
  const frame = encodeFrame(type, payload);
771
1374
  this.options.onFrame?.("out", type, frame);
1375
+ this.ledger?.attribute("out", frame, { spatialChurn: true });
772
1376
  this.socket.send(frame);
773
1377
  }
774
1378
  onFrame(bytes) {
@@ -783,6 +1387,7 @@ var Session = class {
783
1387
  return;
784
1388
  }
785
1389
  this.options.onFrame?.("in", type, bytes);
1390
+ this.ledger?.attribute("in", bytes, { spatialChurn: true });
786
1391
  try {
787
1392
  switch (type) {
788
1393
  case FrameType.WELCOME:
@@ -806,6 +1411,9 @@ var Session = class {
806
1411
  case FrameType.REPLY:
807
1412
  this.onReply(payload);
808
1413
  return;
1414
+ case FrameType.SCHEMA:
1415
+ this.onSchema(payload);
1416
+ return;
809
1417
  default:
810
1418
  if (type === FrameType.MSG) this.onMsg(payload);
811
1419
  return;
@@ -822,6 +1430,72 @@ var Session = class {
822
1430
  fatal: false
823
1431
  });
824
1432
  }
1433
+ /**
1434
+ * D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
1435
+ * the old worker has stopped sending and before the resync WELCOME, so this session can stay
1436
+ * open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
1437
+ *
1438
+ * A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
1439
+ * the session on the old schema with a resync WELCOME about to arrive under the new one, which
1440
+ * it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
1441
+ * it anyway under the wrong descriptor, and a misdecode is silent.
1442
+ */
1443
+ onSchema(payload) {
1444
+ const canonical = readSchemaPayload(payload);
1445
+ this.swapSchema(canonical);
1446
+ }
1447
+ /**
1448
+ * The one entry point for a mid-session schema change. Internal: not part of the public client
1449
+ * API this release, and not called from anywhere but `onSchema`.
1450
+ *
1451
+ * **What is rebuilt.** Every schema-derived handle in the client, top down:
1452
+ *
1453
+ * - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
1454
+ * `decodeSnapshot` and rpc-id lookup reads through;
1455
+ * - `descsByName`, the correction path's collection index, invalidated so it re-derives;
1456
+ * - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
1457
+ * facades behind `room.state` — retargeted, not replaced;
1458
+ * - `RenderStore`: the same, plus every interpolation buffer;
1459
+ * - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
1460
+ *
1461
+ * **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
1462
+ * by the old descriptors and has no correct reading under the new ones. The resync WELCOME
1463
+ * arriving immediately after re-establishes all of it, which is what makes dropping cheap:
1464
+ *
1465
+ * - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
1466
+ * - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
1467
+ * resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
1468
+ * indexed against descriptors that no longer exist;
1469
+ * - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
1470
+ * pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
1471
+ * the WELCOME;
1472
+ * - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
1473
+ *
1474
+ * **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
1475
+ * captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
1476
+ * was produced by a room that had those params in hand. So a call issued before the swap keeps
1477
+ * decoding its reply under the schema it was issued with, and resolves normally afterwards. This
1478
+ * costs nothing to implement — the descriptor is already captured per call rather than looked up
1479
+ * at reply time — and it is the honest semantics: the call did happen, under the old contract.
1480
+ *
1481
+ * The narrow case this leaves is a REPLY whose *shape* changed additively between the two
1482
+ * schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
1483
+ * which is exactly what an appended field means. A breaking change to an RPC never reaches here:
1484
+ * it classifies breaking and the session is closed instead.
1485
+ */
1486
+ swapSchema(canonical) {
1487
+ const next = schemaFromCanonical(canonical);
1488
+ if (this.schema && next.hash === this.schema.hash) return;
1489
+ this.schema = next;
1490
+ this.ext = withBuiltins(next);
1491
+ this.rpcSchema = next;
1492
+ this.descsByName = void 0;
1493
+ this.store.swapSchema(this.ext);
1494
+ this.render.swapSchema(this.ext);
1495
+ this.ledger?.swapSchema(this.ext, this.rpcSchema);
1496
+ this.predictor?.swapSchema(this.ext);
1497
+ this.schemaSwaps++;
1498
+ }
825
1499
  onWelcome(payload) {
826
1500
  const welcome = decodeWelcome(payload);
827
1501
  const pending = this.store.capturePendingWrites();
@@ -829,11 +1503,13 @@ var Session = class {
829
1503
  this.role = welcome.role;
830
1504
  this.tick = welcome.tick;
831
1505
  this.resumeToken = welcome.resumeToken;
832
- this.tickIntervalMs = welcome.tickIntervalMs;
1506
+ this.tickIntervalMs = welcome.tickRate > 0 ? 1e3 / welcome.tickRate : 0;
1507
+ this.maxClients = welcome.maxClients;
833
1508
  if (welcome.roomId !== "") this.roomId = welcome.roomId;
834
1509
  this.store.loadSnapshot(welcome.snapshot);
835
1510
  this.store.applyPendingWrites(pending);
836
1511
  this.render.seedSnapshot();
1512
+ this.emit("clients", this.clients);
837
1513
  if (this.predictor) {
838
1514
  this.predictor.reset();
839
1515
  void this.predictor.start();
@@ -859,6 +1535,9 @@ var Session = class {
859
1535
  this.predictor.noteAuthority(delta.tick);
860
1536
  this.predictor.frame(this.scheduler.now());
861
1537
  }
1538
+ if (delta.collections.some((dc) => dc.name === "clients")) {
1539
+ this.emit("clients", this.clients);
1540
+ }
862
1541
  }
863
1542
  onCorrect(payload) {
864
1543
  const r = new ByteReader(payload);
@@ -897,6 +1576,9 @@ var Session = class {
897
1576
  this.predictor.noteAuthority(delta.tick);
898
1577
  this.predictor.frame(this.scheduler.now());
899
1578
  }
1579
+ if (delta.collections.some((dc) => dc.name === "clients")) {
1580
+ this.emit("clients", this.clients);
1581
+ }
900
1582
  }
901
1583
  descsByName;
902
1584
  descOfCollection(name) {
@@ -942,12 +1624,17 @@ var Session = class {
942
1624
  const pong = decodePong(payload);
943
1625
  const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
944
1626
  this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
1627
+ this.emit("rtt", this.rtt);
945
1628
  if (pong.serverTick > this.tick) this.tick = pong.serverTick;
946
1629
  }
947
1630
  onMsg(payload) {
948
1631
  const msg = decodeMsg(payload);
949
- const from = msg.target.kind === "client" ? msg.target.clientId : "server";
950
1632
  const bytes = msg.payload.slice();
1633
+ if (msg.target.kind === "voice") {
1634
+ for (const cb of [...this.voiceListeners]) cb(bytes);
1635
+ return;
1636
+ }
1637
+ const from = msg.target.kind === "client" ? msg.target.clientId : "server";
951
1638
  for (const cb of [...this.messageListeners]) cb(from, bytes);
952
1639
  }
953
1640
  // -------------------------------------------------------------------------
@@ -1145,6 +1832,25 @@ var Session = class {
1145
1832
  this.messageListeners.delete(cb);
1146
1833
  };
1147
1834
  }
1835
+ /**
1836
+ * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
1837
+ * so it is not on the `Room` type and a game cannot call it.
1838
+ *
1839
+ * This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
1840
+ * purpose (that is the third of the three isolation mechanisms), and widening it so that this
1841
+ * method could share the mapping would delete the mechanism to save four lines. Building the
1842
+ * `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
1843
+ */
1844
+ sendVoice(bytes) {
1845
+ this.send(FrameType.MSG, encodeMsg({ target: { kind: "voice" }, payload: bytes }));
1846
+ }
1847
+ /** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
1848
+ onVoice(cb) {
1849
+ this.voiceListeners.add(cb);
1850
+ return () => {
1851
+ this.voiceListeners.delete(cb);
1852
+ };
1853
+ }
1148
1854
  get clients() {
1149
1855
  const coll = this.store.plain.clients;
1150
1856
  if (!coll) return [];
@@ -1187,6 +1893,458 @@ function resolveKey(explicit, schema, url) {
1187
1893
  );
1188
1894
  }
1189
1895
 
1896
+ // src/match.ts
1897
+ var MatchError = class extends Error {
1898
+ constructor(code, message) {
1899
+ super(message);
1900
+ this.code = code;
1901
+ }
1902
+ code;
1903
+ name = "MatchError";
1904
+ };
1905
+ async function findMatch(project, options = {}) {
1906
+ const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
1907
+ const fetchImpl = options.fetch ?? fetch;
1908
+ let res;
1909
+ try {
1910
+ res = await fetchImpl(`${controlUrl}/match`, {
1911
+ method: "POST",
1912
+ headers: { "content-type": "application/json" },
1913
+ body: JSON.stringify({
1914
+ project,
1915
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
1916
+ ...options.party !== void 0 ? { party: options.party } : {},
1917
+ ...options.identity !== void 0 ? { identity: options.identity } : {},
1918
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
1919
+ })
1920
+ });
1921
+ } catch (err) {
1922
+ throw new MatchError(
1923
+ "E_MATCH_UNREACHABLE",
1924
+ `cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
1925
+ );
1926
+ }
1927
+ const text = await res.text().catch(() => "");
1928
+ let body = {};
1929
+ try {
1930
+ body = JSON.parse(text);
1931
+ } catch {
1932
+ }
1933
+ if (!res.ok) {
1934
+ throw new MatchError(
1935
+ typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
1936
+ typeof body.message === "string" ? body.message : `match failed with status ${res.status}`
1937
+ );
1938
+ }
1939
+ if (body.status === "timeout") {
1940
+ throw new MatchError(
1941
+ "E_NO_MATCH",
1942
+ `no match in the ${String(body.queue)} queue: ${String(body.waiting)} of ${String(
1943
+ body.size
1944
+ )} players were waiting. Try again, or bring a friend.`
1945
+ );
1946
+ }
1947
+ if (body.status !== "matched" || typeof body.room !== "string") {
1948
+ throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
1949
+ }
1950
+ return {
1951
+ room: body.room,
1952
+ queue: typeof body.queue === "string" ? body.queue : "default",
1953
+ seat: typeof body.seat === "number" ? body.seat : 0,
1954
+ size: typeof body.size === "number" ? body.size : 0,
1955
+ backfill: body.backfill === true
1956
+ };
1957
+ }
1958
+ async function createParty(project, options) {
1959
+ const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
1960
+ const fetchImpl = options.fetch ?? fetch;
1961
+ let res;
1962
+ try {
1963
+ res = await fetchImpl(`${controlUrl}/match/party`, {
1964
+ method: "POST",
1965
+ headers: { "content-type": "application/json" },
1966
+ body: JSON.stringify({ project, size: options.size })
1967
+ });
1968
+ } catch (err) {
1969
+ throw new MatchError(
1970
+ "E_MATCH_UNREACHABLE",
1971
+ `cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
1972
+ );
1973
+ }
1974
+ const text = await res.text().catch(() => "");
1975
+ let body = {};
1976
+ try {
1977
+ body = JSON.parse(text);
1978
+ } catch {
1979
+ }
1980
+ if (!res.ok) {
1981
+ throw new MatchError(
1982
+ typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
1983
+ typeof body.message === "string" ? body.message : `party failed with status ${res.status}`
1984
+ );
1985
+ }
1986
+ if (typeof body.party !== "string") {
1987
+ throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
1988
+ }
1989
+ return {
1990
+ party: body.party,
1991
+ expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 0
1992
+ };
1993
+ }
1994
+ async function matchRoom(schema, options = {}) {
1995
+ const project = schema.project;
1996
+ const key = options.key ?? (typeof project === "string" ? project : void 0);
1997
+ if (key === void 0 || key === "") {
1998
+ throw new MatchError(
1999
+ "E_MATCH_NO_PROJECT",
2000
+ "matchRoom needs a project key: build your schema with `irtio` so it carries one, or pass { key }"
2001
+ );
2002
+ }
2003
+ const identity = options.identity === true ? new Identity({
2004
+ project: key,
2005
+ controlUrl: options.controlUrl,
2006
+ fetch: options.fetch
2007
+ }) : void 0;
2008
+ const ticket = await findMatch(key, {
2009
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2010
+ ...options.party !== void 0 ? { party: options.party } : {},
2011
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
2012
+ ...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
2013
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {},
2014
+ ...identity !== void 0 ? { identity: await identity.ensure() } : {}
2015
+ });
2016
+ try {
2017
+ return await joinRoom(schema, { ...options, room: ticket.room });
2018
+ } catch (err) {
2019
+ if (!ticket.backfill || !isRoomFull(err)) throw err;
2020
+ const second = await findMatch(key, {
2021
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2022
+ ...options.party !== void 0 ? { party: options.party } : {},
2023
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
2024
+ ...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
2025
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {},
2026
+ ...identity !== void 0 ? { identity: await identity.ensure() } : {}
2027
+ });
2028
+ return joinRoom(schema, { ...options, room: second.room });
2029
+ }
2030
+ }
2031
+ function isRoomFull(err) {
2032
+ const message = err instanceof Error ? err.message : String(err);
2033
+ return message.startsWith("E_ROOM_FULL");
2034
+ }
2035
+
2036
+ // src/voice.ts
2037
+ import {
2038
+ decodeVoiceMessage,
2039
+ encodeVoiceMessage
2040
+ } from "@irtio/protocol";
2041
+ var INTERNAL_VOICE_TRACKS = /* @__PURE__ */ Symbol.for("irtio.voice.tracks");
2042
+ function browserFactory() {
2043
+ return {
2044
+ createDevice: async () => {
2045
+ const mod = await import("mediasoup-client");
2046
+ return new mod.Device();
2047
+ },
2048
+ getMicTrack: async () => {
2049
+ const media = globalThis.navigator?.mediaDevices;
2050
+ if (!media?.getUserMedia) {
2051
+ throw new Error("irtio voice: getUserMedia is unavailable (needs a secure context)");
2052
+ }
2053
+ const stream = await media.getUserMedia({
2054
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
2055
+ video: false
2056
+ });
2057
+ const track = stream.getAudioTracks()[0];
2058
+ if (!track) throw new Error("irtio voice: no audio track from getUserMedia");
2059
+ return track;
2060
+ },
2061
+ attach: (peerId, track) => {
2062
+ const doc = globalThis.document;
2063
+ if (!doc) {
2064
+ return {
2065
+ detach: () => track.stop(),
2066
+ setVolume: () => void 0,
2067
+ setMuted: () => void 0
2068
+ };
2069
+ }
2070
+ const el = doc.createElement("audio");
2071
+ el.autoplay = true;
2072
+ el.setAttribute("playsinline", "");
2073
+ el.dataset.irtioVoicePeer = peerId;
2074
+ el.style.display = "none";
2075
+ el.srcObject = new MediaStream([track]);
2076
+ doc.body.appendChild(el);
2077
+ return {
2078
+ detach: () => {
2079
+ el.srcObject = null;
2080
+ el.remove();
2081
+ track.stop();
2082
+ },
2083
+ setVolume: (volume) => {
2084
+ el.volume = volume;
2085
+ },
2086
+ setMuted: (muted) => {
2087
+ el.muted = muted;
2088
+ }
2089
+ };
2090
+ }
2091
+ };
2092
+ }
2093
+ function asPlayback(result) {
2094
+ return typeof result === "function" ? { detach: result, setVolume: () => void 0, setMuted: () => void 0 } : result;
2095
+ }
2096
+ async function joinVoice(room, options = {}) {
2097
+ const internals = {
2098
+ ...browserFactory(),
2099
+ ...options.__transportFactory
2100
+ };
2101
+ const session = room[INTERNAL_SESSION];
2102
+ if (!session) {
2103
+ throw new Error("irtio voice: joinVoice needs a Room from joinRoom()");
2104
+ }
2105
+ if (options.positional === true) {
2106
+ console.warn(
2107
+ "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."
2108
+ );
2109
+ }
2110
+ const listeners = /* @__PURE__ */ new Map();
2111
+ const emit = (event, value) => {
2112
+ for (const cb of [...listeners.get(event) ?? []]) cb(value);
2113
+ };
2114
+ let changeQueued = false;
2115
+ const changed = () => {
2116
+ if (changeQueued || closed) return;
2117
+ changeQueued = true;
2118
+ queueMicrotask(() => {
2119
+ changeQueued = false;
2120
+ if (!closed) emit("peers-changed", void 0);
2121
+ });
2122
+ };
2123
+ const waiters = [];
2124
+ const peers = /* @__PURE__ */ new Map();
2125
+ const playbacks = /* @__PURE__ */ new Map();
2126
+ const localPrefs = /* @__PURE__ */ new Map();
2127
+ const prefsFor = (peerId) => {
2128
+ let p = localPrefs.get(peerId);
2129
+ if (!p) {
2130
+ p = { mutedLocally: false, volume: 1 };
2131
+ localPrefs.set(peerId, p);
2132
+ }
2133
+ return p;
2134
+ };
2135
+ const consumed = /* @__PURE__ */ new Set();
2136
+ let closed = false;
2137
+ let muted = false;
2138
+ let device;
2139
+ let sendTransport;
2140
+ let recvTransport;
2141
+ let micTrack;
2142
+ let producer;
2143
+ const signal = (m) => session.sendVoice(encodeVoiceMessage(m));
2144
+ function expect(match) {
2145
+ return new Promise((resolve, reject) => {
2146
+ waiters.push({ match, resolve, reject });
2147
+ });
2148
+ }
2149
+ const unsubscribe = session.onVoice((bytes) => {
2150
+ const m = decodeVoiceMessage(bytes);
2151
+ if (!m) return;
2152
+ if (m.t === "error") {
2153
+ const err = new Error(`irtio voice: ${m.code} ${m.message}`);
2154
+ for (const w of waiters.splice(0)) w.reject(err);
2155
+ emit("error", { code: m.code, message: m.message });
2156
+ return;
2157
+ }
2158
+ for (let i = 0; i < waiters.length; i++) {
2159
+ const w = waiters[i];
2160
+ if (w.match(m)) {
2161
+ waiters.splice(i, 1);
2162
+ w.resolve(m);
2163
+ return;
2164
+ }
2165
+ }
2166
+ switch (m.t) {
2167
+ case "peer-joined": {
2168
+ const known = peers.has(m.peer.peerId);
2169
+ peers.set(m.peer.peerId, {
2170
+ muted: m.peer.muted,
2171
+ ...m.peer.producerId !== void 0 ? { producerId: m.peer.producerId } : {}
2172
+ });
2173
+ if (!known) emit("peer-joined", m.peer.peerId);
2174
+ changed();
2175
+ if (m.peer.producerId !== void 0) void consumePeer(m.peer.producerId);
2176
+ return;
2177
+ }
2178
+ case "peer-left": {
2179
+ peers.delete(m.peerId);
2180
+ playbacks.get(m.peerId)?.playback.detach();
2181
+ playbacks.delete(m.peerId);
2182
+ emit("peer-left", m.peerId);
2183
+ changed();
2184
+ return;
2185
+ }
2186
+ case "peer-muted": {
2187
+ const p = peers.get(m.peerId);
2188
+ if (p) p.muted = m.muted;
2189
+ emit("peer-muted", { peerId: m.peerId, muted: m.muted });
2190
+ changed();
2191
+ return;
2192
+ }
2193
+ default:
2194
+ return;
2195
+ }
2196
+ });
2197
+ async function consumePeer(producerId) {
2198
+ if (closed || consumed.has(producerId) || !recvTransport || !device) return;
2199
+ consumed.add(producerId);
2200
+ try {
2201
+ signal({ t: "consume", producerId, rtpCapabilities: device.rtpCapabilities });
2202
+ const reply = await expect(
2203
+ (m) => m.t === "consumed" && m.producerId === producerId
2204
+ );
2205
+ if (closed) return;
2206
+ const consumer = await recvTransport.consume({
2207
+ id: reply.consumerId,
2208
+ producerId: reply.producerId,
2209
+ kind: "audio",
2210
+ rtpParameters: reply.rtpParameters
2211
+ });
2212
+ const peerId = reply.peerId || producerId;
2213
+ playbacks.get(peerId)?.playback.detach();
2214
+ const playback = asPlayback(internals.attach(peerId, consumer.track));
2215
+ playbacks.set(peerId, { playback, track: consumer.track });
2216
+ const prefs = prefsFor(peerId);
2217
+ playback.setVolume(prefs.volume);
2218
+ playback.setMuted(prefs.mutedLocally);
2219
+ signal({ t: "resume", consumerId: reply.consumerId });
2220
+ changed();
2221
+ } catch (err) {
2222
+ consumed.delete(producerId);
2223
+ emit("error", {
2224
+ code: "E_VOICE_CONSUME",
2225
+ message: err instanceof Error ? err.message : String(err)
2226
+ });
2227
+ }
2228
+ }
2229
+ async function teardown() {
2230
+ if (closed) return;
2231
+ closed = true;
2232
+ for (const w of waiters.splice(0)) w.reject(new Error("irtio voice: left the call"));
2233
+ for (const p of playbacks.values()) p.playback.detach();
2234
+ playbacks.clear();
2235
+ peers.clear();
2236
+ producer?.close();
2237
+ micTrack?.stop();
2238
+ sendTransport?.close();
2239
+ recvTransport?.close();
2240
+ unsubscribe();
2241
+ }
2242
+ try {
2243
+ micTrack = await internals.getMicTrack();
2244
+ device = await internals.createDevice();
2245
+ signal({ t: "join", rtpCapabilities: {} });
2246
+ const joined = await expect(
2247
+ (m) => m.t === "joined"
2248
+ );
2249
+ await device.load({ routerRtpCapabilities: joined.routerRtpCapabilities });
2250
+ sendTransport = device.createSendTransport(joined.sendTransport);
2251
+ recvTransport = device.createRecvTransport(joined.recvTransport);
2252
+ for (const t of [sendTransport, recvTransport]) {
2253
+ const transport = t;
2254
+ transport.on("connect", ({ dtlsParameters }, callback, errback) => {
2255
+ signal({ t: "connect", transportId: transport.id, dtlsParameters });
2256
+ expect((m) => m.t === "connected" && m.transportId === transport.id).then(
2257
+ () => callback(),
2258
+ (e) => errback(e)
2259
+ );
2260
+ });
2261
+ }
2262
+ sendTransport.on("produce", ({ rtpParameters }, callback, errback) => {
2263
+ signal({
2264
+ t: "produce",
2265
+ transportId: sendTransport.id,
2266
+ kind: "audio",
2267
+ rtpParameters
2268
+ });
2269
+ expect((m) => m.t === "produced").then(
2270
+ (m) => callback({ id: m.producerId }),
2271
+ (e) => errback(e)
2272
+ );
2273
+ });
2274
+ producer = await sendTransport.produce({ track: micTrack });
2275
+ for (const peer of joined.peers) {
2276
+ peers.set(peer.peerId, {
2277
+ muted: peer.muted,
2278
+ ...peer.producerId !== void 0 ? { producerId: peer.producerId } : {}
2279
+ });
2280
+ if (peer.producerId !== void 0) void consumePeer(peer.producerId);
2281
+ }
2282
+ changed();
2283
+ } catch (err) {
2284
+ await teardown();
2285
+ throw err instanceof Error ? err : new Error(String(err));
2286
+ }
2287
+ const handle = {
2288
+ mute(next) {
2289
+ if (closed || next === muted) return;
2290
+ muted = next;
2291
+ signal({ t: "mute", muted: next });
2292
+ changed();
2293
+ },
2294
+ setPeerVolume(peerId, volume) {
2295
+ if (closed) return;
2296
+ const v = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1;
2297
+ const prefs = prefsFor(peerId);
2298
+ if (prefs.volume === v) return;
2299
+ prefs.volume = v;
2300
+ playbacks.get(peerId)?.playback.setVolume(v);
2301
+ changed();
2302
+ },
2303
+ mutePeer(peerId, mutedLocally) {
2304
+ if (closed) return;
2305
+ const prefs = prefsFor(peerId);
2306
+ if (prefs.mutedLocally === mutedLocally) return;
2307
+ prefs.mutedLocally = mutedLocally;
2308
+ playbacks.get(peerId)?.playback.setMuted(mutedLocally);
2309
+ changed();
2310
+ },
2311
+ peerState(peerId) {
2312
+ const peer = peers.get(peerId);
2313
+ if (!peer) return void 0;
2314
+ const prefs = prefsFor(peerId);
2315
+ return { muted: peer.muted, mutedLocally: prefs.mutedLocally, volume: prefs.volume };
2316
+ },
2317
+ get muted() {
2318
+ return muted;
2319
+ },
2320
+ get peers() {
2321
+ return [...peers.keys()];
2322
+ },
2323
+ async leave() {
2324
+ if (closed) return;
2325
+ signal({ t: "leave" });
2326
+ await teardown();
2327
+ },
2328
+ on(event, cb) {
2329
+ let set = listeners.get(event);
2330
+ if (!set) {
2331
+ set = /* @__PURE__ */ new Set();
2332
+ listeners.set(event, set);
2333
+ }
2334
+ set.add(cb);
2335
+ return () => {
2336
+ set.delete(cb);
2337
+ };
2338
+ }
2339
+ };
2340
+ const tracks = {
2341
+ peerTrack: (peerId) => playbacks.get(peerId)?.track,
2342
+ micTrack: () => micTrack
2343
+ };
2344
+ Object.defineProperty(handle, INTERNAL_VOICE_TRACKS, { value: tracks, enumerable: false });
2345
+ return handle;
2346
+ }
2347
+
1190
2348
  // src/index.ts
1191
2349
  function callProxy(session) {
1192
2350
  const cache = /* @__PURE__ */ new Map();
@@ -1203,11 +2361,22 @@ function callProxy(session) {
1203
2361
  });
1204
2362
  }
1205
2363
  async function joinRoom(schema, options = {}) {
2364
+ if (options.physics !== void 0 && options.physics2d !== void 0) {
2365
+ throw new Error(
2366
+ "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."
2367
+ );
2368
+ }
1206
2369
  const url = resolveUrl(options.url, options.region);
1207
2370
  const key = resolveKey(options.key, schema, url);
1208
2371
  const explicitRoom = options.room !== void 0;
1209
2372
  const fromLocation = !explicitRoom && currentLocation() !== void 0;
1210
2373
  const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
2374
+ const identity = options.identity === true ? new Identity({ project: key, controlUrl: options.controlUrl }) : options.identity instanceof Identity ? options.identity : void 0;
2375
+ if (identity !== void 0 && options.token !== void 0) {
2376
+ throw new Error(
2377
+ "irtio: joinRoom was given both { token } and { identity }. A join asserts one identity \u2014 pass your own JWT, or the platform identity, not both."
2378
+ );
2379
+ }
1211
2380
  const session = new Session({
1212
2381
  schema,
1213
2382
  url,
@@ -1217,13 +2386,16 @@ async function joinRoom(schema, options = {}) {
1217
2386
  role: options.role,
1218
2387
  name: options.name,
1219
2388
  token: options.token,
2389
+ ...identity !== void 0 ? { assertion: () => identity.assertion() } : {},
1220
2390
  rpc: options.rpc,
1221
2391
  writeIntervalMs: options.writeIntervalMs,
1222
2392
  interpDelayMs: options.interpDelayMs,
1223
2393
  physics: options.physics,
2394
+ physics2d: options.physics2d,
1224
2395
  transport: options.transport,
1225
2396
  scheduler: options.scheduler,
1226
2397
  onFrame: options.onFrame,
2398
+ profile: options.profile,
1227
2399
  onStatus: options.onStatus
1228
2400
  });
1229
2401
  await session.start();
@@ -1244,6 +2416,9 @@ function makeRoom(session) {
1244
2416
  get tick() {
1245
2417
  return session.tick;
1246
2418
  },
2419
+ get maxClients() {
2420
+ return session.maxClients;
2421
+ },
1247
2422
  get status() {
1248
2423
  return session.status;
1249
2424
  },
@@ -1259,6 +2434,12 @@ function makeRoom(session) {
1259
2434
  get clients() {
1260
2435
  return session.clients;
1261
2436
  },
2437
+ ...session.ledger ? {
2438
+ profile: {
2439
+ total: () => session.ledger?.snapshot() ?? EMPTY_PROFILE2,
2440
+ perSecond: () => session.profilePerSecond()
2441
+ }
2442
+ } : {},
1262
2443
  ...session.predictionRequested ? {
1263
2444
  prediction: {
1264
2445
  get active() {
@@ -1278,6 +2459,7 @@ function makeRoom(session) {
1278
2459
  flush: () => session.flush(),
1279
2460
  leave: () => session.leave()
1280
2461
  };
2462
+ Object.defineProperty(room, INTERNAL_SESSION, { value: session, enumerable: false });
1281
2463
  return room;
1282
2464
  }
1283
2465
  async function joinRelay(options = {}) {
@@ -1306,6 +2488,9 @@ async function joinRelay(options = {}) {
1306
2488
  get id() {
1307
2489
  return session.roomId;
1308
2490
  },
2491
+ get maxClients() {
2492
+ return session.maxClients;
2493
+ },
1309
2494
  get link() {
1310
2495
  return session.link;
1311
2496
  },
@@ -1325,13 +2510,21 @@ async function joinRelay(options = {}) {
1325
2510
  };
1326
2511
  }
1327
2512
  export {
2513
+ ACCOUNT_STORAGE_KEY,
1328
2514
  CALL_TIMEOUT_MS,
1329
2515
  ClientStore,
2516
+ DEFAULT_CONTROL_URL,
1330
2517
  DEFAULT_REGION,
1331
2518
  DEFAULT_WRITE_INTERVAL_MS,
1332
2519
  DEV_PORT,
1333
2520
  E_CONNECT_FAILED,
2521
+ E_IDENTITY_RATE_LIMITED,
2522
+ INTERNAL_VOICE_TRACKS,
2523
+ Identity,
2524
+ IdentityError,
2525
+ MAX_IDENTITY_RETRY_WAIT_MS,
1334
2526
  MAX_PREDICTED_BODIES,
2527
+ MatchError,
1335
2528
  PING_INTERVAL_MS,
1336
2529
  PREDICTION_EPSILON,
1337
2530
  REGION_RE,
@@ -1339,10 +2532,15 @@ export {
1339
2532
  SMOOTHING_HALF_LIFE_MS,
1340
2533
  SMOOTHING_SNAP_UNITS,
1341
2534
  Session,
2535
+ createParty,
1342
2536
  defaultScheduler,
2537
+ findMatch,
2538
+ identityStorageKey,
1343
2539
  joinRelay,
1344
2540
  joinRoom,
2541
+ joinVoice,
1345
2542
  linkForUrl,
2543
+ matchRoom,
1346
2544
  resolveUrl,
1347
2545
  roomIdFrom,
1348
2546
  webSocketTransport