@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.
- package/README.md +118 -0
- package/dist/browser.d.ts +81 -0
- package/dist/browser.js +303 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-44ZHRYG6.js +959 -0
- package/dist/chunk-44ZHRYG6.js.map +1 -0
- package/dist/chunk-7FWVWBX6.js +380 -0
- package/dist/chunk-7FWVWBX6.js.map +1 -0
- package/dist/chunk-7HBAY3QY.js +133 -0
- package/dist/chunk-7HBAY3QY.js.map +1 -0
- package/dist/chunk-KU4W4SKO.js +1236 -0
- package/dist/chunk-KU4W4SKO.js.map +1 -0
- package/dist/cli.js +131 -0
- package/dist/cli.js.map +1 -0
- package/dist/engine-2w32ngB2.d.ts +897 -0
- package/dist/engine-HE7MEQHD.js +15 -0
- package/dist/engine-HE7MEQHD.js.map +1 -0
- package/dist/examples.d.ts +38 -0
- package/dist/examples.js +233 -0
- package/dist/examples.js.map +1 -0
- package/dist/index.d.ts +322 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/pulse.d.ts +40 -0
- package/dist/pulse.js +80 -0
- package/dist/pulse.js.map +1 -0
- package/dist/scenario--CvzkzX_.d.ts +637 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +14 -0
- package/dist/server.js.map +1 -0
- package/dist/vite.d.ts +27 -0
- package/dist/vite.js +48 -0
- package/dist/vite.js.map +1 -0
- package/package.json +91 -0
|
@@ -0,0 +1,959 @@
|
|
|
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/router.ts
|
|
305
|
+
var JSON_HEADERS = { "content-type": "application/json", "cache-control": "no-store" };
|
|
306
|
+
function json(body, init = {}) {
|
|
307
|
+
return new Response(body === void 0 ? null : JSON.stringify(body), { ...init, status: init.status ?? 200, headers: { ...JSON_HEADERS, ...init.headers } });
|
|
308
|
+
}
|
|
309
|
+
function text(body, init = {}) {
|
|
310
|
+
return new Response(body, { ...init, headers: { "content-type": "text/plain; charset=utf-8", ...init.headers } });
|
|
311
|
+
}
|
|
312
|
+
function empty(status = 204, init = {}) {
|
|
313
|
+
return new Response(null, { ...init, status });
|
|
314
|
+
}
|
|
315
|
+
function problem(status, detail, extra = {}) {
|
|
316
|
+
return json({ type: "about:blank", title: statusText(status), status, detail, ...extra }, { status, headers: { "content-type": "application/problem+json" } });
|
|
317
|
+
}
|
|
318
|
+
function malformed(kind, extra) {
|
|
319
|
+
switch (kind) {
|
|
320
|
+
case "invalid-json":
|
|
321
|
+
return new Response('{"items": [1, 2,', { status: 200, headers: JSON_HEADERS });
|
|
322
|
+
case "wrong-content-type":
|
|
323
|
+
return new Response(JSON.stringify(extra ?? { ok: true }), { status: 200, headers: { "content-type": "text/html" } });
|
|
324
|
+
case "truncated":
|
|
325
|
+
return new Response(JSON.stringify(extra ?? { items: [{ id: "a" }] }).slice(0, -6), { status: 200, headers: JSON_HEADERS });
|
|
326
|
+
case "empty-200":
|
|
327
|
+
return new Response("", { status: 200, headers: JSON_HEADERS });
|
|
328
|
+
case "html-500":
|
|
329
|
+
return new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", { status: 502, headers: { "content-type": "text/html" } });
|
|
330
|
+
case "schema-drift":
|
|
331
|
+
return json(extra ?? { data: { items: "not-an-array" }, meta: null });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function sequence(steps, opts = {}) {
|
|
335
|
+
let i = 0;
|
|
336
|
+
return async (ctx) => {
|
|
337
|
+
const idx = opts.loop ? i % steps.length : Math.min(i, steps.length - 1);
|
|
338
|
+
i++;
|
|
339
|
+
const step = steps[idx];
|
|
340
|
+
if (step instanceof Response) return step.clone();
|
|
341
|
+
return step(ctx);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function delayed(ms, handler) {
|
|
345
|
+
return async (ctx) => {
|
|
346
|
+
await ctx.clock.sleep(typeof ms === "function" ? ms(ctx) : ms, `delay ${ctx.method} ${ctx.url.pathname}`);
|
|
347
|
+
return handler(ctx);
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function compileRoute(route2) {
|
|
351
|
+
const keys = [];
|
|
352
|
+
const pattern = route2.path.split("/").map((seg) => {
|
|
353
|
+
if (seg === "*") return "(?:.*)";
|
|
354
|
+
if (seg.startsWith(":")) {
|
|
355
|
+
keys.push(seg.slice(1));
|
|
356
|
+
return "([^/]+)";
|
|
357
|
+
}
|
|
358
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
359
|
+
}).join("/");
|
|
360
|
+
const re = new RegExp(`^${pattern}/?$`);
|
|
361
|
+
return {
|
|
362
|
+
...route2,
|
|
363
|
+
calls: 0,
|
|
364
|
+
match(pathname) {
|
|
365
|
+
const m = re.exec(pathname);
|
|
366
|
+
if (!m) return null;
|
|
367
|
+
const params = {};
|
|
368
|
+
keys.forEach((k, i) => params[k] = decodeURIComponent(m[i + 1] ?? ""));
|
|
369
|
+
return params;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function statusText(status) {
|
|
374
|
+
return {
|
|
375
|
+
400: "Bad Request",
|
|
376
|
+
401: "Unauthorized",
|
|
377
|
+
402: "Payment Required",
|
|
378
|
+
403: "Forbidden",
|
|
379
|
+
404: "Not Found",
|
|
380
|
+
409: "Conflict",
|
|
381
|
+
422: "Unprocessable Content",
|
|
382
|
+
429: "Too Many Requests",
|
|
383
|
+
500: "Internal Server Error",
|
|
384
|
+
502: "Bad Gateway",
|
|
385
|
+
503: "Service Unavailable",
|
|
386
|
+
504: "Gateway Timeout"
|
|
387
|
+
}[status] ?? "Error";
|
|
388
|
+
}
|
|
389
|
+
var route = {
|
|
390
|
+
get: (path, handler, name) => ({ method: "GET", path, handler, name }),
|
|
391
|
+
post: (path, handler, name) => ({ method: "POST", path, handler, name }),
|
|
392
|
+
put: (path, handler, name) => ({ method: "PUT", path, handler, name }),
|
|
393
|
+
patch: (path, handler, name) => ({ method: "PATCH", path, handler, name }),
|
|
394
|
+
delete: (path, handler, name) => ({ method: "DELETE", path, handler, name }),
|
|
395
|
+
any: (path, handler, name) => ({ method: "*", path, handler, name })
|
|
396
|
+
};
|
|
397
|
+
function crud(path, collection, opts) {
|
|
398
|
+
const wrap = opts.list ?? ((items) => ({ items }));
|
|
399
|
+
return [
|
|
400
|
+
route.get(path, (ctx) => json(wrap(ctx.state.list(collection, { sort: opts.sort }), ctx)), `${collection}.list`),
|
|
401
|
+
route.post(path, async (ctx) => {
|
|
402
|
+
const body = await ctx.body();
|
|
403
|
+
const invalid = opts.validate?.(body, ctx);
|
|
404
|
+
if (invalid) return problem(422, invalid);
|
|
405
|
+
const record = opts.create(body, ctx);
|
|
406
|
+
ctx.state.insert(collection, record);
|
|
407
|
+
return json(record, { status: 201 });
|
|
408
|
+
}, `${collection}.create`),
|
|
409
|
+
route.get(`${path}/:id`, (ctx) => {
|
|
410
|
+
const rec = ctx.state.get(collection, ctx.params.id);
|
|
411
|
+
return rec ? json(rec) : problem(404, `${collection} ${ctx.params.id} not found`);
|
|
412
|
+
}, `${collection}.read`),
|
|
413
|
+
route.patch(`${path}/:id`, async (ctx) => {
|
|
414
|
+
const body = await ctx.body();
|
|
415
|
+
const invalid = opts.validate?.(body, ctx);
|
|
416
|
+
if (invalid) return problem(422, invalid);
|
|
417
|
+
const next = ctx.state.update(collection, ctx.params.id, (cur) => opts.update ? opts.update(cur, body, ctx) : { ...cur, ...body, id: cur.id });
|
|
418
|
+
return next ? json(next) : problem(404, `${collection} ${ctx.params.id} not found`);
|
|
419
|
+
}, `${collection}.update`),
|
|
420
|
+
route.put(`${path}/:id`, async (ctx) => {
|
|
421
|
+
const body = await ctx.body();
|
|
422
|
+
const invalid = opts.validate?.(body, ctx);
|
|
423
|
+
if (invalid) return problem(422, invalid);
|
|
424
|
+
const next = ctx.state.update(collection, ctx.params.id, (cur) => opts.update ? opts.update(cur, body, ctx) : { ...cur, ...body, id: cur.id });
|
|
425
|
+
return next ? json(next) : problem(404, `${collection} ${ctx.params.id} not found`);
|
|
426
|
+
}, `${collection}.replace`),
|
|
427
|
+
route.delete(`${path}/:id`, (ctx) => {
|
|
428
|
+
const removed = ctx.state.remove(collection, ctx.params.id);
|
|
429
|
+
return removed ? empty(204) : problem(404, `${collection} ${ctx.params.id} not found`);
|
|
430
|
+
}, `${collection}.delete`)
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/core/streams.ts
|
|
435
|
+
var TopicLog = class {
|
|
436
|
+
constructor(now, retain = 500) {
|
|
437
|
+
this.now = now;
|
|
438
|
+
this.retain = retain;
|
|
439
|
+
}
|
|
440
|
+
now;
|
|
441
|
+
retain;
|
|
442
|
+
seqs = /* @__PURE__ */ new Map();
|
|
443
|
+
logs = /* @__PURE__ */ new Map();
|
|
444
|
+
publish(topic, data) {
|
|
445
|
+
const seq = (this.seqs.get(topic) ?? 0) + 1;
|
|
446
|
+
this.seqs.set(topic, seq);
|
|
447
|
+
const event = { topic, eventID: String(seq), t: this.now(), data };
|
|
448
|
+
const log = this.logs.get(topic) ?? [];
|
|
449
|
+
log.push(event);
|
|
450
|
+
if (log.length > this.retain) log.splice(0, log.length - this.retain);
|
|
451
|
+
this.logs.set(topic, log);
|
|
452
|
+
return event;
|
|
453
|
+
}
|
|
454
|
+
last(topic) {
|
|
455
|
+
return String(this.seqs.get(topic) ?? 0);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Events after `since`. `missed` is true when `since` predates the retained
|
|
459
|
+
* window. `null`/`undefined` means "no resume point": live only, nothing is
|
|
460
|
+
* replayed (what a fresh EventSource without Last-Event-ID gets). Pass "0"
|
|
461
|
+
* to replay everything retained.
|
|
462
|
+
*/
|
|
463
|
+
replay(topic, since) {
|
|
464
|
+
if (since === null || since === void 0 || since === "") return { events: [], missed: false };
|
|
465
|
+
const s = Number(since);
|
|
466
|
+
const log = this.logs.get(topic) ?? [];
|
|
467
|
+
const oldest = log[0] ? Number(log[0].eventID) : (this.seqs.get(topic) ?? 0) + 1;
|
|
468
|
+
const missed = s > 0 && s < oldest - 1;
|
|
469
|
+
return { events: log.filter((e) => Number(e.eventID) > s), missed };
|
|
470
|
+
}
|
|
471
|
+
topics() {
|
|
472
|
+
return [...this.seqs.keys()];
|
|
473
|
+
}
|
|
474
|
+
clear() {
|
|
475
|
+
this.seqs.clear();
|
|
476
|
+
this.logs.clear();
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
var defaultSerializer = (e) => JSON.stringify({ ...e.data, topic: e.topic, eventID: e.eventID });
|
|
480
|
+
var ws = (path, def) => ({ kind: "ws", path, ...def });
|
|
481
|
+
var sse = (path, onOpen, def = {}) => ({ kind: "sse", path, onOpen, ...def });
|
|
482
|
+
var StreamHub = class {
|
|
483
|
+
constructor(clock, log) {
|
|
484
|
+
this.clock = clock;
|
|
485
|
+
this.log = log;
|
|
486
|
+
this.topics = new TopicLog(() => clock.now());
|
|
487
|
+
}
|
|
488
|
+
clock;
|
|
489
|
+
log;
|
|
490
|
+
topics;
|
|
491
|
+
connections = /* @__PURE__ */ new Map();
|
|
492
|
+
nextId = 1;
|
|
493
|
+
/** Extra virtual latency applied to every outgoing stream message. */
|
|
494
|
+
latencyMs = 0;
|
|
495
|
+
/** Publish to a topic: appended to the replay log and fanned out to subscribers. */
|
|
496
|
+
publish(topic, data) {
|
|
497
|
+
const event = this.topics.publish(topic, data);
|
|
498
|
+
for (const conn of this.connections.values()) {
|
|
499
|
+
const sub = conn.subscriptions.get(topic);
|
|
500
|
+
if (!sub) continue;
|
|
501
|
+
if (conn.kind === "ws") this.deliver(conn, sub.serialize(event));
|
|
502
|
+
else this.deliverSse(conn, event, sub.serialize);
|
|
503
|
+
}
|
|
504
|
+
return event;
|
|
505
|
+
}
|
|
506
|
+
deliver(conn, payload) {
|
|
507
|
+
if (conn.readyState !== "open") return;
|
|
508
|
+
const doSend = () => {
|
|
509
|
+
if (conn.readyState !== "open") return;
|
|
510
|
+
if (conn.paused) {
|
|
511
|
+
conn.meta.__queued?.push(payload) ?? (conn.meta.__queued = [payload]);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
conn.send(payload);
|
|
515
|
+
};
|
|
516
|
+
if (this.latencyMs > 0) this.clock.after(this.latencyMs, doSend, `ws latency ${conn.id}`);
|
|
517
|
+
else doSend();
|
|
518
|
+
}
|
|
519
|
+
deliverSse(conn, event, eventName) {
|
|
520
|
+
const doSend = () => conn.send(event.data, { event: eventName(event), id: event.eventID });
|
|
521
|
+
if (this.latencyMs > 0) this.clock.after(this.latencyMs, doSend, `sse latency ${conn.id}`);
|
|
522
|
+
else doSend();
|
|
523
|
+
}
|
|
524
|
+
/** Called by a transport adapter once the WebSocket handshake completed. */
|
|
525
|
+
openSocket(route2, ctx, transport, protocol) {
|
|
526
|
+
const id = `ws_${this.nextId++}`;
|
|
527
|
+
const closeCbs = [];
|
|
528
|
+
const hub = this;
|
|
529
|
+
const socket = {
|
|
530
|
+
id,
|
|
531
|
+
kind: "ws",
|
|
532
|
+
path: route2.path,
|
|
533
|
+
url: ctx.url,
|
|
534
|
+
openedAt: this.clock.now(),
|
|
535
|
+
sent: 0,
|
|
536
|
+
received: 0,
|
|
537
|
+
paused: false,
|
|
538
|
+
subscriptions: /* @__PURE__ */ new Map(),
|
|
539
|
+
meta: {},
|
|
540
|
+
protocol,
|
|
541
|
+
readyState: "open",
|
|
542
|
+
send(data) {
|
|
543
|
+
if (socket.readyState !== "open") return;
|
|
544
|
+
socket.sent++;
|
|
545
|
+
transport.send(typeof data === "string" || data instanceof ArrayBuffer ? data : JSON.stringify(data));
|
|
546
|
+
},
|
|
547
|
+
close(code = 1e3, reason = "") {
|
|
548
|
+
if (socket.readyState !== "open") return;
|
|
549
|
+
socket.readyState = "closed";
|
|
550
|
+
transport.close(code, reason);
|
|
551
|
+
hub.finish(socket, code, reason, closeCbs);
|
|
552
|
+
},
|
|
553
|
+
drop() {
|
|
554
|
+
if (socket.readyState !== "open") return;
|
|
555
|
+
socket.readyState = "closed";
|
|
556
|
+
transport.drop();
|
|
557
|
+
hub.finish(socket, 1006, "dropped", closeCbs);
|
|
558
|
+
},
|
|
559
|
+
subscribe(topic, opts = {}) {
|
|
560
|
+
const serialize = opts.serialize ?? defaultSerializer;
|
|
561
|
+
socket.subscriptions.set(topic, { topic, serialize });
|
|
562
|
+
const { events, missed } = hub.topics.replay(topic, opts.since);
|
|
563
|
+
for (const e of events) hub.deliver(socket, serialize(e));
|
|
564
|
+
return { replayed: events.length, missed, last: hub.topics.last(topic) };
|
|
565
|
+
},
|
|
566
|
+
unsubscribe(topic) {
|
|
567
|
+
socket.subscriptions.delete(topic);
|
|
568
|
+
},
|
|
569
|
+
onClose(cb) {
|
|
570
|
+
closeCbs.push(cb);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
this.connections.set(id, socket);
|
|
574
|
+
this.log(`ws open ${id} ${route2.path}`, { protocol });
|
|
575
|
+
void Promise.resolve(route2.onOpen?.(ctx, socket)).catch((err) => this.log(`ws onOpen threw ${id}`, String(err)));
|
|
576
|
+
return socket;
|
|
577
|
+
}
|
|
578
|
+
/** Transport → hub: a client frame arrived. */
|
|
579
|
+
receive(route2, ctx, socket, data) {
|
|
580
|
+
if (socket.readyState !== "open") return;
|
|
581
|
+
socket.received++;
|
|
582
|
+
void Promise.resolve(route2.onMessage?.(ctx, socket, data)).catch((err) => this.log(`ws onMessage threw ${socket.id}`, String(err)));
|
|
583
|
+
}
|
|
584
|
+
/** Transport → hub: the client closed. */
|
|
585
|
+
clientClosed(route2, ctx, socket, code, reason) {
|
|
586
|
+
if (socket.readyState !== "open") return;
|
|
587
|
+
socket.readyState = "closed";
|
|
588
|
+
this.connections.delete(socket.id);
|
|
589
|
+
this.log(`ws closed by client ${socket.id}`, { code, reason });
|
|
590
|
+
route2.onClose?.(ctx, socket, code, reason);
|
|
591
|
+
}
|
|
592
|
+
finish(conn, code, reason, cbs) {
|
|
593
|
+
this.connections.delete(conn.id);
|
|
594
|
+
this.log(`ws closed ${conn.id}`, { code, reason });
|
|
595
|
+
for (const cb of cbs) cb(code, reason);
|
|
596
|
+
}
|
|
597
|
+
/** Build the SSE Response for a route; the adapter just returns it. */
|
|
598
|
+
openSse(route2, ctx) {
|
|
599
|
+
const id = `sse_${this.nextId++}`;
|
|
600
|
+
const encoder = new TextEncoder();
|
|
601
|
+
let controller = null;
|
|
602
|
+
let closed = false;
|
|
603
|
+
const closeCbs = [];
|
|
604
|
+
const hub = this;
|
|
605
|
+
const lastEventId = ctx.request.headers.get("last-event-id") ?? ctx.query.get("lastEventId") ?? ctx.query.get("sinceEventID");
|
|
606
|
+
let keepalive = null;
|
|
607
|
+
const teardown = () => {
|
|
608
|
+
if (closed) return;
|
|
609
|
+
closed = true;
|
|
610
|
+
keepalive?.cancel();
|
|
611
|
+
hub.connections.delete(id);
|
|
612
|
+
hub.log(`sse closed ${id}`);
|
|
613
|
+
for (const cb of closeCbs) cb();
|
|
614
|
+
};
|
|
615
|
+
const write = (chunk) => {
|
|
616
|
+
if (closed || !controller) return;
|
|
617
|
+
try {
|
|
618
|
+
controller.enqueue(encoder.encode(chunk));
|
|
619
|
+
} catch {
|
|
620
|
+
teardown();
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const body = new ReadableStream({
|
|
624
|
+
start(c) {
|
|
625
|
+
controller = c;
|
|
626
|
+
},
|
|
627
|
+
cancel() {
|
|
628
|
+
teardown();
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
const stream = {
|
|
632
|
+
id,
|
|
633
|
+
kind: "sse",
|
|
634
|
+
path: route2.path,
|
|
635
|
+
url: ctx.url,
|
|
636
|
+
openedAt: this.clock.now(),
|
|
637
|
+
sent: 0,
|
|
638
|
+
received: 0,
|
|
639
|
+
paused: false,
|
|
640
|
+
subscriptions: /* @__PURE__ */ new Map(),
|
|
641
|
+
meta: {},
|
|
642
|
+
lastEventId,
|
|
643
|
+
send(data, opts = {}) {
|
|
644
|
+
if (closed) return;
|
|
645
|
+
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
646
|
+
const frame = [
|
|
647
|
+
opts.event ? `event: ${opts.event}` : null,
|
|
648
|
+
opts.id !== void 0 ? `id: ${opts.id}` : null,
|
|
649
|
+
opts.retry !== void 0 ? `retry: ${opts.retry}` : null,
|
|
650
|
+
...payload.split("\n").map((line) => `data: ${line}`)
|
|
651
|
+
].filter((l) => l !== null).join("\n");
|
|
652
|
+
if (stream.paused) {
|
|
653
|
+
stream.meta.__queued?.push(frame + "\n\n") ?? (stream.meta.__queued = [frame + "\n\n"]);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
stream.sent++;
|
|
657
|
+
write(frame + "\n\n");
|
|
658
|
+
},
|
|
659
|
+
comment(text2) {
|
|
660
|
+
write(`: ${text2}
|
|
661
|
+
|
|
662
|
+
`);
|
|
663
|
+
},
|
|
664
|
+
close() {
|
|
665
|
+
if (closed) return;
|
|
666
|
+
try {
|
|
667
|
+
controller?.close();
|
|
668
|
+
} catch {
|
|
669
|
+
}
|
|
670
|
+
teardown();
|
|
671
|
+
},
|
|
672
|
+
drop() {
|
|
673
|
+
if (closed) return;
|
|
674
|
+
try {
|
|
675
|
+
controller?.error(new Error("connection dropped"));
|
|
676
|
+
} catch {
|
|
677
|
+
}
|
|
678
|
+
teardown();
|
|
679
|
+
},
|
|
680
|
+
subscribe(topic, opts = {}) {
|
|
681
|
+
const eventName = opts.event ?? ((e) => String(e.data.type ?? "message"));
|
|
682
|
+
stream.subscriptions.set(topic, { topic, serialize: eventName });
|
|
683
|
+
const { events, missed } = hub.topics.replay(topic, opts.since ?? lastEventId);
|
|
684
|
+
for (const e of events) stream.send(e.data, { event: eventName(e), id: e.eventID });
|
|
685
|
+
return { replayed: events.length, missed, last: hub.topics.last(topic) };
|
|
686
|
+
},
|
|
687
|
+
unsubscribe(topic) {
|
|
688
|
+
stream.subscriptions.delete(topic);
|
|
689
|
+
},
|
|
690
|
+
onClose(cb) {
|
|
691
|
+
closeCbs.push(cb);
|
|
692
|
+
},
|
|
693
|
+
response: new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" } })
|
|
694
|
+
};
|
|
695
|
+
this.connections.set(id, stream);
|
|
696
|
+
this.log(`sse open ${id} ${route2.path}`, { lastEventId });
|
|
697
|
+
write(`: scenario-sim ${id}
|
|
698
|
+
|
|
699
|
+
`);
|
|
700
|
+
const ka = route2.keepaliveMs ?? 15e3;
|
|
701
|
+
if (ka > 0) {
|
|
702
|
+
const tick = () => {
|
|
703
|
+
if (closed) return;
|
|
704
|
+
write(":keepalive\n\n");
|
|
705
|
+
keepalive = hub.clock.after(ka, tick, `sse keepalive ${id}`);
|
|
706
|
+
};
|
|
707
|
+
keepalive = hub.clock.after(ka, tick, `sse keepalive ${id}`);
|
|
708
|
+
}
|
|
709
|
+
void Promise.resolve(route2.onOpen(ctx, stream)).catch((err) => hub.log(`sse onOpen threw ${id}`, String(err)));
|
|
710
|
+
return stream;
|
|
711
|
+
}
|
|
712
|
+
// ── control plane ────────────────────────────────────────────────
|
|
713
|
+
list() {
|
|
714
|
+
return [...this.connections.values()].map((c) => ({
|
|
715
|
+
id: c.id,
|
|
716
|
+
kind: c.kind,
|
|
717
|
+
path: c.path,
|
|
718
|
+
openedAt: c.openedAt,
|
|
719
|
+
sent: c.sent,
|
|
720
|
+
received: c.received,
|
|
721
|
+
paused: c.paused,
|
|
722
|
+
topics: [...c.subscriptions.keys()],
|
|
723
|
+
...c.kind === "ws" ? { protocol: c.protocol } : {},
|
|
724
|
+
meta: Object.fromEntries(Object.entries(c.meta).filter(([k]) => !k.startsWith("__")))
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
get(id) {
|
|
728
|
+
return this.connections.get(id) ?? null;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Disconnect connections: by id, by topic, by path, or all. `drop` cuts the
|
|
732
|
+
* connection without a close frame (what a network blip looks like).
|
|
733
|
+
*/
|
|
734
|
+
disconnect(target, opts = {}) {
|
|
735
|
+
const hit = [];
|
|
736
|
+
for (const c of [...this.connections.values()]) {
|
|
737
|
+
const matches = target.all || target.id && c.id === target.id || target.topic && c.subscriptions.has(target.topic) || target.path && c.path === target.path;
|
|
738
|
+
if (!matches) continue;
|
|
739
|
+
hit.push(c.id);
|
|
740
|
+
if (opts.drop) c.drop();
|
|
741
|
+
else if (c.kind === "ws") c.close(opts.code ?? 1001, opts.reason ?? "disconnected by scenario control");
|
|
742
|
+
else c.close();
|
|
743
|
+
}
|
|
744
|
+
return hit;
|
|
745
|
+
}
|
|
746
|
+
/** Pause delivery on a connection (messages queue); resume flushes them in order. */
|
|
747
|
+
pause(id, paused) {
|
|
748
|
+
const c = this.connections.get(id);
|
|
749
|
+
if (!c) return false;
|
|
750
|
+
c.paused = paused;
|
|
751
|
+
if (!paused) {
|
|
752
|
+
const queued = c.meta.__queued ?? [];
|
|
753
|
+
c.meta.__queued = [];
|
|
754
|
+
for (const payload of queued) {
|
|
755
|
+
if (c.kind === "ws") c.send(payload);
|
|
756
|
+
else {
|
|
757
|
+
c.sent++;
|
|
758
|
+
c.__raw?.(payload);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
closeAll() {
|
|
765
|
+
for (const c of [...this.connections.values()]) c.close();
|
|
766
|
+
this.connections.clear();
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// src/core/faults.ts
|
|
771
|
+
var FaultLayer = class {
|
|
772
|
+
constructor(rng) {
|
|
773
|
+
this.rng = rng;
|
|
774
|
+
}
|
|
775
|
+
rng;
|
|
776
|
+
latencyMs = 0;
|
|
777
|
+
jitterMs = 0;
|
|
778
|
+
failMode = "off";
|
|
779
|
+
/** Paths the `data` fail mode leaves alone so the app shell still boots. */
|
|
780
|
+
shellPaths = /* @__PURE__ */ new Set();
|
|
781
|
+
overrides = [];
|
|
782
|
+
nextId = 1;
|
|
783
|
+
configure(patch) {
|
|
784
|
+
if (patch.latencyMs !== void 0) this.latencyMs = Math.max(0, patch.latencyMs);
|
|
785
|
+
if (patch.jitterMs !== void 0) this.jitterMs = Math.max(0, patch.jitterMs);
|
|
786
|
+
if (patch.failMode !== void 0) this.failMode = patch.failMode;
|
|
787
|
+
if (patch.shellPaths !== void 0) this.shellPaths = new Set(patch.shellPaths);
|
|
788
|
+
}
|
|
789
|
+
/** Effective delay for one request (seeded jitter keeps it reproducible). */
|
|
790
|
+
delayFor(extra = 0) {
|
|
791
|
+
const jitter = this.jitterMs > 0 ? this.rng.int(0, this.jitterMs) : 0;
|
|
792
|
+
return this.latencyMs + jitter + extra;
|
|
793
|
+
}
|
|
794
|
+
setOverride(input) {
|
|
795
|
+
const key = matcherKey(input.matcher, input.method);
|
|
796
|
+
this.overrides = this.overrides.filter((o2) => matcherKey(o2.matcher, o2.method) !== key);
|
|
797
|
+
const o = { ...input, id: `ov_${this.nextId++}`, remaining: input.times ?? Infinity, hits: 0 };
|
|
798
|
+
this.overrides.unshift(o);
|
|
799
|
+
return o;
|
|
800
|
+
}
|
|
801
|
+
clearOverride(target, method) {
|
|
802
|
+
const before = this.overrides.length;
|
|
803
|
+
this.overrides = this.overrides.filter((o) => o.id !== target && matcherKey(o.matcher, o.method) !== matcherKey(target, method));
|
|
804
|
+
return this.overrides.length !== before;
|
|
805
|
+
}
|
|
806
|
+
clearOverrides() {
|
|
807
|
+
this.overrides = [];
|
|
808
|
+
}
|
|
809
|
+
listOverrides() {
|
|
810
|
+
return this.overrides.map((o) => ({ ...o }));
|
|
811
|
+
}
|
|
812
|
+
/** Find (and consume) the first matching override. */
|
|
813
|
+
matchOverride(path, method) {
|
|
814
|
+
for (const o of this.overrides) {
|
|
815
|
+
if (o.method && o.method.toUpperCase() !== method.toUpperCase()) continue;
|
|
816
|
+
const hit = typeof o.matcher === "string" ? path.includes(o.matcher) : new RegExp(o.matcher.regex, o.matcher.flags).test(path);
|
|
817
|
+
if (!hit) continue;
|
|
818
|
+
o.hits++;
|
|
819
|
+
o.remaining--;
|
|
820
|
+
if (o.remaining <= 0) this.overrides = this.overrides.filter((x) => x !== o);
|
|
821
|
+
return o;
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
/** Build the response an override dictates. */
|
|
826
|
+
overrideResponse(o) {
|
|
827
|
+
if (o.malformed) return malformed(o.malformed, o.body);
|
|
828
|
+
const status = o.status ?? 500;
|
|
829
|
+
if (status === 204 || o.body === void 0 && status >= 200 && status < 300) return new Response(null, { status });
|
|
830
|
+
if (o.body === void 0) return problem(status, `forced by override ${o.id}`);
|
|
831
|
+
return json(o.body, { status });
|
|
832
|
+
}
|
|
833
|
+
/** Fail-mode response, or null when the request should proceed. */
|
|
834
|
+
failResponse(path) {
|
|
835
|
+
if (this.failMode === "off") return null;
|
|
836
|
+
if (this.failMode === "data" && this.shellPaths.has(path)) return null;
|
|
837
|
+
return problem(503, `scenario fail mode "${this.failMode}"`);
|
|
838
|
+
}
|
|
839
|
+
snapshot() {
|
|
840
|
+
return { latencyMs: this.latencyMs, jitterMs: this.jitterMs, failMode: this.failMode, shellPaths: [...this.shellPaths], overrides: this.listOverrides() };
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
function matcherKey(matcher, method) {
|
|
844
|
+
const m = typeof matcher === "string" ? `str:${matcher}` : `re:${matcher.regex}:${matcher.flags ?? ""}`;
|
|
845
|
+
return `${(method ?? "*").toUpperCase()} ${m}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/core/scenario.ts
|
|
849
|
+
function defineScenario(def) {
|
|
850
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(def.name)) throw new Error(`scenario name must be URL-safe: ${def.name}`);
|
|
851
|
+
return def;
|
|
852
|
+
}
|
|
853
|
+
var Run = class {
|
|
854
|
+
constructor(id, scenario, seed, sink) {
|
|
855
|
+
this.id = id;
|
|
856
|
+
this.scenario = scenario;
|
|
857
|
+
this.seed = seed;
|
|
858
|
+
this.sink = sink;
|
|
859
|
+
this.rng = new Rng(seed);
|
|
860
|
+
this.clock = new VirtualClock(scenario.clock?.mode ?? "realtime", scenario.clock?.speed ?? 1);
|
|
861
|
+
this.state = new Store(() => this.clock.now());
|
|
862
|
+
this.streams = new StreamHub(this.clock, (m, d) => this.log(m, d));
|
|
863
|
+
this.faults = new FaultLayer(this.rng.fork("faults"));
|
|
864
|
+
if (scenario.faults) this.faults.configure(scenario.faults);
|
|
865
|
+
this.routes = (scenario.routes ?? []).map(compileRoute);
|
|
866
|
+
this.wsRoutes = (scenario.streams ?? []).filter((s) => s.kind === "ws");
|
|
867
|
+
this.sseRoutes = (scenario.streams ?? []).filter((s) => s.kind === "sse");
|
|
868
|
+
this.ready = Promise.resolve(scenario.setup?.(this.setupContext())).then(() => this.log(`run ready (${scenario.name}, seed ${seed})`));
|
|
869
|
+
}
|
|
870
|
+
id;
|
|
871
|
+
scenario;
|
|
872
|
+
seed;
|
|
873
|
+
sink;
|
|
874
|
+
rng;
|
|
875
|
+
clock;
|
|
876
|
+
state;
|
|
877
|
+
streams;
|
|
878
|
+
faults;
|
|
879
|
+
routes;
|
|
880
|
+
wsRoutes;
|
|
881
|
+
sseRoutes;
|
|
882
|
+
createdWall = Date.now();
|
|
883
|
+
requests = 0;
|
|
884
|
+
logEntries = [];
|
|
885
|
+
logSeq = 0;
|
|
886
|
+
ready;
|
|
887
|
+
info() {
|
|
888
|
+
return { id: this.id, scenario: this.scenario.name, seed: this.seed };
|
|
889
|
+
}
|
|
890
|
+
setupContext() {
|
|
891
|
+
return { state: this.state, rng: this.rng, clock: this.clock, streams: this.streams, run: this.info(), log: (m, d) => this.log(m, d) };
|
|
892
|
+
}
|
|
893
|
+
log(message, data) {
|
|
894
|
+
const entry = { seq: ++this.logSeq, t: Math.round(this.clock.now()), message, ...data !== void 0 ? { data } : {} };
|
|
895
|
+
this.logEntries.push(entry);
|
|
896
|
+
if (this.logEntries.length > 2e3) this.logEntries.splice(0, this.logEntries.length - 2e3);
|
|
897
|
+
this.sink?.(`[sim ${this.id}] ${message}`);
|
|
898
|
+
}
|
|
899
|
+
logs(opts = {}) {
|
|
900
|
+
const out = this.logEntries.filter((e) => e.seq > (opts.since ?? 0));
|
|
901
|
+
const limit = opts.limit ?? 200;
|
|
902
|
+
return out.length > limit ? out.slice(out.length - limit) : out;
|
|
903
|
+
}
|
|
904
|
+
async action(name, args = {}) {
|
|
905
|
+
const fn = this.scenario.actions?.[name];
|
|
906
|
+
if (!fn) throw new Error(`unknown action "${name}" (available: ${Object.keys(this.scenario.actions ?? {}).join(", ") || "none"})`);
|
|
907
|
+
this.log(`action ${name}`, args);
|
|
908
|
+
return fn({ ...this.setupContext(), args });
|
|
909
|
+
}
|
|
910
|
+
status() {
|
|
911
|
+
return {
|
|
912
|
+
run: this.id,
|
|
913
|
+
scenario: this.scenario.name,
|
|
914
|
+
label: this.scenario.label ?? null,
|
|
915
|
+
seed: this.seed,
|
|
916
|
+
createdWall: this.createdWall,
|
|
917
|
+
requests: this.requests,
|
|
918
|
+
clock: { mode: this.clock.mode, speed: this.clock.speed, now: Math.round(this.clock.now()), wall: this.clock.iso(), pending: this.clock.pending() },
|
|
919
|
+
state: { collections: this.state.counts(), lastSeq: this.state.lastSeq },
|
|
920
|
+
streams: this.streams.list(),
|
|
921
|
+
topics: this.streams.topics.topics().map((t) => ({ topic: t, last: this.streams.topics.last(t) })),
|
|
922
|
+
faults: this.faults.snapshot(),
|
|
923
|
+
routes: this.routes.map((r) => ({ method: r.method, path: r.path, calls: r.calls, name: r.name ?? null })),
|
|
924
|
+
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" }))],
|
|
925
|
+
actions: Object.keys(this.scenario.actions ?? {})
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
dispose() {
|
|
929
|
+
this.streams.closeAll();
|
|
930
|
+
this.clock.clear();
|
|
931
|
+
this.log("run disposed");
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
export {
|
|
936
|
+
Rng,
|
|
937
|
+
VIRTUAL_EPOCH,
|
|
938
|
+
VirtualClock,
|
|
939
|
+
Store,
|
|
940
|
+
json,
|
|
941
|
+
text,
|
|
942
|
+
empty,
|
|
943
|
+
problem,
|
|
944
|
+
malformed,
|
|
945
|
+
sequence,
|
|
946
|
+
delayed,
|
|
947
|
+
compileRoute,
|
|
948
|
+
statusText,
|
|
949
|
+
route,
|
|
950
|
+
crud,
|
|
951
|
+
TopicLog,
|
|
952
|
+
ws,
|
|
953
|
+
sse,
|
|
954
|
+
StreamHub,
|
|
955
|
+
FaultLayer,
|
|
956
|
+
defineScenario,
|
|
957
|
+
Run
|
|
958
|
+
};
|
|
959
|
+
//# sourceMappingURL=chunk-44ZHRYG6.js.map
|