@irtio/client 0.1.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 ADDED
@@ -0,0 +1,1364 @@
1
+ // src/endpoint.ts
2
+ var DEFAULT_REGION = "eu";
3
+ var DEV_PORT = 7070;
4
+ function currentLocation() {
5
+ const loc = globalThis.location;
6
+ if (!loc || typeof loc.hostname !== "string" || typeof loc.href !== "string") return void 0;
7
+ return loc;
8
+ }
9
+ function isLocalHostname(hostname) {
10
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
11
+ }
12
+ function envUrl() {
13
+ const g = globalThis;
14
+ const fromWindow = g.IRT_URL;
15
+ if (typeof fromWindow === "string" && fromWindow !== "") return fromWindow;
16
+ const fromEnv = g.process?.env?.IRT_URL;
17
+ if (typeof fromEnv === "string" && fromEnv !== "") return fromEnv;
18
+ return void 0;
19
+ }
20
+ function resolveUrl(explicit) {
21
+ if (explicit !== void 0 && explicit !== "") return explicit;
22
+ const fromEnv = envUrl();
23
+ if (fromEnv !== void 0) return fromEnv;
24
+ const loc = currentLocation();
25
+ if (!loc || isLocalHostname(loc.hostname)) return `ws://localhost:${DEV_PORT}`;
26
+ return `wss://${DEFAULT_REGION}.irt.io`;
27
+ }
28
+ function isLocalUrl(url) {
29
+ try {
30
+ return isLocalHostname(new URL(url).hostname);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ function roomIdFrom(roomOrLink) {
36
+ const raw = roomOrLink.trim();
37
+ if (raw === "") return "";
38
+ if (!raw.includes("://") && !raw.includes("?") && !raw.includes("/")) return raw;
39
+ try {
40
+ const url = new URL(raw);
41
+ return url.searchParams.get("room") ?? "";
42
+ } catch {
43
+ return raw;
44
+ }
45
+ }
46
+ function roomIdFromLocation() {
47
+ const loc = currentLocation();
48
+ if (!loc) return "";
49
+ try {
50
+ return new URL(loc.href).searchParams.get("room") ?? "";
51
+ } catch {
52
+ return "";
53
+ }
54
+ }
55
+ function publishRoomToLocation(roomId) {
56
+ const loc = currentLocation();
57
+ const history = globalThis.history;
58
+ if (!loc) return void 0;
59
+ let href;
60
+ try {
61
+ const url = new URL(loc.href);
62
+ url.searchParams.set("room", roomId);
63
+ href = url.href;
64
+ } catch {
65
+ return void 0;
66
+ }
67
+ if (href !== loc.href && typeof history?.replaceState === "function") {
68
+ try {
69
+ history.replaceState(null, "", href);
70
+ } catch {
71
+ }
72
+ }
73
+ return href;
74
+ }
75
+ function linkForUrl(wsUrl, roomId) {
76
+ try {
77
+ const url = new URL(wsUrl);
78
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
79
+ url.pathname = "/";
80
+ url.search = "";
81
+ if (roomId !== "") url.searchParams.set("room", roomId);
82
+ return url.href;
83
+ } catch {
84
+ return roomId === "" ? wsUrl : `${wsUrl}?room=${roomId}`;
85
+ }
86
+ }
87
+
88
+ // src/session.ts
89
+ import {
90
+ FrameType,
91
+ PROTOCOL_VERSION,
92
+ RELAY_HASH8,
93
+ decodeCall,
94
+ decodeErrorPayload,
95
+ decodeFrame,
96
+ decodeMsg,
97
+ decodePong,
98
+ decodeReply,
99
+ decodeWelcome,
100
+ encodeCall,
101
+ encodeFrame,
102
+ encodeHello,
103
+ encodeMsg,
104
+ encodePing,
105
+ encodeReply,
106
+ errorByCode,
107
+ formatError,
108
+ readCorrectClientTick,
109
+ relaySchema,
110
+ rpcTable,
111
+ withBuiltins
112
+ } from "@irtio/protocol";
113
+ import {
114
+ ByteReader,
115
+ decodeDelta,
116
+ decodeDeltaFrom,
117
+ decodeFields,
118
+ encodeFields
119
+ } from "@irtio/schema";
120
+
121
+ // src/contract.ts
122
+ var E_CONNECT_FAILED = "E_CONNECT_FAILED";
123
+
124
+ // src/scheduler.ts
125
+ function rafPair() {
126
+ const g = globalThis;
127
+ if (typeof g.requestAnimationFrame !== "function") return void 0;
128
+ const request = g.requestAnimationFrame.bind(globalThis);
129
+ const cancel = (g.cancelAnimationFrame ?? (() => {
130
+ })).bind(globalThis);
131
+ return { request, cancel };
132
+ }
133
+ function defaultScheduler() {
134
+ const raf = rafPair();
135
+ const base = {
136
+ now: () => Date.now(),
137
+ setTimeout(fn, ms) {
138
+ const handle = setTimeout(fn, ms);
139
+ handle.unref?.();
140
+ return () => clearTimeout(handle);
141
+ }
142
+ };
143
+ if (!raf) return base;
144
+ return {
145
+ ...base,
146
+ frame(fn) {
147
+ const handle = raf.request(() => fn());
148
+ return () => raf.cancel(handle);
149
+ }
150
+ };
151
+ }
152
+
153
+ // src/state.ts
154
+ import {
155
+ applyDelta,
156
+ collectionDirty,
157
+ createDirtySet,
158
+ createFieldMask,
159
+ createState,
160
+ decodeSnapshot,
161
+ encodeDelta,
162
+ frozenProxy,
163
+ isDirtyEmpty,
164
+ track
165
+ } from "@irtio/schema";
166
+ var RESIM_DEPTH = 20;
167
+ function entityOf(state, name) {
168
+ return state[name];
169
+ }
170
+ function subtractMask(dst, src) {
171
+ for (const idx of src.fields) {
172
+ const srcNested = src.nested.get(idx);
173
+ const dstNested = dst.nested.get(idx);
174
+ if (srcNested && dstNested) {
175
+ subtractMask(dstNested, srcNested);
176
+ if (dstNested.fields.size === 0) {
177
+ dst.fields.delete(idx);
178
+ dst.nested.delete(idx);
179
+ }
180
+ continue;
181
+ }
182
+ dst.fields.delete(idx);
183
+ dst.nested.delete(idx);
184
+ }
185
+ }
186
+ var ClientCollectionImpl = class {
187
+ constructor(store, desc) {
188
+ this.store = store;
189
+ this.desc = desc;
190
+ }
191
+ store;
192
+ desc;
193
+ get plain() {
194
+ return entityOf(this.store.plain, this.desc.name);
195
+ }
196
+ get(id) {
197
+ return this.store.instance(this.desc, id);
198
+ }
199
+ has(id) {
200
+ return this.plain.has(id);
201
+ }
202
+ ownerOf(id) {
203
+ return this.plain.ownerOf(id);
204
+ }
205
+ get size() {
206
+ return this.plain.size;
207
+ }
208
+ ids() {
209
+ return this.plain.ids();
210
+ }
211
+ *[Symbol.iterator]() {
212
+ for (const id of [...this.plain.ids()]) {
213
+ yield [id, this.store.instance(this.desc, id)];
214
+ }
215
+ }
216
+ };
217
+ function withIndexSugar(collection) {
218
+ return new Proxy(collection, {
219
+ get(target, prop, receiver) {
220
+ if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
221
+ return target.get(prop);
222
+ },
223
+ has(target, prop) {
224
+ if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
225
+ return target.has(prop);
226
+ }
227
+ });
228
+ }
229
+ var ClientStore = class {
230
+ constructor(ext, meOf) {
231
+ this.ext = ext;
232
+ this.meOf = meOf;
233
+ for (const c of ext.collections) this.descs.set(c.name, c);
234
+ this.plain = createState(ext);
235
+ this.tracked = track(ext, this.plain);
236
+ this.view = this.buildView();
237
+ }
238
+ ext;
239
+ meOf;
240
+ plain;
241
+ tracked;
242
+ descs = /* @__PURE__ */ new Map();
243
+ frozen = /* @__PURE__ */ new WeakMap();
244
+ /** The object handed out as `room.state`; identity survives a resync. */
245
+ view;
246
+ /** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
247
+ pendingWrites = [];
248
+ /** The newest write tick evicted from `pendingWrites` — corrections older than it must snap. */
249
+ evictedThroughTick = 0;
250
+ // -- snapshot / resync -----------------------------------------------------
251
+ loadSnapshot(bytes) {
252
+ this.plain = decodeSnapshot(this.ext, bytes).state;
253
+ this.tracked = track(this.ext, this.plain);
254
+ this.pendingWrites.length = 0;
255
+ this.evictedThroughTick = 0;
256
+ }
257
+ /**
258
+ * Captures the field values of every pending owned write, keyed by collection then id then
259
+ * field name — call this **before** `loadSnapshot` on a resync, or the edit is gone once the
260
+ * old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
261
+ */
262
+ capturePendingWrites() {
263
+ const out = /* @__PURE__ */ new Map();
264
+ const me = this.meOf();
265
+ if (me === "") return out;
266
+ for (const [name, cd] of this.tracked.dirty) {
267
+ const desc = this.desc(name);
268
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
269
+ const coll = entityOf(this.plain, name);
270
+ for (const [id, rd] of cd.updated) {
271
+ if (rd.mask.fields.size === 0 || !coll.has(id) || coll.ownerOf(id) !== me) continue;
272
+ const value = coll.get(id);
273
+ const patch = {};
274
+ for (const idx of rd.mask.fields) {
275
+ const fname = desc.fields[idx]?.name;
276
+ if (fname !== void 0) patch[fname] = value[fname];
277
+ }
278
+ let byId = out.get(name);
279
+ if (!byId) {
280
+ byId = /* @__PURE__ */ new Map();
281
+ out.set(name, byId);
282
+ }
283
+ byId.set(id, patch);
284
+ }
285
+ }
286
+ return out;
287
+ }
288
+ /**
289
+ * Writes a `capturePendingWrites` snapshot into the freshly loaded `plain` (still the raw
290
+ * decoded values — no validation, they were already validated at the time of the original
291
+ * local write) and marks every owned field dirty so the next flush resends the real edit, not
292
+ * whatever the resync snapshot happened to carry for that field.
293
+ */
294
+ applyPendingWrites(pending) {
295
+ const me = this.meOf();
296
+ if (me === "" || pending.size === 0) return;
297
+ for (const [name, byId] of pending) {
298
+ const coll = entityOf(this.plain, name);
299
+ for (const [id, patch] of byId) {
300
+ if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
301
+ const rec = coll.get(id);
302
+ for (const [field, value] of Object.entries(patch)) {
303
+ rec[field] = Array.isArray(value) ? [...value] : value;
304
+ }
305
+ }
306
+ }
307
+ this.remarkOwned();
308
+ }
309
+ buildView() {
310
+ const view = {};
311
+ for (const c of this.ext.collections) {
312
+ if (c.kind === "entity") {
313
+ const facade = withIndexSugar(new ClientCollectionImpl(this, c));
314
+ Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
315
+ } else {
316
+ Object.defineProperty(view, c.name, {
317
+ get: () => this.freeze(this.plain[c.name], this.singletonHint(c)),
318
+ enumerable: true
319
+ });
320
+ }
321
+ }
322
+ return view;
323
+ }
324
+ // -- instances ------------------------------------------------------------
325
+ /** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
326
+ instance(desc, id) {
327
+ const coll = entityOf(this.plain, desc.name);
328
+ const value = coll.get(id);
329
+ if (value === void 0) return void 0;
330
+ if (!desc.serverOwned && coll.ownerOf(id) === this.meOf()) {
331
+ const trackedColl = this.tracked.state[desc.name];
332
+ return trackedColl ? trackedColl.get(id) : value;
333
+ }
334
+ return this.freeze(value, this.entityHint(desc, id, coll.ownerOf(id)));
335
+ }
336
+ freeze(value, hint) {
337
+ const cached = this.frozen.get(value);
338
+ if (cached !== void 0) return cached;
339
+ const proxy = frozenProxy(value, hint);
340
+ this.frozen.set(value, proxy);
341
+ return proxy;
342
+ }
343
+ entityHint(desc, id, owner) {
344
+ if (desc.serverOwned) {
345
+ return `${desc.name} is server-owned; change it with an RPC (room.call.\u2026)`;
346
+ }
347
+ const me = this.meOf();
348
+ if (owner === void 0 || owner === "") {
349
+ return `${desc.name}[${id}] is owned by the server; await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
350
+ }
351
+ return `${desc.name}[${id}] is owned by ${owner}, not by you (${me}); await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
352
+ }
353
+ singletonHint(desc) {
354
+ return `${desc.name} is a singleton and never client-owned; change it with an RPC (room.call.\u2026)`;
355
+ }
356
+ // -- inbound frames -------------------------------------------------------
357
+ /**
358
+ * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
359
+ * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
360
+ */
361
+ applyServerDelta(delta) {
362
+ applyDelta(this.ext, this.plain, this.withoutOwnedUpdates(delta));
363
+ this.replayOverAddEchoes(delta);
364
+ }
365
+ /**
366
+ * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
367
+ * client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
368
+ * `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
369
+ * Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
370
+ * write is clamped) still wins through the snap+replay path.
371
+ */
372
+ replayOverAddEchoes(delta) {
373
+ const me = this.meOf();
374
+ if (me === "" || this.pendingWrites.length === 0) return;
375
+ for (const dc of delta.collections) {
376
+ const desc = this.desc(dc.name);
377
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
378
+ for (const op of dc.ops) {
379
+ if (op.op !== "add" || op.owner !== me) continue;
380
+ for (const pw of this.pendingWrites) this.replayWrite(pw, dc.name, op.id);
381
+ }
382
+ }
383
+ }
384
+ withoutOwnedUpdates(delta) {
385
+ const me = this.meOf();
386
+ let changed = false;
387
+ const collections = [];
388
+ for (const dc of delta.collections) {
389
+ const desc = this.desc(dc.name);
390
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) {
391
+ collections.push(dc);
392
+ continue;
393
+ }
394
+ const coll = entityOf(this.plain, dc.name);
395
+ const ops = [];
396
+ for (const op of dc.ops) {
397
+ if (op.op === "add") {
398
+ const rewritten = this.preserveLocalWrites(desc, coll, op, me);
399
+ if (rewritten !== op) changed = true;
400
+ ops.push(rewritten);
401
+ continue;
402
+ }
403
+ if (op.op !== "update") {
404
+ ops.push(op);
405
+ continue;
406
+ }
407
+ const owner = op.owner ?? coll.ownerOf(op.id);
408
+ if (owner !== me) {
409
+ ops.push(op);
410
+ continue;
411
+ }
412
+ changed = true;
413
+ if (op.owner !== void 0) {
414
+ ops.push({
415
+ op: "update",
416
+ id: op.id,
417
+ owner: op.owner,
418
+ mask: createFieldMask(),
419
+ patch: {}
420
+ });
421
+ }
422
+ }
423
+ collections.push({ name: dc.name, ops });
424
+ }
425
+ return changed ? { tick: delta.tick, hash8: delta.hash8, collections } : delta;
426
+ }
427
+ /**
428
+ * An `add` for an instance this client will own, with its unflushed local field values folded
429
+ * back in. Returns `op` unchanged when there is nothing to preserve.
430
+ */
431
+ preserveLocalWrites(desc, coll, op, me) {
432
+ if (me === "" || op.owner !== me) return op;
433
+ const pending = this.tracked.dirty.get(desc.name)?.updated.get(op.id);
434
+ if (!pending || pending.mask.fields.size === 0) return op;
435
+ const local = coll.get(op.id);
436
+ if (!local) return op;
437
+ const value = { ...op.value };
438
+ for (const idx of pending.mask.fields) {
439
+ const field = desc.fields[idx]?.name;
440
+ if (field !== void 0) value[field] = local[field];
441
+ }
442
+ return { op: "add", id: op.id, owner: op.owner, value };
443
+ }
444
+ /**
445
+ * Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
446
+ * write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
447
+ * the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
448
+ * dirty mark survives so the next flush re-sends the *local* value, not the server's).
449
+ *
450
+ * Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
451
+ * `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
452
+ * and the local dirty marks it supersedes are cleared.
453
+ */
454
+ applyCorrection(delta, clientTick) {
455
+ if (clientTick !== void 0) {
456
+ while (this.pendingWrites.length > 0 && this.pendingWrites[0].tick <= clientTick) {
457
+ this.pendingWrites.shift();
458
+ }
459
+ }
460
+ const snapped = clientTick !== void 0 && clientTick < this.evictedThroughTick;
461
+ const replay = clientTick !== void 0 && !snapped;
462
+ const me = this.meOf();
463
+ const out = [];
464
+ const targets = [];
465
+ const unflushed = { tick: Number.MAX_SAFE_INTEGER, writes: /* @__PURE__ */ new Map() };
466
+ for (const dc of delta.collections) {
467
+ const desc = this.desc(dc.name);
468
+ if (!desc) continue;
469
+ for (const op of dc.ops) {
470
+ if (op.op !== "update") continue;
471
+ const pending = this.tracked.dirty.get(dc.name)?.updated.get(op.id);
472
+ if (pending) {
473
+ if (replay) {
474
+ const coll = entityOf(this.plain, dc.name);
475
+ const value = coll.get(op.id);
476
+ if (value && coll.ownerOf(op.id) === me && pending.mask.fields.size > 0) {
477
+ const patch = {};
478
+ for (const idx of pending.mask.fields) {
479
+ const fname = desc.fields[idx]?.name;
480
+ if (fname !== void 0) patch[fname] = value[fname];
481
+ }
482
+ let byId = unflushed.writes.get(dc.name);
483
+ if (!byId) {
484
+ byId = /* @__PURE__ */ new Map();
485
+ unflushed.writes.set(dc.name, byId);
486
+ }
487
+ byId.set(op.id, patch);
488
+ }
489
+ } else {
490
+ subtractMask(pending.mask, op.mask);
491
+ }
492
+ }
493
+ targets.push({ collection: dc.name, id: op.id });
494
+ out.push({
495
+ collection: dc.name,
496
+ id: op.id,
497
+ fields: [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0),
498
+ patch: op.patch,
499
+ tick: delta.tick,
500
+ ...clientTick !== void 0 ? { clientTick } : {},
501
+ replayed: 0,
502
+ snapped
503
+ });
504
+ }
505
+ }
506
+ applyDelta(this.ext, this.plain, delta);
507
+ let replayed = 0;
508
+ if (replay) {
509
+ for (const pw of [...this.pendingWrites, unflushed]) {
510
+ let applied = false;
511
+ for (const t of targets) {
512
+ if (this.replayWrite(pw, t.collection, t.id)) applied = true;
513
+ }
514
+ if (applied) replayed++;
515
+ }
516
+ }
517
+ return replayed === 0 ? out : out.map((op) => ({ ...op, replayed }));
518
+ }
519
+ desc(name) {
520
+ return this.descs.get(name);
521
+ }
522
+ // -- outbound writes ------------------------------------------------------
523
+ /** Cheap check for the flush loop: has anything been written locally since the last flush? */
524
+ get hasLocalWrites() {
525
+ return !isDirtyEmpty(this.tracked.dirty);
526
+ }
527
+ /**
528
+ * The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
529
+ * dirty set either way (a write to an instance that has since been removed or handed away is
530
+ * dropped, not retried forever).
531
+ */
532
+ takeWrite(tick) {
533
+ const dirty = this.tracked.flush();
534
+ const owned = this.ownedDirty(dirty);
535
+ if (owned.size === 0) return void 0;
536
+ this.retainPendingWrite(tick, owned);
537
+ return encodeDelta(this.ext, this.plain, owned, { tick });
538
+ }
539
+ /**
540
+ * Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
541
+ * `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
542
+ * Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
543
+ */
544
+ retainPendingWrite(tick, owned) {
545
+ const writes = /* @__PURE__ */ new Map();
546
+ for (const [name, cd] of owned) {
547
+ const desc = this.desc(name);
548
+ if (!desc) continue;
549
+ const coll = entityOf(this.plain, name);
550
+ for (const [id, rd] of cd.updated) {
551
+ const value = coll.get(id);
552
+ if (!value) continue;
553
+ const patch = {};
554
+ for (const idx of rd.mask.fields) {
555
+ const fname = desc.fields[idx]?.name;
556
+ if (fname === void 0) continue;
557
+ const v = value[fname];
558
+ patch[fname] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? { ...v } : v;
559
+ }
560
+ let byId = writes.get(name);
561
+ if (!byId) {
562
+ byId = /* @__PURE__ */ new Map();
563
+ writes.set(name, byId);
564
+ }
565
+ byId.set(id, patch);
566
+ }
567
+ }
568
+ if (writes.size === 0) return;
569
+ this.pendingWrites.push({ tick, writes });
570
+ while (this.pendingWrites.length > RESIM_DEPTH) {
571
+ const evicted = this.pendingWrites.shift();
572
+ if (evicted && evicted.tick > this.evictedThroughTick) this.evictedThroughTick = evicted.tick;
573
+ }
574
+ }
575
+ /** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
576
+ replayWrite(pw, collection, id) {
577
+ const patch = pw.writes.get(collection)?.get(id);
578
+ if (!patch) return false;
579
+ const me = this.meOf();
580
+ const coll = entityOf(this.plain, collection);
581
+ if (!coll.has(id) || coll.ownerOf(id) !== me) return false;
582
+ const rec = coll.get(id);
583
+ for (const [field, value] of Object.entries(patch)) {
584
+ rec[field] = Array.isArray(value) ? [...value] : typeof value === "object" && value !== null ? { ...value } : value;
585
+ }
586
+ return true;
587
+ }
588
+ /**
589
+ * Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
590
+ * worker restart) does not silently drop writes that were in flight.
591
+ */
592
+ remarkOwned() {
593
+ const me = this.meOf();
594
+ if (me === "") return;
595
+ for (const c of this.ext.collections) {
596
+ if (c.kind !== "entity" || c.serverOwned) continue;
597
+ const coll = entityOf(this.plain, c.name);
598
+ for (const id of [...coll.ids()]) {
599
+ if (coll.ownerOf(id) !== me) continue;
600
+ const rec = collectionDirty(this.tracked.dirty, c.name).updated;
601
+ const mask = createFieldMask();
602
+ for (const f of c.fields) mask.fields.add(f.index);
603
+ rec.set(id, { mask, owner: false });
604
+ }
605
+ }
606
+ }
607
+ /** Only update ops, only instances that still exist and are still mine, never the owner bit. */
608
+ ownedDirty(dirty) {
609
+ const me = this.meOf();
610
+ const out = createDirtySet();
611
+ if (me === "") return out;
612
+ for (const [name, cd] of dirty) {
613
+ const desc = this.desc(name);
614
+ if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
615
+ const coll = entityOf(this.plain, name);
616
+ for (const [id, rd] of cd.updated) {
617
+ if (rd.mask.fields.size === 0) continue;
618
+ if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
619
+ collectionDirty(out, name).updated.set(id, { mask: rd.mask, owner: false });
620
+ }
621
+ }
622
+ for (const [name, cd] of [...out]) {
623
+ if (cd.updated.size === 0) out.delete(name);
624
+ }
625
+ return out;
626
+ }
627
+ };
628
+
629
+ // src/transport.ts
630
+ function globalWebSocket() {
631
+ const ctor = globalThis.WebSocket;
632
+ if (!ctor) {
633
+ throw new Error(
634
+ "irtio: no global WebSocket. Node 22+ or a browser is required; pass `transport` to use another one."
635
+ );
636
+ }
637
+ return ctor;
638
+ }
639
+ async function toBytes(data) {
640
+ if (data instanceof Uint8Array) return data;
641
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
642
+ if (ArrayBuffer.isView(data)) {
643
+ const view = data;
644
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
645
+ }
646
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
647
+ return new Uint8Array(await data.arrayBuffer());
648
+ }
649
+ throw new Error(`irtio: unexpected websocket payload of type ${typeof data}`);
650
+ }
651
+ var WebSocketTransportSocket = class {
652
+ onopen = null;
653
+ onmessage = null;
654
+ onclose = null;
655
+ onerror = null;
656
+ ws;
657
+ constructor(url) {
658
+ this.ws = new (globalWebSocket())(url);
659
+ this.ws.binaryType = "arraybuffer";
660
+ this.ws.addEventListener("open", () => this.onopen?.());
661
+ this.ws.addEventListener("message", (event) => {
662
+ void toBytes(event.data).then(
663
+ (bytes) => this.onmessage?.(bytes),
664
+ (err) => this.onerror?.(err)
665
+ );
666
+ });
667
+ this.ws.addEventListener(
668
+ "close",
669
+ (event) => this.onclose?.({
670
+ ...event?.code !== void 0 ? { code: event.code } : {},
671
+ ...event?.reason !== void 0 ? { reason: event.reason } : {}
672
+ })
673
+ );
674
+ this.ws.addEventListener("error", (event) => this.onerror?.(event));
675
+ }
676
+ send(bytes) {
677
+ this.ws.send(bytes);
678
+ }
679
+ close() {
680
+ try {
681
+ this.ws.close();
682
+ } catch {
683
+ }
684
+ }
685
+ };
686
+ var webSocketTransport = {
687
+ connect(url) {
688
+ return new WebSocketTransportSocket(url);
689
+ }
690
+ };
691
+
692
+ // src/session.ts
693
+ var DEFAULT_WRITE_INTERVAL_MS = 50;
694
+ var PING_INTERVAL_MS = 1e4;
695
+ var CALL_TIMEOUT_MS = 1e4;
696
+ var BACKOFF_START_MS = 250;
697
+ var BACKOFF_MAX_MS = 5e3;
698
+ function isThenable(v) {
699
+ return typeof v === "object" && v !== null && typeof v.then === "function";
700
+ }
701
+ function nameOfCode(code) {
702
+ try {
703
+ return errorByCode(code).name;
704
+ } catch {
705
+ return "E_INTERNAL";
706
+ }
707
+ }
708
+ var Session = class {
709
+ constructor(options) {
710
+ this.options = options;
711
+ this.ext = options.schema ? withBuiltins(options.schema) : relaySchema;
712
+ this.rpcSchema = options.schema ?? relaySchema;
713
+ this.transport = options.transport ?? webSocketTransport;
714
+ this.scheduler = options.scheduler ?? defaultScheduler();
715
+ this.writeIntervalMs = options.writeIntervalMs ?? DEFAULT_WRITE_INTERVAL_MS;
716
+ this.impls = new Map(Object.entries(options.rpc ?? {}));
717
+ this.roomId = options.roomId;
718
+ this.role = options.role ?? "";
719
+ this.store = new ClientStore(this.ext, () => this.me);
720
+ }
721
+ options;
722
+ ext;
723
+ store;
724
+ /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
725
+ rpcSchema;
726
+ transport;
727
+ scheduler;
728
+ writeIntervalMs;
729
+ impls;
730
+ me = "";
731
+ role = "";
732
+ roomId;
733
+ tick = 0;
734
+ /**
735
+ * The client-local write counter (D19): +1 per flushed `WRITE`, stamped in its delta header.
736
+ * Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
737
+ * for this client survives the grace window too.
738
+ */
739
+ writeTick = 0;
740
+ /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
741
+ tickIntervalMs = 0;
742
+ rtt = 0;
743
+ status = "connecting";
744
+ socket;
745
+ resumeToken;
746
+ joined = false;
747
+ left = false;
748
+ attempt = 0;
749
+ reqId = 0;
750
+ pending = /* @__PURE__ */ new Map();
751
+ listeners = /* @__PURE__ */ new Map();
752
+ messageListeners = /* @__PURE__ */ new Set();
753
+ cancelFlush;
754
+ cancelPing;
755
+ cancelRetry;
756
+ linkOverride;
757
+ settleJoin;
758
+ failJoin;
759
+ // -------------------------------------------------------------------------
760
+ // Lifecycle
761
+ // -------------------------------------------------------------------------
762
+ /** Connects and resolves on the first `WELCOME`; a fatal `ERROR` before it rejects. */
763
+ start() {
764
+ return new Promise((resolve, reject) => {
765
+ this.settleJoin = resolve;
766
+ this.failJoin = reject;
767
+ this.options.onStatus?.("connecting");
768
+ this.emit("status", "connecting");
769
+ this.connect();
770
+ });
771
+ }
772
+ connect() {
773
+ if (this.left) return;
774
+ let socket;
775
+ try {
776
+ socket = this.transport.connect(this.options.url);
777
+ } catch (err) {
778
+ this.transportFailed(err);
779
+ return;
780
+ }
781
+ this.socket = socket;
782
+ socket.onopen = () => {
783
+ if (this.socket === socket) this.sendHello();
784
+ };
785
+ socket.onmessage = (bytes) => {
786
+ if (this.socket === socket) this.onFrame(bytes);
787
+ };
788
+ socket.onclose = (info) => {
789
+ if (this.socket === socket) this.onSocketClosed(info);
790
+ };
791
+ socket.onerror = (err) => {
792
+ if (this.socket === socket && !this.joined) this.transportFailed(err);
793
+ };
794
+ }
795
+ transportFailed(err) {
796
+ const message = err instanceof Error ? err.message : `websocket error (${String(err)})`;
797
+ if (!this.joined) {
798
+ this.emit("error", { code: E_CONNECT_FAILED, message, fatal: true });
799
+ this.fatal(
800
+ new Error(
801
+ `${E_CONNECT_FAILED}: cannot reach ${this.options.url}: ${message} (the room was never joined)`
802
+ )
803
+ );
804
+ return;
805
+ }
806
+ this.emit("error", { code: "E_INTERNAL", message, fatal: false });
807
+ }
808
+ sendHello() {
809
+ const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
810
+ const hello = encodeHello({
811
+ protocolVersion: PROTOCOL_VERSION,
812
+ credential: { kind: "key", key: this.options.key },
813
+ roomId: this.roomId,
814
+ schemaHash8: hash8,
815
+ ...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
816
+ ...this.options.role !== void 0 ? { role: this.options.role } : {},
817
+ ...this.options.name !== void 0 ? { name: this.options.name } : {}
818
+ });
819
+ this.send(FrameType.HELLO, hello);
820
+ }
821
+ /** Leaves for good: no reconnect, pending calls reject, the socket closes. */
822
+ leave() {
823
+ if (this.left) return;
824
+ this.left = true;
825
+ this.stopTimers();
826
+ this.rejectPending(new Error("irtio: left the room"));
827
+ this.setStatus("closed");
828
+ if (this.socket && this.joined) this.send(FrameType.LEAVE, new Uint8Array(0));
829
+ this.socket?.close();
830
+ this.socket = void 0;
831
+ }
832
+ fatal(error) {
833
+ this.left = true;
834
+ this.stopTimers();
835
+ this.rejectPending(error);
836
+ this.setStatus("closed");
837
+ this.socket?.close();
838
+ this.socket = void 0;
839
+ const fail = this.failJoin;
840
+ this.settleJoin = void 0;
841
+ this.failJoin = void 0;
842
+ fail?.(error);
843
+ }
844
+ onSocketClosed(info) {
845
+ this.socket = void 0;
846
+ this.stopTimers();
847
+ if (!this.joined) {
848
+ const detail = info?.code !== void 0 ? ` (code ${info.code}${info.reason ? `: ${info.reason}` : ""})` : "";
849
+ this.fatal(
850
+ new Error(
851
+ `${E_CONNECT_FAILED}: ${this.options.url} closed the connection before the room was joined${detail}`
852
+ )
853
+ );
854
+ return;
855
+ }
856
+ this.rejectPending(new Error("irtio: connection lost before the reply arrived"));
857
+ if (this.left) return;
858
+ this.setStatus("reconnecting");
859
+ const delay = Math.min(BACKOFF_START_MS * 2 ** this.attempt, BACKOFF_MAX_MS);
860
+ this.attempt++;
861
+ this.cancelRetry = this.scheduler.setTimeout(() => {
862
+ this.cancelRetry = void 0;
863
+ this.connect();
864
+ }, delay);
865
+ }
866
+ stopTimers() {
867
+ this.cancelFlush?.();
868
+ this.cancelFlush = void 0;
869
+ this.cancelPing?.();
870
+ this.cancelPing = void 0;
871
+ this.cancelRetry?.();
872
+ this.cancelRetry = void 0;
873
+ }
874
+ // -------------------------------------------------------------------------
875
+ // Frames
876
+ // -------------------------------------------------------------------------
877
+ send(type, payload) {
878
+ const frame = encodeFrame(type, payload);
879
+ this.options.onFrame?.("out", type, frame);
880
+ this.socket?.send(frame);
881
+ }
882
+ onFrame(bytes) {
883
+ let type;
884
+ let payload;
885
+ try {
886
+ const frame = decodeFrame(bytes);
887
+ type = frame.type;
888
+ payload = frame.payload;
889
+ } catch (err) {
890
+ this.localError(err);
891
+ return;
892
+ }
893
+ this.options.onFrame?.("in", type, bytes);
894
+ try {
895
+ switch (type) {
896
+ case FrameType.WELCOME:
897
+ this.onWelcome(payload);
898
+ return;
899
+ case FrameType.DELTA:
900
+ this.onDelta(payload);
901
+ return;
902
+ case FrameType.CORRECT:
903
+ this.onCorrect(payload);
904
+ return;
905
+ case FrameType.ERROR:
906
+ this.onError(payload);
907
+ return;
908
+ case FrameType.PONG:
909
+ this.onPong(payload);
910
+ return;
911
+ case FrameType.CALL:
912
+ this.onCall(payload);
913
+ return;
914
+ case FrameType.REPLY:
915
+ this.onReply(payload);
916
+ return;
917
+ default:
918
+ if (type === FrameType.MSG) this.onMsg(payload);
919
+ return;
920
+ }
921
+ } catch (err) {
922
+ this.localError(err);
923
+ }
924
+ }
925
+ /** A frame we could not decode or apply. Reported, never fatal: the stream may recover. */
926
+ localError(err) {
927
+ this.emit("error", {
928
+ code: "E_INTERNAL",
929
+ message: err instanceof Error ? err.message : String(err),
930
+ fatal: false
931
+ });
932
+ }
933
+ onWelcome(payload) {
934
+ const welcome = decodeWelcome(payload);
935
+ const pending = this.store.capturePendingWrites();
936
+ this.me = welcome.clientId;
937
+ this.role = welcome.role;
938
+ this.tick = welcome.tick;
939
+ this.resumeToken = welcome.resumeToken;
940
+ this.tickIntervalMs = welcome.tickIntervalMs;
941
+ if (welcome.roomId !== "") this.roomId = welcome.roomId;
942
+ this.store.loadSnapshot(welcome.snapshot);
943
+ this.store.applyPendingWrites(pending);
944
+ this.attempt = 0;
945
+ if (this.options.publishLocation) {
946
+ this.linkOverride = publishRoomToLocation(this.roomId);
947
+ }
948
+ this.joined = true;
949
+ this.setStatus("connected");
950
+ this.startTimers();
951
+ const settle = this.settleJoin;
952
+ this.settleJoin = void 0;
953
+ this.failJoin = void 0;
954
+ settle?.(this);
955
+ }
956
+ onDelta(payload) {
957
+ const delta = decodeDelta(this.ext, payload);
958
+ this.tick = delta.tick;
959
+ this.store.applyServerDelta(delta);
960
+ }
961
+ onCorrect(payload) {
962
+ const r = new ByteReader(payload);
963
+ const delta = decodeDeltaFrom(this.ext, r);
964
+ const clientTick = readCorrectClientTick(r);
965
+ this.tick = delta.tick;
966
+ for (const op of this.store.applyCorrection(delta, clientTick)) {
967
+ this.emit("correct", op);
968
+ }
969
+ }
970
+ onError(payload) {
971
+ const e = decodeErrorPayload(payload);
972
+ const code = nameOfCode(e.code);
973
+ if (code === "E_STARTING") {
974
+ this.emit("error", { code, message: e.message, fatal: false });
975
+ this.setStatus("starting");
976
+ return;
977
+ }
978
+ if (code === "E_RESUME_EXPIRED" && this.resumeToken !== void 0) {
979
+ this.resumeToken = void 0;
980
+ this.emit("error", { code, message: e.message, fatal: false });
981
+ return;
982
+ }
983
+ this.emit("error", { code, message: e.message, fatal: e.fatal });
984
+ if (e.fatal) this.fatal(new Error(`${code}: ${e.message}`));
985
+ }
986
+ onPong(payload) {
987
+ const pong = decodePong(payload);
988
+ this.rtt = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
989
+ if (pong.serverTick > this.tick) this.tick = pong.serverTick;
990
+ }
991
+ onMsg(payload) {
992
+ const msg = decodeMsg(payload);
993
+ const from = msg.target.kind === "client" ? msg.target.clientId : "server";
994
+ const bytes = msg.payload.slice();
995
+ for (const cb of [...this.messageListeners]) cb(from, bytes);
996
+ }
997
+ // -------------------------------------------------------------------------
998
+ // Timers
999
+ // -------------------------------------------------------------------------
1000
+ startTimers() {
1001
+ if (!this.cancelFlush) this.armFlush();
1002
+ if (!this.cancelPing) this.armPing();
1003
+ }
1004
+ /**
1005
+ * The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
1006
+ * everywhere else, with `writeIntervalMs` as the hard cap in both. Every owned field written
1007
+ * inside a window leaves as a single `WRITE`.
1008
+ */
1009
+ armFlush() {
1010
+ let capCancel;
1011
+ let frameCancel;
1012
+ const fire = () => {
1013
+ capCancel?.();
1014
+ frameCancel?.();
1015
+ capCancel = void 0;
1016
+ frameCancel = void 0;
1017
+ this.cancelFlush = void 0;
1018
+ if (this.left) return;
1019
+ this.flush();
1020
+ this.armFlush();
1021
+ };
1022
+ capCancel = this.scheduler.setTimeout(fire, this.writeIntervalMs);
1023
+ if (this.scheduler.frame) frameCancel = this.scheduler.frame(fire);
1024
+ this.cancelFlush = () => {
1025
+ capCancel?.();
1026
+ frameCancel?.();
1027
+ };
1028
+ }
1029
+ armPing() {
1030
+ this.cancelPing = this.scheduler.setTimeout(() => {
1031
+ this.cancelPing = void 0;
1032
+ if (this.left || !this.socket) return;
1033
+ this.send(FrameType.PING, encodePing({ t: this.scheduler.now() >>> 0 }));
1034
+ this.armPing();
1035
+ }, PING_INTERVAL_MS);
1036
+ }
1037
+ /** Sends every pending owned write now. */
1038
+ flush() {
1039
+ if (!this.socket || !this.joined) return;
1040
+ if (!this.store.hasLocalWrites) return;
1041
+ const payload = this.store.takeWrite(this.writeTick + 1);
1042
+ if (payload) {
1043
+ this.writeTick++;
1044
+ this.send(FrameType.WRITE, payload);
1045
+ }
1046
+ }
1047
+ // -------------------------------------------------------------------------
1048
+ // RPCs
1049
+ // -------------------------------------------------------------------------
1050
+ descOf(name) {
1051
+ const desc = rpcTable(this.rpcSchema).find((r) => r.name === name);
1052
+ if (!desc) throw new Error(`irtio: unknown rpc ${JSON.stringify(name)}`);
1053
+ return desc;
1054
+ }
1055
+ call(name, params = {}) {
1056
+ let desc;
1057
+ let encoded;
1058
+ try {
1059
+ desc = this.descOf(name);
1060
+ encoded = encodeFields(desc.params, params);
1061
+ } catch (err) {
1062
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1063
+ }
1064
+ if (!this.socket || this.left) {
1065
+ return Promise.reject(new Error(`irtio: ${name} failed \u2014 not connected`));
1066
+ }
1067
+ const reqId = ++this.reqId;
1068
+ return new Promise((resolve, reject) => {
1069
+ const cancelTimeout = this.scheduler.setTimeout(() => {
1070
+ if (this.pending.delete(reqId)) {
1071
+ reject(new Error(formatError("E_RPC_TIMEOUT", { name })));
1072
+ }
1073
+ }, CALL_TIMEOUT_MS);
1074
+ this.pending.set(reqId, {
1075
+ name,
1076
+ returns: desc.returns,
1077
+ resolve,
1078
+ reject,
1079
+ cancelTimeout
1080
+ });
1081
+ this.send(FrameType.CALL, encodeCall({ reqId, rpcId: desc.index, params: encoded }));
1082
+ });
1083
+ }
1084
+ async requestOwnership(entity, id) {
1085
+ const result = await this.call("requestOwnership", { entity, id });
1086
+ return result?.granted === true;
1087
+ }
1088
+ onReply(payload) {
1089
+ const reply = decodeReply(payload);
1090
+ const p = this.pending.get(reply.reqId);
1091
+ if (!p) return;
1092
+ this.pending.delete(reply.reqId);
1093
+ p.cancelTimeout();
1094
+ if (!reply.ok) {
1095
+ p.reject(new Error(reply.error));
1096
+ return;
1097
+ }
1098
+ if (!p.returns) {
1099
+ p.resolve(void 0);
1100
+ return;
1101
+ }
1102
+ try {
1103
+ p.resolve(decodeFields(p.returns, reply.result));
1104
+ } catch (err) {
1105
+ p.reject(new Error(`${p.name}: bad reply payload: ${String(err)}`));
1106
+ }
1107
+ }
1108
+ onCall(payload) {
1109
+ const call = decodeCall(payload);
1110
+ const desc = rpcTable(this.rpcSchema).find((r) => r.index === call.rpcId);
1111
+ if (!desc) {
1112
+ this.replyError(call.reqId, formatError("E_RPC_UNKNOWN", { name: String(call.rpcId) }));
1113
+ return;
1114
+ }
1115
+ let params;
1116
+ try {
1117
+ params = decodeFields(desc.params, call.params);
1118
+ } catch (err) {
1119
+ this.replyError(
1120
+ call.reqId,
1121
+ formatError("E_RPC_BAD_PARAMS", { name: desc.name, reason: String(err) })
1122
+ );
1123
+ return;
1124
+ }
1125
+ const impl = this.impls.get(desc.name);
1126
+ if (!impl) {
1127
+ this.replyError(
1128
+ call.reqId,
1129
+ formatError("E_RPC_REJECTED", {
1130
+ name: desc.name,
1131
+ reason: "no client implementation \u2014 pass it in joinRoom({ rpc })"
1132
+ })
1133
+ );
1134
+ return;
1135
+ }
1136
+ let result;
1137
+ try {
1138
+ result = impl(params);
1139
+ } catch (err) {
1140
+ this.replyError(call.reqId, err instanceof Error ? err.message : String(err));
1141
+ return;
1142
+ }
1143
+ if (isThenable(result)) {
1144
+ Promise.resolve(result).then(
1145
+ (v) => this.replyOk(desc, call.reqId, v),
1146
+ (err) => this.replyError(call.reqId, err instanceof Error ? err.message : String(err))
1147
+ );
1148
+ return;
1149
+ }
1150
+ this.replyOk(desc, call.reqId, result);
1151
+ }
1152
+ replyOk(desc, reqId, result) {
1153
+ let bytes;
1154
+ try {
1155
+ bytes = desc.returns ? encodeFields(desc.returns, result ?? {}) : new Uint8Array(0);
1156
+ } catch (err) {
1157
+ this.replyError(reqId, err instanceof Error ? err.message : String(err));
1158
+ return;
1159
+ }
1160
+ this.send(FrameType.REPLY, encodeReply({ reqId, ok: true, result: bytes }));
1161
+ }
1162
+ replyError(reqId, error) {
1163
+ this.send(FrameType.REPLY, encodeReply({ reqId, ok: false, error }));
1164
+ }
1165
+ rejectPending(error) {
1166
+ for (const [reqId, p] of [...this.pending]) {
1167
+ this.pending.delete(reqId);
1168
+ p.cancelTimeout();
1169
+ p.reject(error);
1170
+ }
1171
+ }
1172
+ // -------------------------------------------------------------------------
1173
+ // Messages, presence, events
1174
+ // -------------------------------------------------------------------------
1175
+ message(target, bytes) {
1176
+ const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
1177
+ this.send(FrameType.MSG, encodeMsg({ target: wire, payload: bytes }));
1178
+ }
1179
+ onMessage(cb) {
1180
+ this.messageListeners.add(cb);
1181
+ return () => {
1182
+ this.messageListeners.delete(cb);
1183
+ };
1184
+ }
1185
+ get clients() {
1186
+ const coll = this.store.plain.clients;
1187
+ if (!coll) return [];
1188
+ const out = [];
1189
+ for (const [, value] of coll) out.push(value);
1190
+ return out;
1191
+ }
1192
+ get link() {
1193
+ return this.linkOverride ?? linkForUrl(this.options.url, this.roomId);
1194
+ }
1195
+ on(event, cb) {
1196
+ let set = this.listeners.get(event);
1197
+ if (!set) {
1198
+ set = /* @__PURE__ */ new Set();
1199
+ this.listeners.set(event, set);
1200
+ }
1201
+ set.add(cb);
1202
+ return () => {
1203
+ set?.delete(cb);
1204
+ };
1205
+ }
1206
+ emit(event, value) {
1207
+ const set = this.listeners.get(event);
1208
+ if (!set) return;
1209
+ for (const cb of [...set]) cb(value);
1210
+ }
1211
+ setStatus(status) {
1212
+ if (this.status === status) return;
1213
+ this.status = status;
1214
+ this.options.onStatus?.(status);
1215
+ this.emit("status", status);
1216
+ }
1217
+ };
1218
+ function resolveKey(explicit, schema, url) {
1219
+ if (explicit !== void 0 && explicit !== "") return explicit;
1220
+ if (schema?.project !== void 0 && schema.project !== "") return schema.project;
1221
+ if (isLocalUrl(url)) return "dev";
1222
+ throw new Error(
1223
+ "irtio: this schema has no project id. Run `npx irtio init` (it writes irtio.json and puts the project id in irtio/schema.ts), or pass `key` to joinRoom."
1224
+ );
1225
+ }
1226
+
1227
+ // src/index.ts
1228
+ function callProxy(session) {
1229
+ const cache = /* @__PURE__ */ new Map();
1230
+ return new Proxy(/* @__PURE__ */ Object.create(null), {
1231
+ get(_target, prop) {
1232
+ if (typeof prop !== "string") return void 0;
1233
+ let fn = cache.get(prop);
1234
+ if (!fn) {
1235
+ fn = prop === "requestOwnership" ? (entity, id) => session.requestOwnership(entity, id) : (params) => session.call(prop, params ?? {});
1236
+ cache.set(prop, fn);
1237
+ }
1238
+ return fn;
1239
+ }
1240
+ });
1241
+ }
1242
+ async function joinRoom(schema, options = {}) {
1243
+ const url = resolveUrl(options.url);
1244
+ const key = resolveKey(options.key, schema, url);
1245
+ const explicitRoom = options.room !== void 0;
1246
+ const fromLocation = !explicitRoom && currentLocation() !== void 0;
1247
+ const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
1248
+ const session = new Session({
1249
+ schema,
1250
+ url,
1251
+ key,
1252
+ roomId,
1253
+ publishLocation: fromLocation,
1254
+ role: options.role,
1255
+ name: options.name,
1256
+ rpc: options.rpc,
1257
+ writeIntervalMs: options.writeIntervalMs,
1258
+ transport: options.transport,
1259
+ scheduler: options.scheduler,
1260
+ onFrame: options.onFrame,
1261
+ onStatus: options.onStatus
1262
+ });
1263
+ await session.start();
1264
+ return makeRoom(session);
1265
+ }
1266
+ function makeRoom(session) {
1267
+ const call = callProxy(session);
1268
+ const room = {
1269
+ get me() {
1270
+ return session.me;
1271
+ },
1272
+ get id() {
1273
+ return session.roomId;
1274
+ },
1275
+ get link() {
1276
+ return session.link;
1277
+ },
1278
+ get tick() {
1279
+ return session.tick;
1280
+ },
1281
+ get status() {
1282
+ return session.status;
1283
+ },
1284
+ get rtt() {
1285
+ return session.rtt;
1286
+ },
1287
+ get state() {
1288
+ return session.store.view;
1289
+ },
1290
+ get clients() {
1291
+ return session.clients;
1292
+ },
1293
+ call,
1294
+ requestOwnership: (entity, id) => session.requestOwnership(entity, id),
1295
+ message: (target, bytes) => session.message(target, bytes),
1296
+ onMessage: (cb) => session.onMessage(cb),
1297
+ on: (event, cb) => session.on(event, cb),
1298
+ flush: () => session.flush(),
1299
+ leave: () => session.leave()
1300
+ };
1301
+ return room;
1302
+ }
1303
+ async function joinRelay(options = {}) {
1304
+ const url = resolveUrl(options.url);
1305
+ const explicitRoom = options.room !== void 0;
1306
+ const fromLocation = !explicitRoom && currentLocation() !== void 0;
1307
+ const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
1308
+ const session = new Session({
1309
+ schema: void 0,
1310
+ url,
1311
+ key: options.key ?? resolveKey(void 0, void 0, url),
1312
+ roomId,
1313
+ publishLocation: fromLocation,
1314
+ role: options.role,
1315
+ name: options.name,
1316
+ transport: options.transport,
1317
+ scheduler: options.scheduler,
1318
+ onFrame: options.onFrame,
1319
+ onStatus: options.onStatus
1320
+ });
1321
+ await session.start();
1322
+ return {
1323
+ get me() {
1324
+ return session.me;
1325
+ },
1326
+ get id() {
1327
+ return session.roomId;
1328
+ },
1329
+ get link() {
1330
+ return session.link;
1331
+ },
1332
+ get status() {
1333
+ return session.status;
1334
+ },
1335
+ get rtt() {
1336
+ return session.rtt;
1337
+ },
1338
+ get clients() {
1339
+ return session.clients;
1340
+ },
1341
+ message: (target, bytes) => session.message(target, bytes),
1342
+ onMessage: (cb) => session.onMessage(cb),
1343
+ on: (event, cb) => session.on(event, cb),
1344
+ leave: () => session.leave()
1345
+ };
1346
+ }
1347
+ export {
1348
+ CALL_TIMEOUT_MS,
1349
+ ClientStore,
1350
+ DEFAULT_REGION,
1351
+ DEFAULT_WRITE_INTERVAL_MS,
1352
+ DEV_PORT,
1353
+ E_CONNECT_FAILED,
1354
+ PING_INTERVAL_MS,
1355
+ RESIM_DEPTH,
1356
+ Session,
1357
+ defaultScheduler,
1358
+ joinRelay,
1359
+ joinRoom,
1360
+ linkForUrl,
1361
+ resolveUrl,
1362
+ roomIdFrom,
1363
+ webSocketTransport
1364
+ };