@irtio/runtime 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.
@@ -0,0 +1,774 @@
1
+ import {
2
+ RoomCore,
3
+ visibleNames
4
+ } from "../chunk-X5S364FY.js";
5
+
6
+ // src/test/clock.ts
7
+ var FakeClock = class {
8
+ /** Current fake time in ms. Starts at 0. */
9
+ time = 0;
10
+ timers = /* @__PURE__ */ new Map();
11
+ nextId = 1;
12
+ now() {
13
+ return this.time;
14
+ }
15
+ setTimeout(fn, ms) {
16
+ const id = this.nextId++;
17
+ this.timers.set(id, { id, at: this.time + Math.max(0, ms), fn });
18
+ return id;
19
+ }
20
+ clearTimeout(handle) {
21
+ if (typeof handle === "number") this.timers.delete(handle);
22
+ }
23
+ /** Number of timers still armed (a room that is ticking always has at least one). */
24
+ get pending() {
25
+ return this.timers.size;
26
+ }
27
+ /**
28
+ * Advances to `time + ms`, firing every timer that comes due in order (earliest `at` first,
29
+ * ties broken by creation order). A timer that arms another timer inside the window fires too.
30
+ */
31
+ advance(ms) {
32
+ const target = this.time + Math.max(0, ms);
33
+ for (; ; ) {
34
+ let next;
35
+ for (const t of this.timers.values()) {
36
+ if (t.at > target) continue;
37
+ if (!next || t.at < next.at || t.at === next.at && t.id < next.id) next = t;
38
+ }
39
+ if (!next) break;
40
+ this.timers.delete(next.id);
41
+ if (next.at > this.time) this.time = next.at;
42
+ next.fn();
43
+ }
44
+ if (target > this.time) this.time = target;
45
+ }
46
+ };
47
+
48
+ // src/test/host.ts
49
+ var HarnessHost = class {
50
+ constructor(clock) {
51
+ this.clock = clock;
52
+ }
53
+ clock;
54
+ logs = [];
55
+ kicks = [];
56
+ /** `room.close()` reasons, in order. */
57
+ closes = [];
58
+ /** `host.crashed()` reasons, in order. */
59
+ crashes = [];
60
+ /** How many times the runtime asked to hibernate. */
61
+ sleeps = 0;
62
+ /** Total frames handed to the host. */
63
+ framesSent = 0;
64
+ bytesSent = 0;
65
+ sentByClient = /* @__PURE__ */ new Map();
66
+ /** Set by the harness: where an outbound frame goes. */
67
+ onSend = () => {
68
+ };
69
+ /** `true` once the runtime has asked to hibernate at least once. */
70
+ get slept() {
71
+ return this.sleeps > 0;
72
+ }
73
+ /** `true` once `room.close()` has been called. */
74
+ get closed() {
75
+ return this.closes.length > 0;
76
+ }
77
+ now() {
78
+ return this.clock.now();
79
+ }
80
+ setTimeout(fn, ms) {
81
+ return this.clock.setTimeout(fn, ms);
82
+ }
83
+ clearTimeout(handle) {
84
+ this.clock.clearTimeout(handle);
85
+ }
86
+ send(clientId, frame) {
87
+ this.framesSent++;
88
+ this.bytesSent += frame.length;
89
+ let counts = this.sentByClient.get(clientId);
90
+ if (!counts) {
91
+ counts = { frames: 0, bytes: 0 };
92
+ this.sentByClient.set(clientId, counts);
93
+ }
94
+ counts.frames++;
95
+ counts.bytes += frame.length;
96
+ this.onSend(clientId, frame);
97
+ }
98
+ kick(clientId, code, reason) {
99
+ this.kicks.push({ clientId, code, reason });
100
+ }
101
+ close(reason) {
102
+ this.closes.push(reason);
103
+ }
104
+ sleep() {
105
+ this.sleeps++;
106
+ }
107
+ log(level, args) {
108
+ this.logs.push({ level, args });
109
+ }
110
+ crashed(reason) {
111
+ this.crashes.push(reason);
112
+ }
113
+ /** Logged messages of one level, flattened to strings (handy in assertions). */
114
+ logsOf(level) {
115
+ return this.logs.filter((l) => l.level === level).map((l) => l.args.map(String).join(" "));
116
+ }
117
+ };
118
+
119
+ // src/test/harness.ts
120
+ import {
121
+ defaultRecord
122
+ } from "@irtio/schema";
123
+
124
+ // src/test/fake-client.ts
125
+ import {
126
+ FrameType,
127
+ decodeCall,
128
+ decodeMsg,
129
+ decodeReply,
130
+ encodeCall,
131
+ encodeFrame,
132
+ encodeMsg,
133
+ encodeReply,
134
+ rpcTable
135
+ } from "@irtio/protocol";
136
+ import {
137
+ applyDelta,
138
+ cloneValue,
139
+ createDirtySet,
140
+ createState,
141
+ decodeDelta,
142
+ decodeFields,
143
+ decodeSnapshot,
144
+ encodeDelta,
145
+ encodeFields,
146
+ markField
147
+ } from "@irtio/schema";
148
+ function structFields(t) {
149
+ return Object.entries(t.fields).map(([name, type]) => ({ name, type }));
150
+ }
151
+ function fieldPath(c, names) {
152
+ const path = [];
153
+ let fields = c.fields.map((f) => ({
154
+ name: f.name,
155
+ type: f.type
156
+ }));
157
+ for (const n of names) {
158
+ const idx = fields.findIndex((f) => f.name === n);
159
+ if (idx < 0) throw new Error(`write: ${c.name} has no field ${JSON.stringify(n)}`);
160
+ path.push(idx);
161
+ const t = fields[idx]?.type;
162
+ fields = t && t.kind === "struct" ? structFields(t) : [];
163
+ }
164
+ return path;
165
+ }
166
+ function isPlainObject(v) {
167
+ return typeof v === "object" && v !== null && !Array.isArray(v);
168
+ }
169
+ function mergePatch(target, patch) {
170
+ for (const [k, v] of Object.entries(patch)) {
171
+ const cur = target[k];
172
+ if (isPlainObject(v) && isPlainObject(cur)) mergePatch(cur, v);
173
+ else target[k] = v;
174
+ }
175
+ }
176
+ function patchLeaves(patch, prefix = []) {
177
+ const out = [];
178
+ for (const [k, v] of Object.entries(patch)) {
179
+ if (isPlainObject(v)) out.push(...patchLeaves(v, [...prefix, k]));
180
+ else out.push([...prefix, k]);
181
+ }
182
+ return out;
183
+ }
184
+ function entityOf(state, name) {
185
+ return state[name];
186
+ }
187
+ function isThenable(v) {
188
+ return typeof v === "object" && v !== null && typeof v.then === "function";
189
+ }
190
+ var FakeClientImpl = class {
191
+ constructor(bridge, id, options) {
192
+ this.bridge = bridge;
193
+ this.id = id;
194
+ this.role = options.role ?? "";
195
+ this.name = options.name ?? "";
196
+ this.joinTick = 0;
197
+ this.view = createState(bridge.ext);
198
+ }
199
+ bridge;
200
+ id;
201
+ role;
202
+ name;
203
+ connected = true;
204
+ view;
205
+ joinTick;
206
+ calls = [];
207
+ messages = [];
208
+ corrections = [];
209
+ frames = [];
210
+ impls = /* @__PURE__ */ new Map();
211
+ pending = /* @__PURE__ */ new Map();
212
+ nextReqId = 1;
213
+ /**
214
+ * Joins the room. Separate from the constructor so the harness can register the client before
215
+ * any frame the join produces could be delivered to it.
216
+ */
217
+ initialJoin(options) {
218
+ const result = this.bridge.join(this.id, options);
219
+ this.role = result.role;
220
+ this.joinTick = result.tick;
221
+ this.loadSnapshot(result.snapshot);
222
+ }
223
+ // -------------------------------------------------------------------------
224
+ // View
225
+ // -------------------------------------------------------------------------
226
+ loadSnapshot(bytes) {
227
+ this.view = decodeSnapshot(this.bridge.ext, bytes).state;
228
+ }
229
+ entity(name) {
230
+ return entityOf(this.view, name);
231
+ }
232
+ get(entity, id) {
233
+ return this.entity(entity).get(id);
234
+ }
235
+ // -------------------------------------------------------------------------
236
+ // Outbound
237
+ // -------------------------------------------------------------------------
238
+ descOf(name) {
239
+ const desc = rpcTable(this.bridge.schema).find((r) => r.name === name);
240
+ if (!desc) throw new Error(`FakeClient: unknown rpc ${JSON.stringify(name)}`);
241
+ return desc;
242
+ }
243
+ write(entity, id, patch) {
244
+ const c = this.bridge.ext.collection(entity);
245
+ if (!c || c.kind !== "entity") {
246
+ throw new Error(`write: ${JSON.stringify(entity)} is not an entity collection in the view`);
247
+ }
248
+ const current = this.get(entity, id);
249
+ if (!current) throw new Error(`write: ${entity}[${id}] is not in ${this.id}'s view`);
250
+ const value = cloneValue(current);
251
+ mergePatch(value, patch);
252
+ const scratch = createState(this.bridge.ext);
253
+ entityOf(scratch, entity).add(id, value, { owner: "" });
254
+ const dirty = createDirtySet();
255
+ for (const names of patchLeaves(patch)) markField(dirty, entity, id, fieldPath(c, names));
256
+ const payload = encodeDelta(this.bridge.ext, scratch, dirty, { tick: this.bridge.tick });
257
+ this.bridge.toCore(this.id, encodeFrame(FrameType.WRITE, payload));
258
+ }
259
+ writeRaw(frame) {
260
+ this.bridge.toCore(this.id, frame);
261
+ }
262
+ call(name, params = {}) {
263
+ const desc = this.descOf(name);
264
+ const reqId = this.nextReqId++;
265
+ return new Promise((resolve, reject) => {
266
+ this.pending.set(reqId, { name, returns: desc.returns, resolve, reject });
267
+ const payload = encodeCall({
268
+ reqId,
269
+ rpcId: desc.index,
270
+ params: encodeFields(desc.params, params)
271
+ });
272
+ this.bridge.toCore(this.id, encodeFrame(FrameType.CALL, payload));
273
+ });
274
+ }
275
+ async requestOwnership(entity, id) {
276
+ const result = await this.call("requestOwnership", { entity, id });
277
+ return result?.granted === true;
278
+ }
279
+ send(target, bytes) {
280
+ const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
281
+ this.bridge.toCore(
282
+ this.id,
283
+ encodeFrame(FrameType.MSG, encodeMsg({ target: wire, payload: bytes }))
284
+ );
285
+ }
286
+ onCall(name, impl) {
287
+ this.descOf(name);
288
+ this.impls.set(name, impl);
289
+ }
290
+ // -------------------------------------------------------------------------
291
+ // Membership
292
+ // -------------------------------------------------------------------------
293
+ disconnect() {
294
+ if (!this.connected) return;
295
+ this.connected = false;
296
+ this.bridge.markDisconnected(this.id);
297
+ }
298
+ reconnect() {
299
+ this.connected = true;
300
+ const result = this.bridge.join(this.id, {
301
+ role: this.role,
302
+ name: this.name,
303
+ reconnecting: true
304
+ });
305
+ this.role = result.role;
306
+ this.joinTick = result.tick;
307
+ this.loadSnapshot(result.snapshot);
308
+ }
309
+ leave(reason = "left") {
310
+ this.bridge.leave(this.id, reason);
311
+ this.connected = false;
312
+ for (const [reqId, p] of [...this.pending]) {
313
+ this.pending.delete(reqId);
314
+ p.reject(new Error(`${p.name}: client ${this.id} left before the reply arrived`));
315
+ }
316
+ }
317
+ // -------------------------------------------------------------------------
318
+ // Inbound
319
+ // -------------------------------------------------------------------------
320
+ /** Delivers one framed protocol frame from the room. Called by the harness. */
321
+ receive(frame) {
322
+ const type = frame[0];
323
+ const payload = frame.subarray(1);
324
+ switch (type) {
325
+ case FrameType.DELTA:
326
+ this.applyDeltaFrame(payload, false);
327
+ return;
328
+ case FrameType.CORRECT:
329
+ this.applyDeltaFrame(payload, true);
330
+ return;
331
+ case FrameType.CALL:
332
+ this.handleCall(payload);
333
+ return;
334
+ case FrameType.REPLY:
335
+ this.handleReply(payload);
336
+ return;
337
+ case FrameType.MSG:
338
+ this.handleMsg(payload);
339
+ return;
340
+ default:
341
+ return;
342
+ }
343
+ }
344
+ applyDeltaFrame(payload, correction) {
345
+ const delta = decodeDelta(this.bridge.ext, payload);
346
+ this.checkFrameVisibility(delta);
347
+ if (correction) {
348
+ for (const dc of delta.collections) {
349
+ for (const op of dc.ops) {
350
+ if (op.op !== "update") continue;
351
+ this.corrections.push({
352
+ collection: dc.name,
353
+ id: op.id,
354
+ patch: cloneValue(op.patch),
355
+ tick: delta.tick
356
+ });
357
+ }
358
+ }
359
+ }
360
+ applyDelta(this.bridge.ext, this.view, delta);
361
+ }
362
+ /** Flags any collection the frame named that this client's role may not see. */
363
+ checkFrameVisibility(delta) {
364
+ const keep = this.bridge.visible(this.role);
365
+ for (const dc of delta.collections) {
366
+ if (keep.has(dc.name) || dc.ops.length === 0) continue;
367
+ this.bridge.noteLeak({
368
+ clientId: this.id,
369
+ role: this.role,
370
+ collection: dc.name,
371
+ kind: "frame",
372
+ ids: dc.ops.map((o) => o.id),
373
+ tick: delta.tick
374
+ });
375
+ }
376
+ }
377
+ handleCall(payload) {
378
+ let call;
379
+ try {
380
+ call = decodeCall(payload);
381
+ } catch {
382
+ return;
383
+ }
384
+ const desc = rpcTable(this.bridge.schema).find((r) => r.index === call.rpcId);
385
+ if (!desc) return;
386
+ let params;
387
+ try {
388
+ params = decodeFields(desc.params, call.params);
389
+ } catch {
390
+ return;
391
+ }
392
+ this.calls.push({ name: desc.name, params });
393
+ const impl = this.impls.get(desc.name);
394
+ if (!impl) return;
395
+ let result;
396
+ try {
397
+ result = impl(params);
398
+ } catch (err) {
399
+ this.replyError(call.reqId, err);
400
+ return;
401
+ }
402
+ if (isThenable(result)) {
403
+ Promise.resolve(result).then(
404
+ (v) => this.replyOk(desc, call.reqId, v),
405
+ (err) => this.replyError(call.reqId, err)
406
+ );
407
+ return;
408
+ }
409
+ this.replyOk(desc, call.reqId, result);
410
+ }
411
+ replyOk(desc, reqId, result) {
412
+ let bytes;
413
+ try {
414
+ bytes = desc.returns ? encodeFields(desc.returns, result ?? {}) : new Uint8Array(0);
415
+ } catch (err) {
416
+ this.replyError(reqId, err);
417
+ return;
418
+ }
419
+ this.bridge.toCore(
420
+ this.id,
421
+ encodeFrame(FrameType.REPLY, encodeReply({ reqId, ok: true, result: bytes }))
422
+ );
423
+ }
424
+ replyError(reqId, err) {
425
+ const error = err instanceof Error ? err.message : String(err);
426
+ this.bridge.toCore(
427
+ this.id,
428
+ encodeFrame(FrameType.REPLY, encodeReply({ reqId, ok: false, error }))
429
+ );
430
+ }
431
+ handleReply(payload) {
432
+ let reply;
433
+ try {
434
+ reply = decodeReply(payload);
435
+ } catch {
436
+ return;
437
+ }
438
+ const p = this.pending.get(reply.reqId);
439
+ if (!p) return;
440
+ this.pending.delete(reply.reqId);
441
+ if (!reply.ok) {
442
+ p.reject(new Error(reply.error));
443
+ return;
444
+ }
445
+ if (!p.returns) {
446
+ p.resolve(void 0);
447
+ return;
448
+ }
449
+ try {
450
+ p.resolve(decodeFields(p.returns, reply.result));
451
+ } catch (err) {
452
+ p.reject(new Error(`${p.name}: bad reply payload: ${String(err)}`));
453
+ }
454
+ }
455
+ handleMsg(payload) {
456
+ let msg;
457
+ try {
458
+ msg = decodeMsg(payload);
459
+ } catch {
460
+ return;
461
+ }
462
+ const from = msg.target.kind === "client" ? msg.target.clientId : "server";
463
+ this.messages.push({ from, bytes: msg.payload.slice() });
464
+ }
465
+ };
466
+
467
+ // src/test/types.ts
468
+ import { FrameType as FrameType2 } from "@irtio/protocol";
469
+ var NAMES = new Map(
470
+ Object.keys(FrameType2).map((k) => [FrameType2[k], k])
471
+ );
472
+ function frameTypeName(type) {
473
+ const name = NAMES.get(type);
474
+ if (!name) throw new Error(`frameTypeName: unknown frame type ${type}`);
475
+ return name;
476
+ }
477
+
478
+ // src/test/harness.ts
479
+ function deepEqual(a, b) {
480
+ if (Object.is(a, b)) return true;
481
+ if (Array.isArray(a) || Array.isArray(b)) {
482
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
483
+ return a.every((v, i) => deepEqual(v, b[i]));
484
+ }
485
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
486
+ const ra = a;
487
+ const rb = b;
488
+ const keys = /* @__PURE__ */ new Set([...Object.keys(ra), ...Object.keys(rb)]);
489
+ for (const k of keys) if (!deepEqual(ra[k], rb[k])) return false;
490
+ return true;
491
+ }
492
+ var Harness = class {
493
+ constructor(definition, options) {
494
+ this.definition = definition;
495
+ this.options = options;
496
+ this.host = new HarnessHost(this.clock);
497
+ this.host.onSend = (clientId, frame) => this.deliver(clientId, frame);
498
+ this.coreRef = new RoomCore(definition, this.host, {
499
+ roomId: options.roomId ?? "test-room",
500
+ seed: options.seed ?? 1,
501
+ ...options.publicUrl !== void 0 ? { publicUrl: options.publicUrl } : {},
502
+ ...options.restoreFrom !== void 0 ? { restoreFrom: options.restoreFrom } : {}
503
+ });
504
+ this.coreRef.start();
505
+ const self = this;
506
+ this.bridge = {
507
+ get ext() {
508
+ return self.coreRef.ext;
509
+ },
510
+ get schema() {
511
+ return self.coreRef.schema;
512
+ },
513
+ get tick() {
514
+ return self.coreRef.tick;
515
+ },
516
+ get now() {
517
+ return self.clock.time;
518
+ },
519
+ toCore: (clientId, frame) => this.toCore(clientId, frame),
520
+ join: (clientId, joinOptions) => this.enter(() => this.coreRef.join(clientId, joinOptions)),
521
+ leave: (clientId, reason) => this.enter(() => this.coreRef.leave(clientId, reason)),
522
+ markDisconnected: (clientId) => this.enter(() => this.coreRef.markDisconnected(clientId)),
523
+ noteLeak: (leak) => {
524
+ this.frameLeaks.push(leak);
525
+ },
526
+ visible: (role) => visibleNames(this.coreRef.ext, role)
527
+ };
528
+ }
529
+ definition;
530
+ options;
531
+ clock = new FakeClock();
532
+ host;
533
+ coreRef;
534
+ clientList = [];
535
+ byId = /* @__PURE__ */ new Map();
536
+ traceLog = [];
537
+ frameLeaks = [];
538
+ queue = [];
539
+ depth = 0;
540
+ pumping = false;
541
+ nextClientId = 1;
542
+ bridge;
543
+ // -------------------------------------------------------------------------
544
+ // Accessors
545
+ // -------------------------------------------------------------------------
546
+ get core() {
547
+ return this.coreRef;
548
+ }
549
+ get room() {
550
+ return this.coreRef.room;
551
+ }
552
+ get state() {
553
+ return this.coreRef.state;
554
+ }
555
+ get plain() {
556
+ return this.coreRef.plain;
557
+ }
558
+ get stats() {
559
+ return this.coreRef.stats;
560
+ }
561
+ get now() {
562
+ return this.clock.time;
563
+ }
564
+ get tickCount() {
565
+ return this.coreRef.tick;
566
+ }
567
+ get mode() {
568
+ return this.coreRef.mode;
569
+ }
570
+ get clients() {
571
+ return this.clientList;
572
+ }
573
+ get trace() {
574
+ return this.traceLog;
575
+ }
576
+ get intervalMs() {
577
+ return 1e3 / this.definition.config.tickRate;
578
+ }
579
+ // -------------------------------------------------------------------------
580
+ // Frame plumbing
581
+ // -------------------------------------------------------------------------
582
+ record(dir, clientId, frame) {
583
+ let name;
584
+ try {
585
+ name = frameTypeName(frame[0] ?? -1);
586
+ } catch {
587
+ return;
588
+ }
589
+ const entry = {
590
+ dir,
591
+ clientId,
592
+ frame: name,
593
+ tick: this.coreRef.tick,
594
+ bytes: frame.length,
595
+ at: this.clock.time
596
+ };
597
+ this.traceLog.push(entry);
598
+ this.byId.get(clientId)?.frames.push(entry);
599
+ }
600
+ /**
601
+ * One room → client frame. The client is "busy" while it decodes, so anything it sends back
602
+ * (a `REPLY` to a server→client `CALL`) is queued rather than fed straight back into the core:
603
+ * the core is mid-`send` and has not finished bookkeeping the call yet.
604
+ *
605
+ * When the send did not come from a harness entry point (a test calling `h.room.call(...)`
606
+ * directly), the drain is deferred to a microtask — which is exactly where the test's `await`
607
+ * picks it up.
608
+ */
609
+ deliver(clientId, frame) {
610
+ const client = this.byId.get(clientId);
611
+ this.record("out", clientId, frame);
612
+ this.depth++;
613
+ try {
614
+ if (client?.connected) client.receive(frame);
615
+ } finally {
616
+ this.depth--;
617
+ if (this.depth === 0 && this.queue.length > 0) queueMicrotask(() => this.pump());
618
+ }
619
+ }
620
+ toCore(clientId, frame) {
621
+ this.queue.push({ clientId, frame });
622
+ if (this.depth === 0) this.pump();
623
+ }
624
+ /** Runs `fn` with the core "busy"; drains client frames once the outermost call returns. */
625
+ enter(fn) {
626
+ this.depth++;
627
+ try {
628
+ return fn();
629
+ } finally {
630
+ this.depth--;
631
+ if (this.depth === 0) this.pump();
632
+ }
633
+ }
634
+ pump() {
635
+ if (this.pumping) return;
636
+ this.pumping = true;
637
+ try {
638
+ let guard = 0;
639
+ for (; ; ) {
640
+ const item = this.queue.shift();
641
+ if (!item) break;
642
+ if (++guard > 1e5) throw new Error("harness: the frame pump did not settle");
643
+ this.record("in", item.clientId, item.frame);
644
+ this.depth++;
645
+ try {
646
+ this.coreRef.receive(item.clientId, item.frame);
647
+ } finally {
648
+ this.depth--;
649
+ }
650
+ }
651
+ } finally {
652
+ this.pumping = false;
653
+ }
654
+ }
655
+ // -------------------------------------------------------------------------
656
+ // Clock
657
+ // -------------------------------------------------------------------------
658
+ tick(n = 1) {
659
+ this.enter(() => {
660
+ for (let i = 0; i < n; i++) {
661
+ if (this.mode === "tick") this.clock.advance(this.intervalMs);
662
+ else this.clock.advance(0);
663
+ }
664
+ });
665
+ }
666
+ run(ms) {
667
+ this.enter(() => this.clock.advance(ms));
668
+ }
669
+ until(pred, options = {}) {
670
+ const maxTicks = options.maxTicks ?? 1e3;
671
+ if (pred()) return;
672
+ for (let i = 0; i < maxTicks; i++) {
673
+ this.tick(1);
674
+ if (pred()) return;
675
+ }
676
+ throw new Error(`h.until: predicate still false after ${maxTicks} ticks`);
677
+ }
678
+ join(a, b) {
679
+ if (typeof a === "number") {
680
+ const out = [];
681
+ for (let i = 0; i < a; i++) out.push(this.joinOne(b ?? {}));
682
+ return out;
683
+ }
684
+ return this.joinOne(a ?? {});
685
+ }
686
+ joinOne(spec) {
687
+ const id = spec.id ?? `c${this.nextClientId++}`;
688
+ const roles = this.definition.schema.roles;
689
+ const role = spec.role ?? roles[0] ?? "";
690
+ const options = {
691
+ role,
692
+ ...spec.name !== void 0 ? { name: spec.name } : {}
693
+ };
694
+ const client = new FakeClientImpl(this.bridge, id, options);
695
+ this.byId.set(id, client);
696
+ this.clientList.push(client);
697
+ try {
698
+ client.initialJoin(options);
699
+ } catch (err) {
700
+ this.byId.delete(id);
701
+ this.clientList.splice(this.clientList.indexOf(client), 1);
702
+ throw err;
703
+ }
704
+ return client;
705
+ }
706
+ // -------------------------------------------------------------------------
707
+ // Visibility
708
+ // -------------------------------------------------------------------------
709
+ checkVisibility() {
710
+ const leaks = [...this.frameLeaks];
711
+ const ext = this.coreRef.ext;
712
+ for (const client of this.clientList) {
713
+ const keep = visibleNames(ext, client.role);
714
+ for (const c of ext.collections) {
715
+ if (keep.has(c.name)) continue;
716
+ if (c.kind === "entity") {
717
+ const coll = client.view[c.name];
718
+ if (!coll || coll.size === 0) continue;
719
+ leaks.push({
720
+ clientId: client.id,
721
+ role: client.role,
722
+ collection: c.name,
723
+ kind: "view",
724
+ ids: [...coll.ids()],
725
+ tick: this.coreRef.tick
726
+ });
727
+ } else {
728
+ const value = client.view[c.name];
729
+ if (deepEqual(value, defaultRecord(c))) continue;
730
+ leaks.push({
731
+ clientId: client.id,
732
+ role: client.role,
733
+ collection: c.name,
734
+ kind: "view",
735
+ ids: [""],
736
+ tick: this.coreRef.tick
737
+ });
738
+ }
739
+ }
740
+ }
741
+ return leaks;
742
+ }
743
+ // -------------------------------------------------------------------------
744
+ // Hibernation
745
+ // -------------------------------------------------------------------------
746
+ serialize() {
747
+ return this.enter(() => this.coreRef.serialize());
748
+ }
749
+ restore(bytes) {
750
+ this.coreRef.stop();
751
+ this.queue.length = 0;
752
+ this.clientList.length = 0;
753
+ this.byId.clear();
754
+ this.nextClientId = 1;
755
+ this.coreRef = RoomCore.restore(this.definition, bytes, this.host, {
756
+ roomId: this.options.roomId ?? "test-room",
757
+ seed: this.options.seed ?? 1,
758
+ ...this.options.publicUrl !== void 0 ? { publicUrl: this.options.publicUrl } : {}
759
+ });
760
+ this.coreRef.start();
761
+ }
762
+ stop() {
763
+ this.coreRef.stop();
764
+ }
765
+ };
766
+ function createRoomHarness(definition, options = {}) {
767
+ return new Harness(definition, options);
768
+ }
769
+ export {
770
+ FakeClock,
771
+ HarnessHost,
772
+ createRoomHarness,
773
+ frameTypeName
774
+ };