@irtio/client 0.8.0 → 0.9.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.
@@ -121,6 +121,13 @@ var ClientStore = class {
121
121
  tracked;
122
122
  descs = /* @__PURE__ */ new Map();
123
123
  frozen = /* @__PURE__ */ new WeakMap();
124
+ /**
125
+ * Per-collection add/remove subscribers (`room.onAdd` / `room.onRemove`). Kept here rather than
126
+ * in the session because this is the only place that sees a frame's ops against the state the
127
+ * frame is about to change: a removed row's last values exist for exactly as long as it takes
128
+ * `applyDelta` to run.
129
+ */
130
+ entityListeners = /* @__PURE__ */ new Map();
124
131
  /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
125
132
  facades = /* @__PURE__ */ new Map();
126
133
  /** The object handed out as `room.state`; identity survives a resync. */
@@ -161,11 +168,13 @@ var ClientStore = class {
161
168
  }
162
169
  // -- snapshot / resync -----------------------------------------------------
163
170
  loadSnapshot(bytes) {
171
+ const before = this.captureRows();
164
172
  this.plain = decodeSnapshot(this.ext, bytes).state;
165
173
  this.tracked = track(this.ext, this.plain);
166
174
  this.pendingWrites.length = 0;
167
175
  this.evictedThroughTick = 0;
168
176
  this.baselineIntents.clear();
177
+ this.emitSnapshotDiff(before);
169
178
  }
170
179
  /**
171
180
  * Captures the field values of every pending owned write, keyed by collection then id then
@@ -294,14 +303,144 @@ var ClientStore = class {
294
303
  singletonHint(desc) {
295
304
  return `${desc.name} is a singleton and never client-owned; change it with an RPC (room.call.\u2026)`;
296
305
  }
306
+ // -- entity add/remove events ---------------------------------------------
307
+ listenersFor(name) {
308
+ let set = this.entityListeners.get(name);
309
+ if (!set) {
310
+ set = { add: /* @__PURE__ */ new Set(), remove: /* @__PURE__ */ new Set() };
311
+ this.entityListeners.set(name, set);
312
+ }
313
+ return set;
314
+ }
315
+ /**
316
+ * Subscribes to rows appearing in one entity collection. Rows that are **already present** are
317
+ * announced synchronously as the subscription is made — the join snapshot has usually landed
318
+ * before user code runs, and a listener that had to reconcile the initial set by hand would
319
+ * make the event useless for exactly the case (a spawn effect) it exists for.
320
+ */
321
+ onEntityAdd(name, cb) {
322
+ const desc = this.desc(name);
323
+ if (desc?.kind === "entity") {
324
+ const coll = entityOf(this.plain, name);
325
+ for (const id of [...coll.ids()]) cb(id, this.instance(desc, id));
326
+ }
327
+ const set = this.listenersFor(name);
328
+ set.add.add(cb);
329
+ return () => {
330
+ set.add.delete(cb);
331
+ };
332
+ }
333
+ /** Subscribes to rows disappearing from one entity collection (never fires for past removals). */
334
+ onEntityRemove(name, cb) {
335
+ const set = this.listenersFor(name);
336
+ set.remove.add(cb);
337
+ return () => {
338
+ set.remove.delete(cb);
339
+ };
340
+ }
341
+ emitAdd(name, id) {
342
+ const set = this.entityListeners.get(name);
343
+ if (!set || set.add.size === 0) return;
344
+ const desc = this.desc(name);
345
+ if (!desc) return;
346
+ const row = this.instance(desc, id);
347
+ for (const cb of [...set.add]) cb(id, row);
348
+ }
349
+ emitRemove(name, id, value, hint) {
350
+ const set = this.entityListeners.get(name);
351
+ if (!set || set.remove.size === 0) return;
352
+ const row = this.freeze(value, hint);
353
+ for (const cb of [...set.remove]) cb(id, row);
354
+ }
355
+ /**
356
+ * The adds and removes a delta is about to make, captured against the state as it stands now:
357
+ * a remove's last values are only readable before `applyDelta` drops the row.
358
+ */
359
+ captureOps(delta) {
360
+ if (this.entityListeners.size === 0) return void 0;
361
+ const adds = [];
362
+ const removes = [];
363
+ for (const dc of delta.collections) {
364
+ const set = this.entityListeners.get(dc.name);
365
+ if (!set || set.add.size === 0 && set.remove.size === 0) continue;
366
+ const desc = this.desc(dc.name);
367
+ if (!desc || desc.kind !== "entity") continue;
368
+ const coll = entityOf(this.plain, dc.name);
369
+ for (const op of dc.ops) {
370
+ if (op.op === "add") {
371
+ if (!coll.has(op.id)) adds.push([dc.name, op.id]);
372
+ } else if (op.op === "remove") {
373
+ const value = coll.get(op.id);
374
+ if (value) removes.push([dc.name, op.id, value]);
375
+ }
376
+ }
377
+ }
378
+ return adds.length === 0 && removes.length === 0 ? void 0 : { adds, removes };
379
+ }
380
+ flushOps(captured) {
381
+ if (!captured) return;
382
+ for (const [name, id, value] of captured.removes) {
383
+ const desc = this.desc(name);
384
+ this.emitRemove(
385
+ name,
386
+ id,
387
+ value,
388
+ desc ? `${desc.name}[${id}] has been removed; this is its last value` : name
389
+ );
390
+ }
391
+ for (const [name, id] of captured.adds) this.emitAdd(name, id);
392
+ }
393
+ /** Fires adds and removes for the difference a wholesale snapshot load made. */
394
+ emitSnapshotDiff(before) {
395
+ if (this.entityListeners.size === 0) return;
396
+ for (const [name, set] of this.entityListeners) {
397
+ const desc = this.desc(name);
398
+ if (!desc || desc.kind !== "entity") continue;
399
+ const prev = before.get(name) ?? /* @__PURE__ */ new Map();
400
+ const coll = entityOf(this.plain, name);
401
+ if (set.remove.size > 0) {
402
+ for (const [id, value] of prev) {
403
+ if (!coll.has(id)) {
404
+ this.emitRemove(
405
+ name,
406
+ id,
407
+ value,
408
+ `${name}[${id}] has been removed; this is its last value`
409
+ );
410
+ }
411
+ }
412
+ }
413
+ if (set.add.size > 0) {
414
+ for (const id of [...coll.ids()]) {
415
+ if (!prev.has(id)) this.emitAdd(name, id);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ /** The rows every subscribed collection holds right now, for the snapshot diff. */
421
+ captureRows() {
422
+ const out = /* @__PURE__ */ new Map();
423
+ if (this.entityListeners.size === 0) return out;
424
+ for (const name of this.entityListeners.keys()) {
425
+ const desc = this.desc(name);
426
+ if (!desc || desc.kind !== "entity") continue;
427
+ const coll = entityOf(this.plain, name);
428
+ const rows = /* @__PURE__ */ new Map();
429
+ for (const [id, value] of coll) rows.set(id, value);
430
+ out.set(name, rows);
431
+ }
432
+ return out;
433
+ }
297
434
  // -- inbound frames -------------------------------------------------------
298
435
  /**
299
436
  * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
300
437
  * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
301
438
  */
302
439
  applyServerDelta(delta) {
440
+ const captured = this.captureOps(delta);
303
441
  applyDelta(this.ext, this.plain, this.withoutOwnedUpdates(delta));
304
442
  this.replayOverAddEchoes(delta);
443
+ this.flushOps(captured);
305
444
  }
306
445
  /**
307
446
  * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
@@ -454,7 +593,9 @@ var ClientStore = class {
454
593
  });
455
594
  }
456
595
  }
596
+ const captured = this.captureOps(delta);
457
597
  applyDelta(this.ext, this.plain, delta);
598
+ this.flushOps(captured);
458
599
  let replayed = 0;
459
600
  if (replay) {
460
601
  for (const pw of [...this.pendingWrites, unflushed]) {
@@ -5,7 +5,7 @@ import {
5
5
  RESIM_DEPTH,
6
6
  SMOOTHING_HALF_LIFE_MS,
7
7
  SMOOTHING_SNAP_UNITS
8
- } from "./chunk-5Z4DHUA3.js";
8
+ } from "./chunk-5OHONPVG.js";
9
9
 
10
10
  // src/predictor.ts
11
11
  var MAX_FREE_STEPS_PER_FRAME = 5;
package/dist/index.d.ts CHANGED
@@ -52,6 +52,8 @@ interface CorrectionOp {
52
52
  /** `true` when the correction outran the resim window and everything it named snapped. */
53
53
  readonly snapped: boolean;
54
54
  }
55
+ /** A row appeared (`onAdd`) or disappeared (`onRemove`) in one entity collection. */
56
+ type EntityCallback = (id: string, row: unknown) => void;
55
57
  declare class ClientStore {
56
58
  /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
57
59
  ext: AnySchema;
@@ -61,6 +63,13 @@ declare class ClientStore {
61
63
  private tracked;
62
64
  private readonly descs;
63
65
  private readonly frozen;
66
+ /**
67
+ * Per-collection add/remove subscribers (`room.onAdd` / `room.onRemove`). Kept here rather than
68
+ * in the session because this is the only place that sees a frame's ops against the state the
69
+ * frame is about to change: a removed row's last values exist for exactly as long as it takes
70
+ * `applyDelta` to run.
71
+ */
72
+ private readonly entityListeners;
64
73
  /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
65
74
  private readonly facades;
66
75
  /** The object handed out as `room.state`; identity survives a resync. */
@@ -124,6 +133,28 @@ declare class ClientStore {
124
133
  private freeze;
125
134
  private entityHint;
126
135
  private singletonHint;
136
+ private listenersFor;
137
+ /**
138
+ * Subscribes to rows appearing in one entity collection. Rows that are **already present** are
139
+ * announced synchronously as the subscription is made — the join snapshot has usually landed
140
+ * before user code runs, and a listener that had to reconcile the initial set by hand would
141
+ * make the event useless for exactly the case (a spawn effect) it exists for.
142
+ */
143
+ onEntityAdd(name: string, cb: EntityCallback): () => void;
144
+ /** Subscribes to rows disappearing from one entity collection (never fires for past removals). */
145
+ onEntityRemove(name: string, cb: EntityCallback): () => void;
146
+ private emitAdd;
147
+ private emitRemove;
148
+ /**
149
+ * The adds and removes a delta is about to make, captured against the state as it stands now:
150
+ * a remove's last values are only readable before `applyDelta` drops the row.
151
+ */
152
+ private captureOps;
153
+ private flushOps;
154
+ /** Fires adds and removes for the difference a wholesale snapshot load made. */
155
+ private emitSnapshotDiff;
156
+ /** The rows every subscribed collection holds right now, for the snapshot diff. */
157
+ private captureRows;
127
158
  /**
128
159
  * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
129
160
  * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
@@ -1453,6 +1484,12 @@ type ClientState<S, Role extends string = RoleOf<S> & string> = {
1453
1484
  serverOwned: true;
1454
1485
  } ? ClientCollection<DeepReadonly<InferFields<F>>> : ClientCollection<Owned<InferFields<F>>> : SchemaDefs<S>[K] extends SingletonDef<infer F, any> ? DeepReadonly<InferFields<F>> : never;
1455
1486
  };
1487
+ /** The entity-collection names visible to `Role` — the keys `room.onAdd`/`room.onRemove` take. */
1488
+ type EntityKeys<S, Role extends string = RoleOf<S> & string> = {
1489
+ [K in keyof ClientState<S, Role>]: ClientState<S, Role>[K] extends ReadonlyCollection<any> ? K : never;
1490
+ }[keyof ClientState<S, Role>];
1491
+ /** The row type of one entity collection, as `room.state` hands it out. */
1492
+ type RowOf<S, Role extends string, K extends keyof ClientState<S, Role>> = ClientState<S, Role>[K] extends ReadonlyCollection<infer T> ? T : never;
1456
1493
  /** `connecting` → `starting`? → `connected` ⇄ `reconnecting` → `closed`. */
1457
1494
  type Status = 'connecting' | 'starting' | 'connected' | 'reconnecting' | 'closed';
1458
1495
  /**
@@ -1873,9 +1910,29 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
1873
1910
  /** D70: this socket's message counters, including typed frames it could not read. */
1874
1911
  readonly stats: RoomStats;
1875
1912
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1913
+ /**
1914
+ * Fires when a row appears in one entity collection — a server-owned spawn, another client's
1915
+ * entity, a resync that brought a row this client had not seen.
1916
+ *
1917
+ * Rows that are **already present** when you subscribe are announced synchronously, inside the
1918
+ * `onAdd` call itself: the join snapshot has landed by the time `joinRoom` resolves, so a
1919
+ * listener registered right after the join sees the whole initial set and then every later
1920
+ * arrival, with no separate pass over `room.state`.
1921
+ */
1922
+ onAdd<K extends EntityKeys<S, Role>>(collection: K, cb: (id: string, row: RowOf<S, Role, K>) => void): Unsubscribe;
1923
+ /**
1924
+ * Fires when a row disappears from one entity collection, carrying its **last values** — the
1925
+ * row itself is the death event, so a corpse stain or a score tick needs no parallel RPC. The
1926
+ * row handed to the callback is read-only and is not in `room.state` any more.
1927
+ */
1928
+ onRemove<K extends EntityKeys<S, Role>>(collection: K, cb: (id: string, lastRow: RowOf<S, Role, K>) => void): Unsubscribe;
1876
1929
  /** Sends any pending owned writes immediately instead of at the next flush window. */
1877
1930
  flush(): void;
1878
- leave(): void;
1931
+ /**
1932
+ * Leaves for good. The returned promise resolves once the socket has closed (immediately if it
1933
+ * was already closed), so a script can `await room.leave()` before exiting.
1934
+ */
1935
+ leave(): Promise<void>;
1879
1936
  }
1880
1937
  /**
1881
1938
  * What `joinRelay` returns: presence and the message channel, nothing else.
@@ -1905,7 +1962,8 @@ interface RelayRoom<S = never> {
1905
1962
  /** D70: this socket's message counters, including typed frames it could not read. */
1906
1963
  readonly stats: RoomStats;
1907
1964
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1908
- leave(): void;
1965
+ /** Leaves for good; resolves once the socket has closed (immediately if it already had). */
1966
+ leave(): Promise<void>;
1909
1967
  }
1910
1968
 
1911
1969
  /**
@@ -2635,8 +2693,15 @@ declare class Session {
2635
2693
  private connect;
2636
2694
  private transportFailed;
2637
2695
  private sendHello;
2638
- /** Leaves for good: no reconnect, pending calls reject, the socket closes. */
2639
- leave(): void;
2696
+ /**
2697
+ * Leaves for good: no reconnect, pending calls reject, the socket closes.
2698
+ *
2699
+ * The promise resolves when the socket reports its close (or immediately when there was
2700
+ * nothing open), so `await room.leave()` is a real wait rather than a guessed timeout. A
2701
+ * transport that never reports one is bounded by `LEAVE_CLOSE_TIMEOUT_MS` so the caller cannot
2702
+ * hang on it.
2703
+ */
2704
+ leave(): Promise<void>;
2640
2705
  private fatal;
2641
2706
  private onSocketClosed;
2642
2707
  private stopTimers;
@@ -2949,4 +3014,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
2949
3014
  */
2950
3015
  declare function joinRelay<S extends AnySchema = never>(options?: JoinRelayOptions<S extends AnySchema ? S : AnySchema>): Promise<RelayRoom<S>>;
2951
3016
 
2952
- export { ACCOUNT_STORAGE_KEY, type AttachLobbyOptions, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapier2dBody, type ClientRapier2dBodyFactory, type ClientRapier2dBodySpec, type ClientRapier2dIntentHook, type ClientRapier2dModule, type ClientRapier2dOptions, type ClientRapier2dWorld, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector2d, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, LOBBY_MEMBERS, LOBBY_POLL_MS, LOBBY_STATE, type LobbyPlayer, type LobbyView, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MAX_PROXY_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, type PublicTicket, type QuickMatchDetail, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, attachLobby, attachable, createParty, defaultScheduler, findMatch, findPublic, hasLobby, identityStorageKey, joinPublic, joinRelay, joinRoom, joinVoice, linkForUrl, lobbyOf, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };
3017
+ export { ACCOUNT_STORAGE_KEY, type AttachLobbyOptions, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapier2dBody, type ClientRapier2dBodyFactory, type ClientRapier2dBodySpec, type ClientRapier2dIntentHook, type ClientRapier2dModule, type ClientRapier2dOptions, type ClientRapier2dWorld, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector2d, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type EntityKeys, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, LOBBY_MEMBERS, LOBBY_POLL_MS, LOBBY_STATE, type LobbyPlayer, type LobbyView, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MAX_PROXY_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, type PublicTicket, type QuickMatchDetail, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, type RowOf, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, attachLobby, attachable, createParty, defaultScheduler, findMatch, findPublic, hasLobby, identityStorageKey, joinPublic, joinRelay, joinRoom, joinVoice, linkForUrl, lobbyOf, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  SMOOTHING_HALF_LIFE_MS,
9
9
  SMOOTHING_SNAP_UNITS,
10
10
  emptyPredictionStats
11
- } from "./chunk-5Z4DHUA3.js";
11
+ } from "./chunk-5OHONPVG.js";
12
12
 
13
13
  // src/index.ts
14
14
  import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
@@ -1065,6 +1065,7 @@ var DEFAULT_WRITE_INTERVAL_MS = 50;
1065
1065
  var PING_INTERVAL_MS = 2e3;
1066
1066
  var RTT_ALPHA = 0.3;
1067
1067
  var CALL_TIMEOUT_MS = 1e4;
1068
+ var LEAVE_CLOSE_TIMEOUT_MS = 1e3;
1068
1069
  var BACKOFF_START_MS = 250;
1069
1070
  var BACKOFF_MAX_MS = 5e3;
1070
1071
  function isThenable(v) {
@@ -1117,7 +1118,7 @@ var Session = class {
1117
1118
  const physics2d = options.physics2d;
1118
1119
  if (physics && hasPhysics) {
1119
1120
  this.predictionRequested = true;
1120
- void import("./physics-4SGKNBAC.js").then(({ PhysicsPredictor }) => {
1121
+ void import("./physics-D65WIHPR.js").then(({ PhysicsPredictor }) => {
1121
1122
  if (this.left) return;
1122
1123
  attach(
1123
1124
  new PhysicsPredictor(
@@ -1133,7 +1134,7 @@ var Session = class {
1133
1134
  } else if (physics2d !== void 0 && physics2d.engine === "rapier2d" && hasPhysics) {
1134
1135
  const rapier2d = physics2d;
1135
1136
  this.predictionRequested = true;
1136
- void import("./physics-rapier2d-TBOAJMVM.js").then(({ Rapier2dPredictor }) => {
1137
+ void import("./physics-rapier2d-O53E6BHO.js").then(({ Rapier2dPredictor }) => {
1137
1138
  if (this.left) return;
1138
1139
  attach(
1139
1140
  new Rapier2dPredictor(
@@ -1149,7 +1150,7 @@ var Session = class {
1149
1150
  } else if (physics2d !== void 0 && physics2d.engine !== "rapier2d" && hasPhysics) {
1150
1151
  const matter2d = physics2d;
1151
1152
  this.predictionRequested = true;
1152
- void import("./physics2d-CNSEWKP4.js").then(({ Physics2dPredictor }) => {
1153
+ void import("./physics2d-GIL6YYAY.js").then(({ Physics2dPredictor }) => {
1153
1154
  if (this.left) return;
1154
1155
  attach(
1155
1156
  new Physics2dPredictor(
@@ -1379,18 +1380,45 @@ var Session = class {
1379
1380
  });
1380
1381
  this.send(FrameType.HELLO, hello);
1381
1382
  }
1382
- /** Leaves for good: no reconnect, pending calls reject, the socket closes. */
1383
+ /**
1384
+ * Leaves for good: no reconnect, pending calls reject, the socket closes.
1385
+ *
1386
+ * The promise resolves when the socket reports its close (or immediately when there was
1387
+ * nothing open), so `await room.leave()` is a real wait rather than a guessed timeout. A
1388
+ * transport that never reports one is bounded by `LEAVE_CLOSE_TIMEOUT_MS` so the caller cannot
1389
+ * hang on it.
1390
+ */
1383
1391
  leave() {
1384
- if (this.left) return;
1392
+ if (this.left) return Promise.resolve();
1385
1393
  this.left = true;
1386
1394
  this.stopTimers();
1387
1395
  this.predictor?.free();
1388
1396
  this.rejectPending(new Error("irtio: left the room"));
1389
1397
  this.setStatus("closed");
1390
1398
  if (this.socket && this.joined) this.send(FrameType.LEAVE, new Uint8Array(0));
1391
- this.socket?.close();
1399
+ const socket = this.socket;
1392
1400
  this.socket = void 0;
1393
1401
  this.socketOpen = false;
1402
+ if (!socket) {
1403
+ return Promise.resolve();
1404
+ }
1405
+ return new Promise((resolve) => {
1406
+ let settled = false;
1407
+ let cancelTimeout;
1408
+ const finish = () => {
1409
+ if (settled) return;
1410
+ settled = true;
1411
+ cancelTimeout?.();
1412
+ resolve();
1413
+ };
1414
+ const previous = socket.onclose;
1415
+ socket.onclose = (info) => {
1416
+ previous?.(info);
1417
+ finish();
1418
+ };
1419
+ socket.close();
1420
+ if (!settled) cancelTimeout = this.scheduler.setTimeout(finish, LEAVE_CLOSE_TIMEOUT_MS);
1421
+ });
1394
1422
  }
1395
1423
  fatal(error) {
1396
1424
  this.left = true;
@@ -2865,6 +2893,8 @@ function makeRoom(session) {
2865
2893
  messages,
2866
2894
  stats,
2867
2895
  on: (event, cb) => session.on(event, cb),
2896
+ onAdd: ((collection, cb) => session.store.onEntityAdd(collection, cb)),
2897
+ onRemove: ((collection, cb) => session.store.onEntityRemove(collection, cb)),
2868
2898
  flush: () => session.flush(),
2869
2899
  leave: () => session.leave()
2870
2900
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-XFD6WYQW.js";
4
- import "./chunk-5Z4DHUA3.js";
3
+ } from "./chunk-VZFJXPVF.js";
4
+ import "./chunk-5OHONPVG.js";
5
5
 
6
6
  // src/physics.ts
7
7
  function readPose(body, into) {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-XFD6WYQW.js";
4
- import "./chunk-5Z4DHUA3.js";
3
+ } from "./chunk-VZFJXPVF.js";
4
+ import "./chunk-5OHONPVG.js";
5
5
 
6
6
  // src/physics-rapier2d.ts
7
7
  import {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-XFD6WYQW.js";
4
- import "./chunk-5Z4DHUA3.js";
3
+ } from "./chunk-VZFJXPVF.js";
4
+ import "./chunk-5OHONPVG.js";
5
5
 
6
6
  // src/physics2d.ts
7
7
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/client",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "irtio client SDK: joinRoom, owned-write batching, corrections, typed RPCs, presence, reconnection",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,8 +20,8 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "mediasoup-client": "^3.23.1",
23
- "@irtio/protocol": "0.8.0",
24
- "@irtio/schema": "0.8.0"
23
+ "@irtio/protocol": "0.9.0",
24
+ "@irtio/schema": "0.9.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@dimforge/rapier2d-compat": ">=0.20.0",