@crvouga/mockingbird-service-flex 1.7.1 → 1.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog — @crvouga/mockingbird-service-flex
2
2
 
3
+ ## 1.8.0 (2026-10-02)
4
+
5
+ ### Features
6
+
7
+ - coordinate HTTP and protocol mocks ([3168653](https://github.com/crvouga/mockingbird/commit/31686534c55a9823fdaa92afc9cc456cf741b809))
8
+
9
+ ### Fixes and improvements
10
+
11
+ - decouple smoke types from built packages ([a4b31c6](https://github.com/crvouga/mockingbird/commit/a4b31c6205d92aa0c6f7c9de3518d5ae44d38829))
12
+ - protect child admin endpoints ([5410b94](https://github.com/crvouga/mockingbird/commit/5410b948a30dcedc21e9a70e44fd415c1ea25394))
13
+ - harden protocol startup and state ([6f5b557](https://github.com/crvouga/mockingbird/commit/6f5b557e87b48b31985949e04f3b5ef58f51d295))
14
+ - preserve checkpoints and lifecycle ([50e8c32](https://github.com/crvouga/mockingbird/commit/50e8c322feded2f7f45afce9d8cdef43c37ab9ab))
15
+ - clean branch state on reset ([3b714d4](https://github.com/crvouga/mockingbird/commit/3b714d4a5df8e75fee0bb31e98d7c7566e164381))
16
+ - type signal listener cleanup ([5279852](https://github.com/crvouga/mockingbird/commit/5279852e56ea60262ccf17aa6e46b3c31091a34a))
17
+
3
18
  ## 1.7.1 (2026-10-01)
4
19
 
5
20
  ### Fixes and improvements
@@ -183,29 +183,30 @@ var createControlPlane = (context) => {
183
183
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
184
  },
185
185
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
186
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
187
+ "POST /clock": ({ body, namespace }) => {
188
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
189
  if (!isRecord(body))
189
190
  return adminError(400, "expected a JSON object");
190
191
  if (body.reset === true)
191
- context.clock.reset();
192
+ clock.reset();
192
193
  if (body.freeze === true)
193
- context.clock.freeze();
194
+ clock.freeze();
194
195
  if (body.set !== void 0) {
195
196
  const instant = parseInstant(body.set);
196
197
  if (instant === void 0)
197
198
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
199
+ clock.set(instant);
199
200
  }
200
201
  if (body.advance !== void 0) {
201
202
  const delta = parseDuration(body.advance);
202
203
  if (delta === void 0)
203
204
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
205
+ clock.advance(delta);
205
206
  }
206
207
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
208
+ clock.unfreeze();
209
+ return json(200, clock.state());
209
210
  },
210
211
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
212
  "POST /faults": ({ body, namespace }) => {
@@ -474,7 +475,33 @@ var faultResponse = (rule) => {
474
475
  };
475
476
  var createFaultRegistry = (rng = createRng(0), sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
476
477
  const entries = [];
478
+ let isolated = false;
479
+ const scopedRngs = /* @__PURE__ */ new Map();
480
+ const random = (namespace) => {
481
+ if (!isolated || namespace === "default")
482
+ return rng;
483
+ let scoped = scopedRngs.get(namespace);
484
+ if (!scoped) {
485
+ scoped = createRng(rng.seed);
486
+ scopedRngs.set(namespace, scoped);
487
+ }
488
+ return scoped;
489
+ };
477
490
  return {
491
+ isolateNamespaces() {
492
+ isolated = true;
493
+ },
494
+ snapshot(namespace) {
495
+ return {
496
+ rules: structuredClone(entries.map((entry, position) => ({
497
+ ...entry.rule,
498
+ remaining: entry.remaining,
499
+ hits: entry.hits,
500
+ position
501
+ })).filter((entry) => entry.namespace === namespace)),
502
+ rngState: random(namespace).state()
503
+ };
504
+ },
478
505
  add(rule) {
479
506
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
480
507
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -494,6 +521,16 @@ var createFaultRegistry = (rng = createRng(0), sleep2 = (ms) => new Promise((res
494
521
  },
495
522
  clear() {
496
523
  entries.length = 0;
524
+ rng.reset();
525
+ scopedRngs.clear();
526
+ },
527
+ restore(namespace, state) {
528
+ for (let i = entries.length - 1; i >= 0; i--)
529
+ if (entries[i]?.rule.namespace === namespace)
530
+ entries.splice(i, 1);
531
+ for (const { remaining, hits, position, ...rule } of state.rules)
532
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
533
+ random(namespace).setState(state.rngState);
497
534
  },
498
535
  async take(candidate) {
499
536
  const hits = [];
@@ -503,7 +540,7 @@ var createFaultRegistry = (rng = createRng(0), sleep2 = (ms) => new Promise((res
503
540
  if (!matches(entry.rule, candidate))
504
541
  continue;
505
542
  const rate = entry.rule.rate ?? 1;
506
- if (rng.next() >= rate)
543
+ if (random(candidate.namespace).next() >= rate)
507
544
  continue;
508
545
  entry.hits++;
509
546
  if (entry.remaining !== null)
@@ -1131,7 +1168,8 @@ var annotateResponse = (response, extra) => {
1131
1168
  var responseNotes = (response) => notes.get(response);
1132
1169
 
1133
1170
  // ../core/dist/metrics.js
1134
- var createMetrics = () => {
1171
+ var createMetrics = (partition = true) => {
1172
+ const namespaces = /* @__PURE__ */ new Map();
1135
1173
  let requests = 0;
1136
1174
  let faults = 0;
1137
1175
  let totalDurationMs = 0;
@@ -1139,6 +1177,14 @@ var createMetrics = () => {
1139
1177
  const unmatched = /* @__PURE__ */ new Map();
1140
1178
  return {
1141
1179
  record(entry) {
1180
+ if (partition) {
1181
+ let scoped = namespaces.get(entry.namespace);
1182
+ if (!scoped) {
1183
+ scoped = createMetrics(false);
1184
+ namespaces.set(entry.namespace, scoped);
1185
+ }
1186
+ scoped.record(entry);
1187
+ }
1142
1188
  requests++;
1143
1189
  totalDurationMs += entry.durationMs;
1144
1190
  if (entry.faultId !== void 0)
@@ -1150,7 +1196,7 @@ var createMetrics = () => {
1150
1196
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
1151
1197
  }
1152
1198
  },
1153
- report: () => ({
1199
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
1154
1200
  requests,
1155
1201
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
1156
1202
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -1159,8 +1205,13 @@ var createMetrics = () => {
1159
1205
  }),
1160
1206
  faults,
1161
1207
  totalDurationMs
1162
- }),
1163
- reset() {
1208
+ },
1209
+ reset(namespace) {
1210
+ if (namespace !== void 0) {
1211
+ namespaces.delete(namespace);
1212
+ return;
1213
+ }
1214
+ namespaces.clear();
1164
1215
  requests = 0;
1165
1216
  faults = 0;
1166
1217
  totalDurationMs = 0;
@@ -4132,7 +4183,7 @@ var stateAdminRoutes = (routes) => {
4132
4183
  };
4133
4184
 
4134
4185
  // ../core/dist/version.js
4135
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4186
+ var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4136
4187
 
4137
4188
  // ../core/dist/signing.js
4138
4189
  var encoder = new TextEncoder();
@@ -4225,7 +4276,9 @@ var createWebhookHub = (options) => {
4225
4276
  const faults = /* @__PURE__ */ new Map();
4226
4277
  const held = /* @__PURE__ */ new Map();
4227
4278
  const inFlight2 = /* @__PURE__ */ new Set();
4279
+ const controllers = /* @__PURE__ */ new Map();
4228
4280
  let immediate = Promise.resolve();
4281
+ const snapshots = /* @__PURE__ */ new WeakMap();
4229
4282
  const track = (work) => {
4230
4283
  inFlight2.add(work);
4231
4284
  void work.finally(() => inFlight2.delete(work));
@@ -4246,6 +4299,7 @@ var createWebhookHub = (options) => {
4246
4299
  responseBody: null
4247
4300
  };
4248
4301
  const controller = new AbortController();
4302
+ controllers.set(delivery.id, controller);
4249
4303
  const timer = scheduleTimer(() => controller.abort(), timeoutMs);
4250
4304
  try {
4251
4305
  const signed = await options.signer({
@@ -4275,6 +4329,7 @@ var createWebhookHub = (options) => {
4275
4329
  } catch (error2) {
4276
4330
  record.error = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error2 instanceof Error ? error2.message : String(error2);
4277
4331
  } finally {
4332
+ controllers.delete(delivery.id);
4278
4333
  cancel(timer);
4279
4334
  record.durationMs = now() - started;
4280
4335
  delivery.attempts.push(record);
@@ -4292,6 +4347,8 @@ var createWebhookHub = (options) => {
4292
4347
  if (!payloads.has(delivery.id))
4293
4348
  return;
4294
4349
  await attempt(delivery).then((ok) => {
4350
+ if (!payloads.has(delivery.id))
4351
+ return;
4295
4352
  if (ok)
4296
4353
  delivery.state = "delivered";
4297
4354
  else
@@ -4353,6 +4410,37 @@ var createWebhookHub = (options) => {
4353
4410
  fanOut(message);
4354
4411
  };
4355
4412
  const hub = {
4413
+ pending(namespace) {
4414
+ return inFlight2.size + (held.get(namespace)?.length ?? 0) + [...deliveries.values()].filter((d) => d.namespace === namespace && d.state === "pending").length;
4415
+ },
4416
+ snapshot(namespace) {
4417
+ if (hub.pending(namespace) > 0)
4418
+ throw new Error("webhooks are not quiescent");
4419
+ const handle = {};
4420
+ snapshots.set(handle, structuredClone({
4421
+ namespace,
4422
+ endpoints: own.get(namespace) ?? [],
4423
+ messages: messages.filter((m) => m.namespace === namespace),
4424
+ deliveries: [...deliveries].filter(([, d]) => d.namespace === namespace),
4425
+ payloads: [...payloads].filter(([, p]) => p.message.namespace === namespace),
4426
+ faults: faults.get(namespace) ?? []
4427
+ }));
4428
+ return handle;
4429
+ },
4430
+ restore(namespace, handle) {
4431
+ const point = typeof handle === "object" && handle !== null ? snapshots.get(handle) : void 0;
4432
+ if (!point || point.namespace !== namespace || hub.pending(namespace) > 0)
4433
+ throw new Error("invalid webhook restore");
4434
+ hub.clear(namespace);
4435
+ const copied = structuredClone(point);
4436
+ own.set(namespace, copied.endpoints);
4437
+ messages.push(...copied.messages);
4438
+ for (const [id2, delivery] of copied.deliveries)
4439
+ deliveries.set(id2, delivery);
4440
+ for (const [id2, payload] of copied.payloads)
4441
+ payloads.set(id2, payload);
4442
+ faults.set(namespace, copied.faults);
4443
+ },
4356
4444
  publish(input) {
4357
4445
  const contentType = input.contentType ?? (input.form ? "application/x-www-form-urlencoded" : "application/json");
4358
4446
  const body = typeof input.body === "string" ? input.body : input.form ? new URLSearchParams(input.form).toString() : JSON.stringify(input.body);
@@ -4443,6 +4531,7 @@ var createWebhookHub = (options) => {
4443
4531
  for (const [id2, delivery] of deliveries) {
4444
4532
  if (namespace !== void 0 && delivery.namespace !== namespace)
4445
4533
  continue;
4534
+ controllers.get(id2)?.abort();
4446
4535
  const timer = pending.get(id2);
4447
4536
  if (timer !== void 0)
4448
4537
  cancel(timer);
@@ -4630,6 +4719,20 @@ var createRuntime = (options) => {
4630
4719
  const instances = /* @__PURE__ */ new Map();
4631
4720
  const publicNamespaces = /* @__PURE__ */ new Set();
4632
4721
  const branchRngs = /* @__PURE__ */ new Map();
4722
+ const namespaceClocks = /* @__PURE__ */ new Map();
4723
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4724
+ let isolatedNamespaces = false;
4725
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4726
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4727
+ return clock;
4728
+ let found = namespaceClocks.get(name);
4729
+ if (!found) {
4730
+ found = createClock();
4731
+ namespaceClocks.set(name, found);
4732
+ }
4733
+ return found;
4734
+ };
4735
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4633
4736
  const timelines = /* @__PURE__ */ new Map();
4634
4737
  const branchStorage = /* @__PURE__ */ new Map();
4635
4738
  const captured = /* @__PURE__ */ new Map();
@@ -4643,17 +4746,21 @@ var createRuntime = (options) => {
4643
4746
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4644
4747
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4645
4748
  }
4749
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4646
4750
  const created = options.create({
4647
4751
  namespace: storageNamespace(key),
4648
4752
  publicNamespace,
4649
4753
  sqlite,
4650
- clock,
4651
- rng: isolatedRng ?? rng
4754
+ clock: namespaceClock(publicNamespace),
4755
+ rng: createdContextRng
4652
4756
  });
4653
4757
  instances.set(key, created);
4758
+ namespaceForStorage.set(key, publicNamespace);
4654
4759
  publicNamespaces.add(publicNamespace);
4655
4760
  if (isolatedRng)
4656
4761
  branchRngs.set(key, isolatedRng);
4762
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4763
+ branchRngs.set(key, createdContextRng);
4657
4764
  return created;
4658
4765
  };
4659
4766
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4671,7 +4778,7 @@ var createRuntime = (options) => {
4671
4778
  captured.set(storage, snapshot2);
4672
4779
  return Object.freeze({
4673
4780
  snapshot: snapshot2,
4674
- clock: Object.freeze(clock.state()),
4781
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4675
4782
  rngState: (branchRngs.get(storage) ?? rng).state()
4676
4783
  });
4677
4784
  };
@@ -4681,7 +4788,7 @@ var createRuntime = (options) => {
4681
4788
  return found;
4682
4789
  instance(name);
4683
4790
  found = new Timeline({
4684
- now: clock.now,
4791
+ now: namespaceClock(name).now,
4685
4792
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4686
4793
  });
4687
4794
  found.commit(capture(name));
@@ -4708,12 +4815,13 @@ var createRuntime = (options) => {
4708
4815
  const point = history2.checkout("main", at);
4709
4816
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4710
4817
  captured.set(namespace, point.value.snapshot);
4711
- rng.setState(point.value.rngState);
4712
- clock.set(point.value.clock.now);
4818
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4819
+ const selectedClock = namespaceClock(namespace);
4820
+ selectedClock.set(point.value.clock.now);
4713
4821
  if (point.value.clock.frozen)
4714
- clock.freeze();
4822
+ selectedClock.freeze();
4715
4823
  else
4716
- clock.unfreeze();
4824
+ selectedClock.unfreeze();
4717
4825
  }
4718
4826
  return namespace;
4719
4827
  }
@@ -4771,11 +4879,12 @@ var createRuntime = (options) => {
4771
4879
  const storage = ensureBranch(namespace, branchName);
4772
4880
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4773
4881
  captured.set(storage, point.value.snapshot);
4774
- clock.set(point.value.clock.now);
4882
+ const selectedClock = namespaceClock(namespace);
4883
+ selectedClock.set(point.value.clock.now);
4775
4884
  if (point.value.clock.frozen)
4776
- clock.freeze();
4885
+ selectedClock.freeze();
4777
4886
  else
4778
- clock.unfreeze();
4887
+ selectedClock.unfreeze();
4779
4888
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4780
4889
  };
4781
4890
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4783,10 +4892,21 @@ var createRuntime = (options) => {
4783
4892
  options.webhooks?.clear();
4784
4893
  for (const each of instances.values())
4785
4894
  await each.reset();
4895
+ for (const each of branchRngs.values())
4896
+ each.reset();
4786
4897
  timelines.clear();
4898
+ for (const storage of branchStorage.values()) {
4899
+ instances.delete(storage);
4900
+ branchRngs.delete(storage);
4901
+ namespaceForStorage.delete(storage);
4902
+ }
4787
4903
  branchStorage.clear();
4788
- branchRngs.clear();
4789
4904
  captured.clear();
4905
+ faults.clear();
4906
+ clock.reset();
4907
+ rng.reset();
4908
+ for (const each of namespaceClocks.values())
4909
+ each.reset();
4790
4910
  return;
4791
4911
  }
4792
4912
  options.webhooks?.clear(name);
@@ -4804,11 +4924,18 @@ var createRuntime = (options) => {
4804
4924
  else
4805
4925
  clearNamespace(sqlite, storageNamespace(storage));
4806
4926
  branchStorage.delete(mapping);
4927
+ instances.delete(storage);
4928
+ namespaceForStorage.delete(storage);
4807
4929
  branchRngs.delete(storage);
4808
4930
  captured.delete(storage);
4809
4931
  }
4810
4932
  timelines.delete(name);
4811
4933
  captured.delete(name);
4934
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4935
+ namespaceClock(name).reset();
4936
+ (branchRngs.get(name) ?? rng).reset();
4937
+ journal.clear(name);
4938
+ metrics.reset(name);
4812
4939
  };
4813
4940
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4814
4941
  return checkpoint(name, "main").value.snapshot;
@@ -4846,6 +4973,53 @@ var createRuntime = (options) => {
4846
4973
  rng,
4847
4974
  credentials,
4848
4975
  webhooks: options.webhooks,
4976
+ isolateNamespaces() {
4977
+ if (publicNamespaces.size > 1)
4978
+ throw new Error("enable namespace isolation before creating named namespaces");
4979
+ isolatedNamespaces = true;
4980
+ faults.isolateNamespaces();
4981
+ },
4982
+ namespaceClock,
4983
+ namespaceOf(request) {
4984
+ const url = new URL(request.url);
4985
+ const prefixed = PATH_PREFIX.exec(url.pathname);
4986
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4987
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4988
+ if (path === "/__admin" || path.startsWith("/__admin/"))
4989
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4990
+ if (selected !== void 0)
4991
+ return selected;
4992
+ const credential = options.credential?.(request);
4993
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4994
+ },
4995
+ fleetSnapshot(namespace) {
4996
+ instance(namespace);
4997
+ const handle = {};
4998
+ fleetPoints.set(handle, {
4999
+ namespace,
5000
+ state: capture(namespace),
5001
+ faults: faults.snapshot(namespace),
5002
+ webhooks: options.webhooks?.snapshot(namespace)
5003
+ });
5004
+ return handle;
5005
+ },
5006
+ fleetRestore(handle, namespace) {
5007
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
5008
+ if (!point || point.namespace !== namespace)
5009
+ throw new Error("invalid fleet checkpoint");
5010
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
5011
+ captured.set(namespace, point.state.snapshot);
5012
+ const selectedClock = namespaceClock(namespace);
5013
+ selectedClock.freeze();
5014
+ selectedClock.set(point.state.clock.now);
5015
+ if (!point.state.clock.frozen)
5016
+ selectedClock.unfreeze();
5017
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
5018
+ faults.restore(namespace, point.faults);
5019
+ if (point.webhooks !== void 0)
5020
+ options.webhooks?.restore(namespace, point.webhooks);
5021
+ timelines.delete(namespace);
5022
+ },
4849
5023
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4850
5024
  const preset = options.presets?.[name];
4851
5025
  if (!preset)
@@ -5021,6 +5195,7 @@ var createRuntime = (options) => {
5021
5195
  startedAt: wallNow(),
5022
5196
  wallNow,
5023
5197
  clock,
5198
+ namespaceClock,
5024
5199
  faults,
5025
5200
  metrics,
5026
5201
  journal,
@@ -7242,6 +7417,8 @@ var FlexAPI = class {
7242
7417
  var isNextActionType = (value) => typeof value === "string" && NEXT_ACTION_TYPES.includes(value);
7243
7418
 
7244
7419
  export {
7420
+ createClock,
7421
+ parseDuration,
7245
7422
  document2 as document,
7246
7423
  operationIds,
7247
7424
  supportedOperationIds,
@@ -7264,4 +7441,4 @@ export {
7264
7441
  FlexAPI,
7265
7442
  isNextActionType
7266
7443
  };
7267
- //# sourceMappingURL=chunk-QPJYRGYD.js.map
7444
+ //# sourceMappingURL=chunk-O2G3Z532.js.map