@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.
- package/LICENSE +21 -0
- package/dist/chunk-3DQHT4CM.js +129 -0
- package/dist/chunk-X5S364FY.js +1573 -0
- package/dist/contract-BhD88PGb.d.ts +120 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +48 -0
- package/dist/room-CBsSCueH.d.ts +246 -0
- package/dist/test/index.d.ts +283 -0
- package/dist/test/index.js +774 -0
- package/dist/worker/index.d.ts +173 -0
- package/dist/worker/index.js +280 -0
- package/package.json +39 -0
|
@@ -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
|
+
};
|