@omniaura/scenario-sim 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,1236 @@
1
+ // src/core/rng.ts
2
+ var Rng = class _Rng {
3
+ constructor(seed) {
4
+ this.seed = seed;
5
+ const h = hashSeed(String(seed));
6
+ this.a = h[0];
7
+ this.b = h[1];
8
+ this.c = h[2];
9
+ this.d = h[3];
10
+ for (let i = 0; i < 12; i++) this.next();
11
+ this.draws = 0;
12
+ }
13
+ seed;
14
+ a;
15
+ b;
16
+ c;
17
+ d;
18
+ /** Draws so far; useful when asserting determinism across runs. */
19
+ draws = 0;
20
+ /** Uniform in [0, 1). */
21
+ next() {
22
+ this.draws++;
23
+ this.a |= 0;
24
+ this.b |= 0;
25
+ this.c |= 0;
26
+ this.d |= 0;
27
+ const t = (this.a + this.b | 0) + this.d | 0;
28
+ this.d = this.d + 1 | 0;
29
+ this.a = this.b ^ this.b >>> 9;
30
+ this.b = this.c + (this.c << 3) | 0;
31
+ this.c = this.c << 21 | this.c >>> 11;
32
+ this.c = this.c + t | 0;
33
+ return (t >>> 0) / 4294967296;
34
+ }
35
+ /** Integer in [min, max] inclusive. */
36
+ int(min, max) {
37
+ if (max < min) [min, max] = [max, min];
38
+ return min + Math.floor(this.next() * (max - min + 1));
39
+ }
40
+ float(min = 0, max = 1) {
41
+ return min + this.next() * (max - min);
42
+ }
43
+ chance(probability) {
44
+ return this.next() < probability;
45
+ }
46
+ pick(items) {
47
+ if (items.length === 0) throw new RangeError("pick() from an empty list");
48
+ return items[this.int(0, items.length - 1)];
49
+ }
50
+ shuffle(items) {
51
+ const out = [...items];
52
+ for (let i = out.length - 1; i > 0; i--) {
53
+ const j = this.int(0, i);
54
+ [out[i], out[j]] = [out[j], out[i]];
55
+ }
56
+ return out;
57
+ }
58
+ /** Deterministic opaque id: `<prefix>_<8 base36 chars>`. */
59
+ id(prefix = "id") {
60
+ let s = "";
61
+ while (s.length < 8) s += Math.floor(this.next() * 36 ** 4).toString(36).padStart(4, "0");
62
+ return `${prefix}_${s.slice(0, 8)}`;
63
+ }
64
+ /** RFC-4122-shaped id (version 4 bits set) from the seeded stream. */
65
+ uuid() {
66
+ const hex = () => Math.floor(this.next() * 65536).toString(16).padStart(4, "0");
67
+ const v = hex();
68
+ return `${hex()}${hex()}-${hex()}-4${hex().slice(1)}-${(parseInt(v[0], 16) & 3 | 8).toString(16)}${v.slice(1)}-${hex()}${hex()}${hex()}`;
69
+ }
70
+ /** Fork a child generator with a derived seed (stable across runs). */
71
+ fork(label) {
72
+ return new _Rng(`${this.seed}/${label}`);
73
+ }
74
+ };
75
+ function hashSeed(str) {
76
+ let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762;
77
+ for (let i = 0; i < str.length; i++) {
78
+ const k = str.charCodeAt(i);
79
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
80
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
81
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
82
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
83
+ }
84
+ h1 = Math.imul(h3 ^ h1 >>> 18, 597399067);
85
+ h2 = Math.imul(h4 ^ h2 >>> 22, 2869860233);
86
+ h3 = Math.imul(h1 ^ h3 >>> 17, 951274213);
87
+ h4 = Math.imul(h2 ^ h4 >>> 19, 2716044179);
88
+ return [(h1 ^ h2 ^ h3 ^ h4) >>> 0, (h2 ^ h1) >>> 0, (h3 ^ h1) >>> 0, (h4 ^ h1) >>> 0];
89
+ }
90
+
91
+ // src/core/clock.ts
92
+ var VIRTUAL_EPOCH = Date.UTC(2026, 0, 1);
93
+ var VirtualClock = class {
94
+ constructor(mode = "realtime", speed = 1, epoch = VIRTUAL_EPOCH) {
95
+ this.mode = mode;
96
+ this.speed = speed;
97
+ this.epoch = epoch;
98
+ }
99
+ mode;
100
+ speed;
101
+ epoch;
102
+ entries = /* @__PURE__ */ new Map();
103
+ nextId = 1;
104
+ base = 0;
105
+ startedReal = typeof performance !== "undefined" ? performance.now() : Date.now();
106
+ manualNow = 0;
107
+ /** Virtual ms since the run started. */
108
+ now() {
109
+ if (this.mode === "manual") return this.manualNow;
110
+ const real = (typeof performance !== "undefined" ? performance.now() : Date.now()) - this.startedReal;
111
+ return this.base + real * this.speed;
112
+ }
113
+ /** Virtual wall-clock ms (epoch + now). */
114
+ wall() {
115
+ return this.epoch + Math.round(this.now());
116
+ }
117
+ iso(offsetMs = 0) {
118
+ return new Date(this.wall() + offsetMs).toISOString();
119
+ }
120
+ after(ms, fn, label = "timer") {
121
+ const id = this.nextId++;
122
+ const at = this.now() + Math.max(0, ms);
123
+ const entry = { id, at, label, fn, handle: null };
124
+ this.entries.set(id, entry);
125
+ if (this.mode === "realtime") {
126
+ entry.handle = setTimeout(() => this.fire(entry), Math.max(0, ms) / (this.speed || 1));
127
+ }
128
+ return { id, at, label, cancel: () => this.cancel(id) };
129
+ }
130
+ /** Promise that resolves after `ms` virtual milliseconds. */
131
+ sleep(ms, label = "sleep") {
132
+ return new Promise((resolve) => void this.after(ms, resolve, label));
133
+ }
134
+ cancel(id) {
135
+ const e = this.entries.get(id);
136
+ if (!e) return;
137
+ if (e.handle) clearTimeout(e.handle);
138
+ this.entries.delete(id);
139
+ }
140
+ /** Manual mode: advance by `ms`, firing due timers in order. Returns what fired. */
141
+ step(ms) {
142
+ if (this.mode !== "manual") throw new Error("step() requires manual clock mode");
143
+ const target = this.manualNow + Math.max(0, ms);
144
+ const fired = [];
145
+ for (; ; ) {
146
+ let next = null;
147
+ for (const e of this.entries.values()) if (e.at <= target && (!next || e.at < next.at || e.at === next.at && e.id < next.id)) next = e;
148
+ if (!next) break;
149
+ this.manualNow = Math.max(this.manualNow, next.at);
150
+ fired.push({ id: next.id, at: next.at, label: next.label });
151
+ this.fire(next);
152
+ }
153
+ this.manualNow = target;
154
+ return { now: this.manualNow, fired };
155
+ }
156
+ /** Switch modes; pending timers are re-armed (realtime) or parked (manual). */
157
+ setMode(mode) {
158
+ if (mode === this.mode) return;
159
+ const now = this.now();
160
+ this.mode = mode;
161
+ if (mode === "manual") {
162
+ this.manualNow = now;
163
+ for (const e of this.entries.values()) {
164
+ if (e.handle) clearTimeout(e.handle);
165
+ e.handle = null;
166
+ }
167
+ } else {
168
+ this.base = now;
169
+ this.startedReal = typeof performance !== "undefined" ? performance.now() : Date.now();
170
+ for (const e of this.entries.values()) e.handle = setTimeout(() => this.fire(e), Math.max(0, e.at - now) / (this.speed || 1));
171
+ }
172
+ }
173
+ pending() {
174
+ return [...this.entries.values()].sort((a, b) => a.at - b.at || a.id - b.id).map(({ id, at, label }) => ({ id, at, label }));
175
+ }
176
+ clear() {
177
+ for (const e of this.entries.values()) if (e.handle) clearTimeout(e.handle);
178
+ this.entries.clear();
179
+ }
180
+ fire(e) {
181
+ if (!this.entries.has(e.id)) return;
182
+ this.entries.delete(e.id);
183
+ try {
184
+ e.fn();
185
+ } catch (err) {
186
+ console.warn(`[scenario-sim] timer "${e.label}" threw`, err);
187
+ }
188
+ }
189
+ };
190
+
191
+ // src/core/store.ts
192
+ var Store = class {
193
+ constructor(now, logLimit = 5e3) {
194
+ this.now = now;
195
+ this.logLimit = logLimit;
196
+ }
197
+ now;
198
+ logLimit;
199
+ collections = /* @__PURE__ */ new Map();
200
+ listeners = /* @__PURE__ */ new Set();
201
+ log = [];
202
+ seq = 0;
203
+ collection(name) {
204
+ let c = this.collections.get(name);
205
+ if (!c) {
206
+ c = /* @__PURE__ */ new Map();
207
+ this.collections.set(name, c);
208
+ }
209
+ return c;
210
+ }
211
+ collections_() {
212
+ return [...this.collections.keys()];
213
+ }
214
+ insert(collection, record) {
215
+ const c = this.collection(collection);
216
+ if (c.has(record.id)) throw new Error(`duplicate id ${record.id} in ${collection}`);
217
+ c.set(record.id, record);
218
+ this.emit({ kind: "insert", collection, id: record.id, record });
219
+ return record;
220
+ }
221
+ upsert(collection, record) {
222
+ const c = this.collection(collection);
223
+ const previous = c.get(record.id);
224
+ c.set(record.id, record);
225
+ this.emit(previous ? { kind: "update", collection, id: record.id, record, previous } : { kind: "insert", collection, id: record.id, record });
226
+ return record;
227
+ }
228
+ get(collection, id) {
229
+ return this.collections.get(collection)?.get(id);
230
+ }
231
+ list(collection, opts = {}) {
232
+ let out = [...this.collections.get(collection)?.values() ?? []];
233
+ if (opts.where) out = out.filter(opts.where);
234
+ if (opts.sort) out.sort(opts.sort);
235
+ if (opts.offset) out = out.slice(opts.offset);
236
+ if (opts.limit !== void 0) out = out.slice(0, opts.limit);
237
+ return out;
238
+ }
239
+ count(collection) {
240
+ return this.collections.get(collection)?.size ?? 0;
241
+ }
242
+ update(collection, id, patch) {
243
+ const c = this.collection(collection);
244
+ const previous = c.get(id);
245
+ if (!previous) return void 0;
246
+ const next = typeof patch === "function" ? patch(previous) : { ...previous, ...patch, id };
247
+ c.set(id, next);
248
+ this.emit({ kind: "update", collection, id, record: next, previous });
249
+ return next;
250
+ }
251
+ remove(collection, id) {
252
+ const c = this.collection(collection);
253
+ const previous = c.get(id);
254
+ if (!previous) return void 0;
255
+ c.delete(id);
256
+ this.emit({ kind: "remove", collection, id, previous });
257
+ return previous;
258
+ }
259
+ clear(collection) {
260
+ if (collection) this.collection(collection).clear();
261
+ else this.collections.clear();
262
+ this.emit({ kind: "clear", collection: collection ?? "*" });
263
+ }
264
+ /** Application-level event (not tied to a record), e.g. "job.progress". */
265
+ custom(collection, name, data, id) {
266
+ this.emit({ kind: "custom", collection, name, data, id });
267
+ }
268
+ on(listener) {
269
+ this.listeners.add(listener);
270
+ return () => this.listeners.delete(listener);
271
+ }
272
+ events(opts = {}) {
273
+ const out = this.log.filter((e) => e.seq > (opts.since ?? 0) && (!opts.collection || e.collection === opts.collection));
274
+ const limit = opts.limit ?? 200;
275
+ return out.length > limit ? out.slice(out.length - limit) : out;
276
+ }
277
+ get lastSeq() {
278
+ return this.seq;
279
+ }
280
+ snapshot() {
281
+ const out = {};
282
+ for (const [name, c] of this.collections) out[name] = [...c.values()];
283
+ return out;
284
+ }
285
+ counts() {
286
+ const out = {};
287
+ for (const [name, c] of this.collections) out[name] = c.size;
288
+ return out;
289
+ }
290
+ emit(partial) {
291
+ const event = { seq: ++this.seq, t: this.now(), ...partial };
292
+ this.log.push(event);
293
+ if (this.log.length > this.logLimit) this.log.splice(0, this.log.length - this.logLimit);
294
+ for (const l of this.listeners) {
295
+ try {
296
+ l(event);
297
+ } catch (err) {
298
+ console.warn("[scenario-sim] store listener threw", err);
299
+ }
300
+ }
301
+ }
302
+ };
303
+
304
+ // src/core/streams.ts
305
+ var TopicLog = class {
306
+ constructor(now, retain = 500) {
307
+ this.now = now;
308
+ this.retain = retain;
309
+ }
310
+ now;
311
+ retain;
312
+ seqs = /* @__PURE__ */ new Map();
313
+ logs = /* @__PURE__ */ new Map();
314
+ publish(topic, data) {
315
+ const seq = (this.seqs.get(topic) ?? 0) + 1;
316
+ this.seqs.set(topic, seq);
317
+ const event = { topic, eventID: String(seq), t: this.now(), data };
318
+ const log = this.logs.get(topic) ?? [];
319
+ log.push(event);
320
+ if (log.length > this.retain) log.splice(0, log.length - this.retain);
321
+ this.logs.set(topic, log);
322
+ return event;
323
+ }
324
+ last(topic) {
325
+ return String(this.seqs.get(topic) ?? 0);
326
+ }
327
+ /**
328
+ * Events after `since`. `missed` is true when `since` predates the retained
329
+ * window. `null`/`undefined` means "no resume point": live only, nothing is
330
+ * replayed (what a fresh EventSource without Last-Event-ID gets). Pass "0"
331
+ * to replay everything retained.
332
+ */
333
+ replay(topic, since) {
334
+ if (since === null || since === void 0 || since === "") return { events: [], missed: false };
335
+ const s = Number(since);
336
+ const log = this.logs.get(topic) ?? [];
337
+ const oldest = log[0] ? Number(log[0].eventID) : (this.seqs.get(topic) ?? 0) + 1;
338
+ const missed = s > 0 && s < oldest - 1;
339
+ return { events: log.filter((e) => Number(e.eventID) > s), missed };
340
+ }
341
+ topics() {
342
+ return [...this.seqs.keys()];
343
+ }
344
+ clear() {
345
+ this.seqs.clear();
346
+ this.logs.clear();
347
+ }
348
+ };
349
+ var defaultSerializer = (e) => JSON.stringify({ ...e.data, topic: e.topic, eventID: e.eventID });
350
+ var StreamHub = class {
351
+ constructor(clock, log) {
352
+ this.clock = clock;
353
+ this.log = log;
354
+ this.topics = new TopicLog(() => clock.now());
355
+ }
356
+ clock;
357
+ log;
358
+ topics;
359
+ connections = /* @__PURE__ */ new Map();
360
+ nextId = 1;
361
+ /** Extra virtual latency applied to every outgoing stream message. */
362
+ latencyMs = 0;
363
+ /** Publish to a topic: appended to the replay log and fanned out to subscribers. */
364
+ publish(topic, data) {
365
+ const event = this.topics.publish(topic, data);
366
+ for (const conn of this.connections.values()) {
367
+ const sub = conn.subscriptions.get(topic);
368
+ if (!sub) continue;
369
+ if (conn.kind === "ws") this.deliver(conn, sub.serialize(event));
370
+ else this.deliverSse(conn, event, sub.serialize);
371
+ }
372
+ return event;
373
+ }
374
+ deliver(conn, payload) {
375
+ if (conn.readyState !== "open") return;
376
+ const doSend = () => {
377
+ if (conn.readyState !== "open") return;
378
+ if (conn.paused) {
379
+ conn.meta.__queued?.push(payload) ?? (conn.meta.__queued = [payload]);
380
+ return;
381
+ }
382
+ conn.send(payload);
383
+ };
384
+ if (this.latencyMs > 0) this.clock.after(this.latencyMs, doSend, `ws latency ${conn.id}`);
385
+ else doSend();
386
+ }
387
+ deliverSse(conn, event, eventName) {
388
+ const doSend = () => conn.send(event.data, { event: eventName(event), id: event.eventID });
389
+ if (this.latencyMs > 0) this.clock.after(this.latencyMs, doSend, `sse latency ${conn.id}`);
390
+ else doSend();
391
+ }
392
+ /** Called by a transport adapter once the WebSocket handshake completed. */
393
+ openSocket(route, ctx, transport, protocol) {
394
+ const id = `ws_${this.nextId++}`;
395
+ const closeCbs = [];
396
+ const hub = this;
397
+ const socket = {
398
+ id,
399
+ kind: "ws",
400
+ path: route.path,
401
+ url: ctx.url,
402
+ openedAt: this.clock.now(),
403
+ sent: 0,
404
+ received: 0,
405
+ paused: false,
406
+ subscriptions: /* @__PURE__ */ new Map(),
407
+ meta: {},
408
+ protocol,
409
+ readyState: "open",
410
+ send(data) {
411
+ if (socket.readyState !== "open") return;
412
+ socket.sent++;
413
+ transport.send(typeof data === "string" || data instanceof ArrayBuffer ? data : JSON.stringify(data));
414
+ },
415
+ close(code = 1e3, reason = "") {
416
+ if (socket.readyState !== "open") return;
417
+ socket.readyState = "closed";
418
+ transport.close(code, reason);
419
+ hub.finish(socket, code, reason, closeCbs);
420
+ },
421
+ drop() {
422
+ if (socket.readyState !== "open") return;
423
+ socket.readyState = "closed";
424
+ transport.drop();
425
+ hub.finish(socket, 1006, "dropped", closeCbs);
426
+ },
427
+ subscribe(topic, opts = {}) {
428
+ const serialize = opts.serialize ?? defaultSerializer;
429
+ socket.subscriptions.set(topic, { topic, serialize });
430
+ const { events, missed } = hub.topics.replay(topic, opts.since);
431
+ for (const e of events) hub.deliver(socket, serialize(e));
432
+ return { replayed: events.length, missed, last: hub.topics.last(topic) };
433
+ },
434
+ unsubscribe(topic) {
435
+ socket.subscriptions.delete(topic);
436
+ },
437
+ onClose(cb) {
438
+ closeCbs.push(cb);
439
+ }
440
+ };
441
+ this.connections.set(id, socket);
442
+ this.log(`ws open ${id} ${route.path}`, { protocol });
443
+ void Promise.resolve(route.onOpen?.(ctx, socket)).catch((err) => this.log(`ws onOpen threw ${id}`, String(err)));
444
+ return socket;
445
+ }
446
+ /** Transport → hub: a client frame arrived. */
447
+ receive(route, ctx, socket, data) {
448
+ if (socket.readyState !== "open") return;
449
+ socket.received++;
450
+ void Promise.resolve(route.onMessage?.(ctx, socket, data)).catch((err) => this.log(`ws onMessage threw ${socket.id}`, String(err)));
451
+ }
452
+ /** Transport → hub: the client closed. */
453
+ clientClosed(route, ctx, socket, code, reason) {
454
+ if (socket.readyState !== "open") return;
455
+ socket.readyState = "closed";
456
+ this.connections.delete(socket.id);
457
+ this.log(`ws closed by client ${socket.id}`, { code, reason });
458
+ route.onClose?.(ctx, socket, code, reason);
459
+ }
460
+ finish(conn, code, reason, cbs) {
461
+ this.connections.delete(conn.id);
462
+ this.log(`ws closed ${conn.id}`, { code, reason });
463
+ for (const cb of cbs) cb(code, reason);
464
+ }
465
+ /** Build the SSE Response for a route; the adapter just returns it. */
466
+ openSse(route, ctx) {
467
+ const id = `sse_${this.nextId++}`;
468
+ const encoder = new TextEncoder();
469
+ let controller = null;
470
+ let closed = false;
471
+ const closeCbs = [];
472
+ const hub = this;
473
+ const lastEventId = ctx.request.headers.get("last-event-id") ?? ctx.query.get("lastEventId") ?? ctx.query.get("sinceEventID");
474
+ let keepalive = null;
475
+ const teardown = () => {
476
+ if (closed) return;
477
+ closed = true;
478
+ keepalive?.cancel();
479
+ hub.connections.delete(id);
480
+ hub.log(`sse closed ${id}`);
481
+ for (const cb of closeCbs) cb();
482
+ };
483
+ const write = (chunk) => {
484
+ if (closed || !controller) return;
485
+ try {
486
+ controller.enqueue(encoder.encode(chunk));
487
+ } catch {
488
+ teardown();
489
+ }
490
+ };
491
+ const body = new ReadableStream({
492
+ start(c) {
493
+ controller = c;
494
+ },
495
+ cancel() {
496
+ teardown();
497
+ }
498
+ });
499
+ const stream = {
500
+ id,
501
+ kind: "sse",
502
+ path: route.path,
503
+ url: ctx.url,
504
+ openedAt: this.clock.now(),
505
+ sent: 0,
506
+ received: 0,
507
+ paused: false,
508
+ subscriptions: /* @__PURE__ */ new Map(),
509
+ meta: {},
510
+ lastEventId,
511
+ send(data, opts = {}) {
512
+ if (closed) return;
513
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
514
+ const frame = [
515
+ opts.event ? `event: ${opts.event}` : null,
516
+ opts.id !== void 0 ? `id: ${opts.id}` : null,
517
+ opts.retry !== void 0 ? `retry: ${opts.retry}` : null,
518
+ ...payload.split("\n").map((line) => `data: ${line}`)
519
+ ].filter((l) => l !== null).join("\n");
520
+ if (stream.paused) {
521
+ stream.meta.__queued?.push(frame + "\n\n") ?? (stream.meta.__queued = [frame + "\n\n"]);
522
+ return;
523
+ }
524
+ stream.sent++;
525
+ write(frame + "\n\n");
526
+ },
527
+ comment(text) {
528
+ write(`: ${text}
529
+
530
+ `);
531
+ },
532
+ close() {
533
+ if (closed) return;
534
+ try {
535
+ controller?.close();
536
+ } catch {
537
+ }
538
+ teardown();
539
+ },
540
+ drop() {
541
+ if (closed) return;
542
+ try {
543
+ controller?.error(new Error("connection dropped"));
544
+ } catch {
545
+ }
546
+ teardown();
547
+ },
548
+ subscribe(topic, opts = {}) {
549
+ const eventName = opts.event ?? ((e) => String(e.data.type ?? "message"));
550
+ stream.subscriptions.set(topic, { topic, serialize: eventName });
551
+ const { events, missed } = hub.topics.replay(topic, opts.since ?? lastEventId);
552
+ for (const e of events) stream.send(e.data, { event: eventName(e), id: e.eventID });
553
+ return { replayed: events.length, missed, last: hub.topics.last(topic) };
554
+ },
555
+ unsubscribe(topic) {
556
+ stream.subscriptions.delete(topic);
557
+ },
558
+ onClose(cb) {
559
+ closeCbs.push(cb);
560
+ },
561
+ response: new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" } })
562
+ };
563
+ this.connections.set(id, stream);
564
+ this.log(`sse open ${id} ${route.path}`, { lastEventId });
565
+ write(`: scenario-sim ${id}
566
+
567
+ `);
568
+ const ka = route.keepaliveMs ?? 15e3;
569
+ if (ka > 0) {
570
+ const tick = () => {
571
+ if (closed) return;
572
+ write(":keepalive\n\n");
573
+ keepalive = hub.clock.after(ka, tick, `sse keepalive ${id}`);
574
+ };
575
+ keepalive = hub.clock.after(ka, tick, `sse keepalive ${id}`);
576
+ }
577
+ void Promise.resolve(route.onOpen(ctx, stream)).catch((err) => hub.log(`sse onOpen threw ${id}`, String(err)));
578
+ return stream;
579
+ }
580
+ // ── control plane ────────────────────────────────────────────────
581
+ list() {
582
+ return [...this.connections.values()].map((c) => ({
583
+ id: c.id,
584
+ kind: c.kind,
585
+ path: c.path,
586
+ openedAt: c.openedAt,
587
+ sent: c.sent,
588
+ received: c.received,
589
+ paused: c.paused,
590
+ topics: [...c.subscriptions.keys()],
591
+ ...c.kind === "ws" ? { protocol: c.protocol } : {},
592
+ meta: Object.fromEntries(Object.entries(c.meta).filter(([k]) => !k.startsWith("__")))
593
+ }));
594
+ }
595
+ get(id) {
596
+ return this.connections.get(id) ?? null;
597
+ }
598
+ /**
599
+ * Disconnect connections: by id, by topic, by path, or all. `drop` cuts the
600
+ * connection without a close frame (what a network blip looks like).
601
+ */
602
+ disconnect(target, opts = {}) {
603
+ const hit = [];
604
+ for (const c of [...this.connections.values()]) {
605
+ const matches = target.all || target.id && c.id === target.id || target.topic && c.subscriptions.has(target.topic) || target.path && c.path === target.path;
606
+ if (!matches) continue;
607
+ hit.push(c.id);
608
+ if (opts.drop) c.drop();
609
+ else if (c.kind === "ws") c.close(opts.code ?? 1001, opts.reason ?? "disconnected by scenario control");
610
+ else c.close();
611
+ }
612
+ return hit;
613
+ }
614
+ /** Pause delivery on a connection (messages queue); resume flushes them in order. */
615
+ pause(id, paused) {
616
+ const c = this.connections.get(id);
617
+ if (!c) return false;
618
+ c.paused = paused;
619
+ if (!paused) {
620
+ const queued = c.meta.__queued ?? [];
621
+ c.meta.__queued = [];
622
+ for (const payload of queued) {
623
+ if (c.kind === "ws") c.send(payload);
624
+ else {
625
+ c.sent++;
626
+ c.__raw?.(payload);
627
+ }
628
+ }
629
+ }
630
+ return true;
631
+ }
632
+ closeAll() {
633
+ for (const c of [...this.connections.values()]) c.close();
634
+ this.connections.clear();
635
+ }
636
+ };
637
+
638
+ // src/core/router.ts
639
+ var JSON_HEADERS = { "content-type": "application/json", "cache-control": "no-store" };
640
+ function json(body, init = {}) {
641
+ return new Response(body === void 0 ? null : JSON.stringify(body), { ...init, status: init.status ?? 200, headers: { ...JSON_HEADERS, ...init.headers } });
642
+ }
643
+ function problem(status, detail, extra = {}) {
644
+ return json({ type: "about:blank", title: statusText(status), status, detail, ...extra }, { status, headers: { "content-type": "application/problem+json" } });
645
+ }
646
+ function malformed(kind, extra) {
647
+ switch (kind) {
648
+ case "invalid-json":
649
+ return new Response('{"items": [1, 2,', { status: 200, headers: JSON_HEADERS });
650
+ case "wrong-content-type":
651
+ return new Response(JSON.stringify(extra ?? { ok: true }), { status: 200, headers: { "content-type": "text/html" } });
652
+ case "truncated":
653
+ return new Response(JSON.stringify(extra ?? { items: [{ id: "a" }] }).slice(0, -6), { status: 200, headers: JSON_HEADERS });
654
+ case "empty-200":
655
+ return new Response("", { status: 200, headers: JSON_HEADERS });
656
+ case "html-500":
657
+ return new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", { status: 502, headers: { "content-type": "text/html" } });
658
+ case "schema-drift":
659
+ return json(extra ?? { data: { items: "not-an-array" }, meta: null });
660
+ }
661
+ }
662
+ function compileRoute(route) {
663
+ const keys = [];
664
+ const pattern = route.path.split("/").map((seg) => {
665
+ if (seg === "*") return "(?:.*)";
666
+ if (seg.startsWith(":")) {
667
+ keys.push(seg.slice(1));
668
+ return "([^/]+)";
669
+ }
670
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
671
+ }).join("/");
672
+ const re = new RegExp(`^${pattern}/?$`);
673
+ return {
674
+ ...route,
675
+ calls: 0,
676
+ match(pathname) {
677
+ const m = re.exec(pathname);
678
+ if (!m) return null;
679
+ const params = {};
680
+ keys.forEach((k, i) => params[k] = decodeURIComponent(m[i + 1] ?? ""));
681
+ return params;
682
+ }
683
+ };
684
+ }
685
+ function statusText(status) {
686
+ return {
687
+ 400: "Bad Request",
688
+ 401: "Unauthorized",
689
+ 402: "Payment Required",
690
+ 403: "Forbidden",
691
+ 404: "Not Found",
692
+ 409: "Conflict",
693
+ 422: "Unprocessable Content",
694
+ 429: "Too Many Requests",
695
+ 500: "Internal Server Error",
696
+ 502: "Bad Gateway",
697
+ 503: "Service Unavailable",
698
+ 504: "Gateway Timeout"
699
+ }[status] ?? "Error";
700
+ }
701
+
702
+ // src/core/faults.ts
703
+ var FaultLayer = class {
704
+ constructor(rng) {
705
+ this.rng = rng;
706
+ }
707
+ rng;
708
+ latencyMs = 0;
709
+ jitterMs = 0;
710
+ failMode = "off";
711
+ /** Paths the `data` fail mode leaves alone so the app shell still boots. */
712
+ shellPaths = /* @__PURE__ */ new Set();
713
+ overrides = [];
714
+ nextId = 1;
715
+ configure(patch) {
716
+ if (patch.latencyMs !== void 0) this.latencyMs = Math.max(0, patch.latencyMs);
717
+ if (patch.jitterMs !== void 0) this.jitterMs = Math.max(0, patch.jitterMs);
718
+ if (patch.failMode !== void 0) this.failMode = patch.failMode;
719
+ if (patch.shellPaths !== void 0) this.shellPaths = new Set(patch.shellPaths);
720
+ }
721
+ /** Effective delay for one request (seeded jitter keeps it reproducible). */
722
+ delayFor(extra = 0) {
723
+ const jitter = this.jitterMs > 0 ? this.rng.int(0, this.jitterMs) : 0;
724
+ return this.latencyMs + jitter + extra;
725
+ }
726
+ setOverride(input) {
727
+ const key = matcherKey(input.matcher, input.method);
728
+ this.overrides = this.overrides.filter((o2) => matcherKey(o2.matcher, o2.method) !== key);
729
+ const o = { ...input, id: `ov_${this.nextId++}`, remaining: input.times ?? Infinity, hits: 0 };
730
+ this.overrides.unshift(o);
731
+ return o;
732
+ }
733
+ clearOverride(target, method) {
734
+ const before = this.overrides.length;
735
+ this.overrides = this.overrides.filter((o) => o.id !== target && matcherKey(o.matcher, o.method) !== matcherKey(target, method));
736
+ return this.overrides.length !== before;
737
+ }
738
+ clearOverrides() {
739
+ this.overrides = [];
740
+ }
741
+ listOverrides() {
742
+ return this.overrides.map((o) => ({ ...o }));
743
+ }
744
+ /** Find (and consume) the first matching override. */
745
+ matchOverride(path, method) {
746
+ for (const o of this.overrides) {
747
+ if (o.method && o.method.toUpperCase() !== method.toUpperCase()) continue;
748
+ const hit = typeof o.matcher === "string" ? path.includes(o.matcher) : new RegExp(o.matcher.regex, o.matcher.flags).test(path);
749
+ if (!hit) continue;
750
+ o.hits++;
751
+ o.remaining--;
752
+ if (o.remaining <= 0) this.overrides = this.overrides.filter((x) => x !== o);
753
+ return o;
754
+ }
755
+ return null;
756
+ }
757
+ /** Build the response an override dictates. */
758
+ overrideResponse(o) {
759
+ if (o.malformed) return malformed(o.malformed, o.body);
760
+ const status = o.status ?? 500;
761
+ if (status === 204 || o.body === void 0 && status >= 200 && status < 300) return new Response(null, { status });
762
+ if (o.body === void 0) return problem(status, `forced by override ${o.id}`);
763
+ return json(o.body, { status });
764
+ }
765
+ /** Fail-mode response, or null when the request should proceed. */
766
+ failResponse(path) {
767
+ if (this.failMode === "off") return null;
768
+ if (this.failMode === "data" && this.shellPaths.has(path)) return null;
769
+ return problem(503, `scenario fail mode "${this.failMode}"`);
770
+ }
771
+ snapshot() {
772
+ return { latencyMs: this.latencyMs, jitterMs: this.jitterMs, failMode: this.failMode, shellPaths: [...this.shellPaths], overrides: this.listOverrides() };
773
+ }
774
+ };
775
+ function matcherKey(matcher, method) {
776
+ const m = typeof matcher === "string" ? `str:${matcher}` : `re:${matcher.regex}:${matcher.flags ?? ""}`;
777
+ return `${(method ?? "*").toUpperCase()} ${m}`;
778
+ }
779
+
780
+ // src/core/scenario.ts
781
+ var Run = class {
782
+ constructor(id, scenario, seed, sink) {
783
+ this.id = id;
784
+ this.scenario = scenario;
785
+ this.seed = seed;
786
+ this.sink = sink;
787
+ this.rng = new Rng(seed);
788
+ this.clock = new VirtualClock(scenario.clock?.mode ?? "realtime", scenario.clock?.speed ?? 1);
789
+ this.state = new Store(() => this.clock.now());
790
+ this.streams = new StreamHub(this.clock, (m, d) => this.log(m, d));
791
+ this.faults = new FaultLayer(this.rng.fork("faults"));
792
+ if (scenario.faults) this.faults.configure(scenario.faults);
793
+ this.routes = (scenario.routes ?? []).map(compileRoute);
794
+ this.wsRoutes = (scenario.streams ?? []).filter((s) => s.kind === "ws");
795
+ this.sseRoutes = (scenario.streams ?? []).filter((s) => s.kind === "sse");
796
+ this.ready = Promise.resolve(scenario.setup?.(this.setupContext())).then(() => this.log(`run ready (${scenario.name}, seed ${seed})`));
797
+ }
798
+ id;
799
+ scenario;
800
+ seed;
801
+ sink;
802
+ rng;
803
+ clock;
804
+ state;
805
+ streams;
806
+ faults;
807
+ routes;
808
+ wsRoutes;
809
+ sseRoutes;
810
+ createdWall = Date.now();
811
+ requests = 0;
812
+ logEntries = [];
813
+ logSeq = 0;
814
+ ready;
815
+ info() {
816
+ return { id: this.id, scenario: this.scenario.name, seed: this.seed };
817
+ }
818
+ setupContext() {
819
+ return { state: this.state, rng: this.rng, clock: this.clock, streams: this.streams, run: this.info(), log: (m, d) => this.log(m, d) };
820
+ }
821
+ log(message, data) {
822
+ const entry = { seq: ++this.logSeq, t: Math.round(this.clock.now()), message, ...data !== void 0 ? { data } : {} };
823
+ this.logEntries.push(entry);
824
+ if (this.logEntries.length > 2e3) this.logEntries.splice(0, this.logEntries.length - 2e3);
825
+ this.sink?.(`[sim ${this.id}] ${message}`);
826
+ }
827
+ logs(opts = {}) {
828
+ const out = this.logEntries.filter((e) => e.seq > (opts.since ?? 0));
829
+ const limit = opts.limit ?? 200;
830
+ return out.length > limit ? out.slice(out.length - limit) : out;
831
+ }
832
+ async action(name, args = {}) {
833
+ const fn = this.scenario.actions?.[name];
834
+ if (!fn) throw new Error(`unknown action "${name}" (available: ${Object.keys(this.scenario.actions ?? {}).join(", ") || "none"})`);
835
+ this.log(`action ${name}`, args);
836
+ return fn({ ...this.setupContext(), args });
837
+ }
838
+ status() {
839
+ return {
840
+ run: this.id,
841
+ scenario: this.scenario.name,
842
+ label: this.scenario.label ?? null,
843
+ seed: this.seed,
844
+ createdWall: this.createdWall,
845
+ requests: this.requests,
846
+ clock: { mode: this.clock.mode, speed: this.clock.speed, now: Math.round(this.clock.now()), wall: this.clock.iso(), pending: this.clock.pending() },
847
+ state: { collections: this.state.counts(), lastSeq: this.state.lastSeq },
848
+ streams: this.streams.list(),
849
+ topics: this.streams.topics.topics().map((t) => ({ topic: t, last: this.streams.topics.last(t) })),
850
+ faults: this.faults.snapshot(),
851
+ routes: this.routes.map((r) => ({ method: r.method, path: r.path, calls: r.calls, name: r.name ?? null })),
852
+ streamRoutes: [...this.wsRoutes.map((r) => ({ kind: "ws", path: r.path, protocols: r.protocols ?? [] })), ...this.sseRoutes.map((r) => ({ kind: "sse", path: r.path, method: r.method ?? "GET" }))],
853
+ actions: Object.keys(this.scenario.actions ?? {})
854
+ };
855
+ }
856
+ dispose() {
857
+ this.streams.closeAll();
858
+ this.clock.clear();
859
+ this.log("run disposed");
860
+ }
861
+ };
862
+
863
+ // src/core/engine.ts
864
+ var UPGRADED_HEADER = "x-sim-upgraded";
865
+ var upgradedResponse = () => new Response(null, { status: 200, headers: { [UPGRADED_HEADER]: "1" } });
866
+ var isUpgraded = (response) => response.headers.get(UPGRADED_HEADER) === "1";
867
+ var Simulator = class {
868
+ scenarios = /* @__PURE__ */ new Map();
869
+ runs = /* @__PURE__ */ new Map();
870
+ controlPath;
871
+ defaultRun;
872
+ defaultScenario;
873
+ cors;
874
+ log;
875
+ constructor(options) {
876
+ if (options.scenarios.length === 0) throw new Error("at least one scenario is required");
877
+ for (const s of options.scenarios) {
878
+ if (this.scenarios.has(s.name)) throw new Error(`duplicate scenario ${s.name}`);
879
+ this.scenarios.set(s.name, s);
880
+ }
881
+ this.defaultScenario = options.defaultScenario ?? options.scenarios[0].name;
882
+ if (!this.scenarios.has(this.defaultScenario)) throw new Error(`unknown default scenario ${this.defaultScenario}`);
883
+ this.controlPath = (options.controlPath ?? "/__sim").replace(/\/$/, "");
884
+ this.defaultRun = options.defaultRun ?? "default";
885
+ this.cors = options.cors ?? true;
886
+ this.log = options.log ?? (() => {
887
+ });
888
+ }
889
+ // ── runs ─────────────────────────────────────────────────────────
890
+ cookie(request, name) {
891
+ const header = request.headers.get("cookie") ?? "";
892
+ for (const part of header.split(";")) {
893
+ const [k, ...rest] = part.trim().split("=");
894
+ if (k === name) return decodeURIComponent(rest.join("="));
895
+ }
896
+ return null;
897
+ }
898
+ runIdFor(request, url) {
899
+ return request.headers.get("x-sim-run") ?? this.cookie(request, "sim_run") ?? url.searchParams.get("__run") ?? this.defaultRun;
900
+ }
901
+ scenarioNameFor(request, url) {
902
+ return request.headers.get("x-sim-scenario") ?? this.cookie(request, "sim_scenario") ?? url.searchParams.get("__scenario") ?? url.searchParams.get("scenario") ?? this.defaultScenario;
903
+ }
904
+ /** Get or lazily create the run for a request. */
905
+ async runFor(request, url) {
906
+ const id = this.runIdFor(request, url);
907
+ let run = this.runs.get(id);
908
+ if (!run) {
909
+ const name = this.scenarioNameFor(request, url);
910
+ run = this.createRun(id, name);
911
+ }
912
+ await run.ready;
913
+ return run;
914
+ }
915
+ createRun(id, scenarioName, seed) {
916
+ const scenario = this.scenarios.get(scenarioName);
917
+ if (!scenario) throw new Error(`unknown scenario "${scenarioName}" (available: ${[...this.scenarios.keys()].join(", ")})`);
918
+ this.runs.get(id)?.dispose();
919
+ const run = new Run(id, scenario, seed ?? scenario.seed ?? scenario.name, this.log);
920
+ this.runs.set(id, run);
921
+ return run;
922
+ }
923
+ getRun(id = this.defaultRun) {
924
+ return this.runs.get(id) ?? null;
925
+ }
926
+ // ── request handling ─────────────────────────────────────────────
927
+ async handle(request, options = {}) {
928
+ const url = new URL(request.url);
929
+ try {
930
+ if (request.method === "OPTIONS" && this.cors) return this.withCors(request, new Response(null, { status: 204 }));
931
+ if (url.pathname === this.controlPath || url.pathname.startsWith(`${this.controlPath}/`)) {
932
+ return this.withCors(request, await this.control(request, url));
933
+ }
934
+ const run = await this.runFor(request, url);
935
+ run.requests++;
936
+ const path = url.pathname;
937
+ const method = request.method.toUpperCase();
938
+ const latencyHeader = request.headers.get("x-sim-latency");
939
+ const failHeader = request.headers.get("x-sim-fail");
940
+ const extraLatency = latencyHeader !== null && latencyHeader !== "" && Number.isFinite(Number(latencyHeader)) ? Math.max(0, Number(latencyHeader)) : 0;
941
+ const failOverride = failHeader === "off" || failHeader === "data" || failHeader === "all" ? failHeader : null;
942
+ const failResponse = (p) => {
943
+ if (failOverride === null) return run.faults.failResponse(p);
944
+ if (failOverride === "off") return null;
945
+ if (failOverride === "data" && run.faults.shellPaths.has(p)) return null;
946
+ return problem(503, `scenario fail mode "${failOverride}" (X-Sim-Fail)`);
947
+ };
948
+ const override = run.faults.matchOverride(path, method);
949
+ if (override) {
950
+ run.log(`${method} ${path} \u2192 override ${override.id}`, { status: override.status ?? null, malformed: override.malformed ?? null });
951
+ await run.clock.sleep(run.faults.delayFor(override.delayMs ?? 0), `override ${override.id}`);
952
+ return this.withCors(request, run.faults.overrideResponse(override));
953
+ }
954
+ const upgradeHeader = request.headers.get("upgrade") ?? request.headers.get("x-sim-upgrade");
955
+ if (upgradeHeader?.toLowerCase() === "websocket") {
956
+ for (const route of run.wsRoutes) {
957
+ const params = matchPath(route.path, path);
958
+ if (!params) continue;
959
+ if (!options.upgrade) return this.withCors(request, problem(426, "WebSocket upgrade requires a transport adapter"));
960
+ const requested = (request.headers.get("sec-websocket-protocol") ?? request.headers.get("x-sim-websocket-protocol") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
961
+ const protocol = route.protocols?.find((p) => requested.includes(p)) ?? null;
962
+ if (route.protocols?.length && requested.length && !protocol) return this.withCors(request, problem(400, `unsupported subprotocol (server offers ${route.protocols.join(", ")})`));
963
+ const failed2 = failResponse(path);
964
+ if (failed2) return this.withCors(request, failed2);
965
+ return options.upgrade(route, this.streamContext(run, request, url, params), run, protocol);
966
+ }
967
+ return this.withCors(request, problem(404, `no WebSocket route matches ${path}`));
968
+ }
969
+ const failed = failResponse(path);
970
+ if (failed) {
971
+ run.log(`${method} ${path} \u2192 fail mode ${failOverride ?? run.faults.failMode}`);
972
+ await run.clock.sleep(run.faults.delayFor(extraLatency), "fail-mode latency");
973
+ return this.withCors(request, failed);
974
+ }
975
+ for (const route of run.sseRoutes) {
976
+ if ((route.method ?? "GET") !== method) continue;
977
+ const params = matchPath(route.path, path);
978
+ if (!params) continue;
979
+ await run.clock.sleep(run.faults.delayFor(extraLatency), "sse open latency");
980
+ const stream = run.streams.openSse(route, this.streamContext(run, request, url, params));
981
+ return this.withCors(request, stream.response);
982
+ }
983
+ for (const route of run.routes) {
984
+ if (route.method !== "*" && route.method !== method) continue;
985
+ const params = route.match(path);
986
+ if (!params) continue;
987
+ route.calls++;
988
+ const ctx = this.routeContext(run, request, url, params, route.calls);
989
+ const started = run.clock.now();
990
+ await run.clock.sleep(run.faults.delayFor(extraLatency), `latency ${method} ${path}`);
991
+ const response = await route.handler(ctx);
992
+ run.log(`${method} ${path} \u2192 ${response.status}`, { route: route.name ?? route.path, ms: Math.round(run.clock.now() - started) });
993
+ return this.withCors(request, response);
994
+ }
995
+ run.log(`${method} ${path} \u2192 404 (no route)`);
996
+ return this.withCors(request, problem(404, `scenario "${run.scenario.name}" has no route for ${method} ${path}`, { hint: `${this.controlPath}/status lists routes; set an override to force an answer` }));
997
+ } catch (err) {
998
+ this.log(`[sim] error handling ${request.method} ${url.pathname}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
999
+ return this.withCors(request, problem(500, `simulator error: ${err instanceof Error ? err.message : String(err)}`));
1000
+ }
1001
+ }
1002
+ routeContext(run, request, url, params, calls) {
1003
+ let parsed = null;
1004
+ return {
1005
+ request,
1006
+ url,
1007
+ method: request.method.toUpperCase(),
1008
+ params,
1009
+ query: url.searchParams,
1010
+ body: () => {
1011
+ parsed ??= request.clone().text().then((t) => t ? JSON.parse(t) : {}).catch(() => ({}));
1012
+ return parsed;
1013
+ },
1014
+ state: run.state,
1015
+ rng: run.rng,
1016
+ clock: run.clock,
1017
+ streams: run.streams,
1018
+ run: run.info(),
1019
+ calls,
1020
+ log: (m, d) => run.log(m, d)
1021
+ };
1022
+ }
1023
+ streamContext(run, request, url, params) {
1024
+ return { request, url, params, query: url.searchParams, state: run.state, rng: run.rng, clock: run.clock, streams: run.streams, run: run.info(), log: (m, d) => run.log(m, d) };
1025
+ }
1026
+ withCors(request, response) {
1027
+ if (!this.cors) return response;
1028
+ const origin = request.headers.get("origin");
1029
+ if (!origin) return response;
1030
+ const headers = new Headers(response.headers);
1031
+ headers.set("access-control-allow-origin", origin);
1032
+ headers.set("access-control-allow-credentials", "true");
1033
+ headers.set("access-control-allow-methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
1034
+ headers.set("access-control-allow-headers", request.headers.get("access-control-request-headers") ?? "*");
1035
+ headers.set("access-control-expose-headers", "*");
1036
+ headers.set("vary", "origin");
1037
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
1038
+ }
1039
+ // ── control plane ────────────────────────────────────────────────
1040
+ async control(request, url) {
1041
+ const route = url.pathname.slice(this.controlPath.length).replace(/\/$/, "") || "/";
1042
+ const method = request.method.toUpperCase();
1043
+ const body = method === "GET" || method === "DELETE" ? {} : await request.clone().text().then((t) => t ? JSON.parse(t) : {}).catch(() => ({}));
1044
+ const runId = String(body.run ?? url.searchParams.get("run") ?? request.headers.get("x-sim-run") ?? this.cookie(request, "sim_run") ?? this.defaultRun);
1045
+ const api = this.api;
1046
+ const q = (k) => url.searchParams.get(k);
1047
+ const num = (v, d) => v === void 0 || v === null || v === "" ? d : Number(v);
1048
+ switch (`${method} ${route}`) {
1049
+ case "GET /":
1050
+ return json({ tool: "@omniaura/scenario-sim", controlPath: this.controlPath, defaultRun: this.defaultRun, defaultScenario: this.defaultScenario, runs: [...this.runs.keys()] });
1051
+ case "GET /scenarios":
1052
+ return json({ default: this.defaultScenario, scenarios: api.scenarios() });
1053
+ case "GET /status":
1054
+ return json(await api.status(runId));
1055
+ case "POST /select": {
1056
+ const b = body;
1057
+ if (!b.scenario) return problem(400, "scenario is required");
1058
+ return json(await api.select(runId, b.scenario, b.seed), { headers: { "set-cookie": `sim_scenario=${encodeURIComponent(b.scenario)}; Path=/; SameSite=Lax` } });
1059
+ }
1060
+ case "POST /reset":
1061
+ return json(await api.reset(runId, body.seed));
1062
+ case "POST /step":
1063
+ return json(api.step(runId, num(body.ms ?? q("ms"), 1e3)));
1064
+ case "POST /clock":
1065
+ return json(api.clock(runId, body));
1066
+ case "GET /state":
1067
+ return json(api.state(runId, q("collection") ?? void 0));
1068
+ case "GET /events":
1069
+ return json(api.events(runId, { since: num(q("since"), 0), limit: num(q("limit"), 200), collection: q("collection") ?? void 0 }));
1070
+ case "GET /log":
1071
+ return json(api.log(runId, { since: num(q("since"), 0), limit: num(q("limit"), 200) }));
1072
+ case "GET /streams":
1073
+ return json(api.streams(runId));
1074
+ case "POST /streams/disconnect": {
1075
+ const b = body;
1076
+ return json(api.disconnect(runId, b, b));
1077
+ }
1078
+ case "POST /streams/pause": {
1079
+ const b = body;
1080
+ if (!b.id) return problem(400, "id is required");
1081
+ return json({ ok: api.pause(runId, b.id, b.paused ?? true) });
1082
+ }
1083
+ case "POST /publish": {
1084
+ const b = body;
1085
+ if (!b.topic) return problem(400, "topic is required");
1086
+ return json(api.publish(runId, b.topic, b.data ?? {}));
1087
+ }
1088
+ case "GET /overrides":
1089
+ return json(api.overrides(runId));
1090
+ case "POST /overrides": {
1091
+ const b = body;
1092
+ if (!b.matcher) return problem(400, "matcher is required");
1093
+ return json(api.setOverride(runId, b));
1094
+ }
1095
+ case "DELETE /overrides": {
1096
+ const matcher = q("matcher");
1097
+ const id = q("id");
1098
+ return json(api.clearOverrides(runId, id ?? matcher ?? void 0, q("method") ?? void 0));
1099
+ }
1100
+ case "POST /faults":
1101
+ return json(api.faults(runId, body));
1102
+ case "POST /action": {
1103
+ const b = body;
1104
+ if (!b.name) return problem(400, "name is required");
1105
+ return json({ result: await api.action(runId, b.name, b.args ?? {}) });
1106
+ }
1107
+ case "GET /runs":
1108
+ return json(api.runs());
1109
+ case "DELETE /runs":
1110
+ return json({ deleted: api.deleteRun(runId) });
1111
+ default:
1112
+ return problem(404, `unknown control route ${method} ${route}`);
1113
+ }
1114
+ }
1115
+ /** In-process control API — the same operations the HTTP control plane offers. */
1116
+ api = {
1117
+ scenarios: () => [...this.scenarios.values()].map((s) => ({ name: s.name, label: s.label ?? null, description: s.description ?? null, seed: s.seed ?? s.name, tags: s.tags ?? [], clock: s.clock ?? { mode: "realtime" }, actions: Object.keys(s.actions ?? {}), qa: s.qa ?? null })),
1118
+ status: async (runId = this.defaultRun) => {
1119
+ const run = this.runs.get(runId);
1120
+ if (!run) return { run: runId, scenario: null, exists: false, defaultScenario: this.defaultScenario };
1121
+ await run.ready;
1122
+ return { exists: true, ...run.status() };
1123
+ },
1124
+ select: async (runId, scenario, seed) => {
1125
+ const run = this.createRun(runId, scenario, seed);
1126
+ await run.ready;
1127
+ return run.status();
1128
+ },
1129
+ reset: async (runId, seed) => {
1130
+ const existing = this.runs.get(runId);
1131
+ const run = this.createRun(runId, existing?.scenario.name ?? this.defaultScenario, seed ?? existing?.seed);
1132
+ await run.ready;
1133
+ return run.status();
1134
+ },
1135
+ step: (runId, ms) => {
1136
+ const run = this.require(runId);
1137
+ if (run.clock.mode !== "manual") run.clock.setMode("manual");
1138
+ const r = run.clock.step(ms);
1139
+ run.log(`step ${ms}ms \u2192 now ${r.now}`, { fired: r.fired.length });
1140
+ return { ...r, pending: run.clock.pending() };
1141
+ },
1142
+ clock: (runId, patch) => {
1143
+ const run = this.require(runId);
1144
+ if (patch.speed !== void 0) run.clock.speed = Math.max(0.01, Number(patch.speed));
1145
+ if (patch.mode) run.clock.setMode(patch.mode);
1146
+ return { mode: run.clock.mode, speed: run.clock.speed, now: Math.round(run.clock.now()), pending: run.clock.pending() };
1147
+ },
1148
+ state: (runId, collection) => {
1149
+ const run = this.require(runId);
1150
+ const snap = run.state.snapshot();
1151
+ return collection ? { run: runId, collection, items: snap[collection] ?? [] } : { run: runId, counts: run.state.counts(), collections: snap };
1152
+ },
1153
+ events: (runId, opts) => {
1154
+ const run = this.require(runId);
1155
+ const events = run.state.events(opts);
1156
+ return { run: runId, last: run.state.lastSeq, count: events.length, events };
1157
+ },
1158
+ log: (runId, opts) => ({ run: runId, entries: this.require(runId).logs(opts) }),
1159
+ streams: (runId) => {
1160
+ const run = this.require(runId);
1161
+ return { run: runId, connections: run.streams.list(), topics: run.streams.topics.topics().map((t) => ({ topic: t, last: run.streams.topics.last(t) })), latencyMs: run.streams.latencyMs };
1162
+ },
1163
+ disconnect: (runId, target, opts = {}) => {
1164
+ const run = this.require(runId);
1165
+ const closed = run.streams.disconnect(target, opts);
1166
+ run.log(`disconnect ${JSON.stringify(target)}`, { closed, drop: opts.drop ?? false });
1167
+ return { closed };
1168
+ },
1169
+ pause: (runId, id, paused) => this.require(runId).streams.pause(id, paused),
1170
+ publish: (runId, topic, data) => this.require(runId).streams.publish(topic, data),
1171
+ overrides: (runId) => ({ run: runId, overrides: this.require(runId).faults.listOverrides() }),
1172
+ setOverride: (runId, input) => {
1173
+ const run = this.require(runId);
1174
+ const o = run.faults.setOverride(input);
1175
+ run.log(`override set ${o.id}`, { matcher: o.matcher, method: o.method ?? "*", status: o.status ?? null, times: o.times ?? null });
1176
+ return o;
1177
+ },
1178
+ clearOverrides: (runId, target, method) => {
1179
+ const run = this.require(runId);
1180
+ if (target) return { cleared: run.faults.clearOverride(target, method) };
1181
+ run.faults.clearOverrides();
1182
+ return { cleared: true };
1183
+ },
1184
+ faults: (runId, patch) => {
1185
+ const run = this.require(runId);
1186
+ run.faults.configure(patch);
1187
+ if (patch.streamLatencyMs !== void 0) run.streams.latencyMs = Math.max(0, patch.streamLatencyMs);
1188
+ run.log("faults configured", patch);
1189
+ return { ...run.faults.snapshot(), streamLatencyMs: run.streams.latencyMs };
1190
+ },
1191
+ action: (runId, name, args = {}) => this.require(runId).action(name, args),
1192
+ runs: () => [...this.runs.values()].map((r) => ({ id: r.id, scenario: r.scenario.name, seed: r.seed, requests: r.requests, createdWall: r.createdWall })),
1193
+ deleteRun: (runId) => {
1194
+ const run = this.runs.get(runId);
1195
+ if (!run) return false;
1196
+ run.dispose();
1197
+ this.runs.delete(runId);
1198
+ return true;
1199
+ }
1200
+ };
1201
+ require(runId = this.defaultRun) {
1202
+ const run = this.runs.get(runId) ?? this.createRun(runId, this.defaultScenario);
1203
+ return run;
1204
+ }
1205
+ dispose() {
1206
+ for (const run of this.runs.values()) run.dispose();
1207
+ this.runs.clear();
1208
+ }
1209
+ };
1210
+ function matchPath(pattern, pathname) {
1211
+ const keys = [];
1212
+ const re = new RegExp(
1213
+ "^" + pattern.split("/").map((seg) => {
1214
+ if (seg === "*") return "(?:.*)";
1215
+ if (seg.startsWith(":")) {
1216
+ keys.push(seg.slice(1));
1217
+ return "([^/]+)";
1218
+ }
1219
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1220
+ }).join("/") + "/?$"
1221
+ );
1222
+ const m = re.exec(pathname);
1223
+ if (!m) return null;
1224
+ const params = {};
1225
+ keys.forEach((k, i) => params[k] = decodeURIComponent(m[i + 1] ?? ""));
1226
+ return params;
1227
+ }
1228
+
1229
+ export {
1230
+ UPGRADED_HEADER,
1231
+ upgradedResponse,
1232
+ isUpgraded,
1233
+ Simulator,
1234
+ matchPath
1235
+ };
1236
+ //# sourceMappingURL=chunk-KU4W4SKO.js.map