@buoy-gg/network 7.0.35 → 7.0.38

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.
Files changed (91) hide show
  1. package/README.md +45 -12
  2. package/lib/commonjs/index.js +26 -1
  3. package/lib/commonjs/index.web.js +26 -1
  4. package/lib/commonjs/network/components/NetworkEventDetailView.js +6 -1
  5. package/lib/commonjs/network/components/NetworkHeaderMenu.js +4 -0
  6. package/lib/commonjs/network/components/NetworkModal.js +24 -8
  7. package/lib/commonjs/network/conditions/NetworkConditionsControl.js +235 -0
  8. package/lib/commonjs/network/conditions/NetworkConditionsControl.test.js +136 -0
  9. package/lib/commonjs/network/conditions/NetworkConditionsIcon.js +33 -0
  10. package/lib/commonjs/network/conditions/NetworkFloatingTool.js +310 -0
  11. package/lib/commonjs/network/conditions/NetworkFloatingTool.test.js +219 -0
  12. package/lib/commonjs/network/conditions/store.js +75 -0
  13. package/lib/commonjs/network/conditions/wait.js +43 -0
  14. package/lib/commonjs/network/conditions/xhr.js +110 -0
  15. package/lib/commonjs/network/hooks/useNetworkEvents.js +1 -1
  16. package/lib/commonjs/network/overrides/applyToXhr.js +10 -8
  17. package/lib/commonjs/network/overrides/engine.js +2 -1
  18. package/lib/commonjs/network/sync/networkSyncAdapter.js +224 -3
  19. package/lib/commonjs/network/utils/generateNetworkCopyText.js +2 -0
  20. package/lib/commonjs/network/utils/networkEventStore.js +5 -77
  21. package/lib/commonjs/network/utils/networkListener.js +245 -46
  22. package/lib/commonjs/network/utils/networkSavedStore.js +2 -2
  23. package/lib/commonjs/network/utils/selectSavedEvents.js +2 -2
  24. package/lib/commonjs/preset.js +2 -1
  25. package/lib/module/index.js +2 -1
  26. package/lib/module/index.web.js +2 -1
  27. package/lib/module/network/components/NetworkEventDetailView.js +6 -1
  28. package/lib/module/network/components/NetworkHeaderMenu.js +4 -0
  29. package/lib/module/network/components/NetworkModal.js +25 -9
  30. package/lib/module/network/conditions/NetworkConditionsControl.js +231 -0
  31. package/lib/module/network/conditions/NetworkConditionsControl.test.js +135 -0
  32. package/lib/module/network/conditions/NetworkConditionsIcon.js +29 -0
  33. package/lib/module/network/conditions/NetworkFloatingTool.js +304 -0
  34. package/lib/module/network/conditions/NetworkFloatingTool.test.js +218 -0
  35. package/lib/module/network/conditions/store.js +65 -0
  36. package/lib/module/network/conditions/wait.js +38 -0
  37. package/lib/module/network/conditions/xhr.js +106 -0
  38. package/lib/module/network/hooks/useNetworkEvents.js +1 -1
  39. package/lib/module/network/overrides/applyToXhr.js +9 -8
  40. package/lib/module/network/overrides/engine.js +2 -1
  41. package/lib/module/network/sync/networkSyncAdapter.js +225 -3
  42. package/lib/module/network/utils/generateNetworkCopyText.js +2 -0
  43. package/lib/module/network/utils/networkEventStore.js +6 -77
  44. package/lib/module/network/utils/networkListener.js +246 -47
  45. package/lib/module/network/utils/networkSavedStore.js +2 -2
  46. package/lib/module/network/utils/selectSavedEvents.js +2 -2
  47. package/lib/module/preset.js +3 -2
  48. package/lib/typescript/index.d.ts +2 -0
  49. package/lib/typescript/index.d.ts.map +1 -1
  50. package/lib/typescript/index.web.d.ts +2 -0
  51. package/lib/typescript/index.web.d.ts.map +1 -1
  52. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  53. package/lib/typescript/network/components/NetworkHeaderMenu.d.ts +2 -1
  54. package/lib/typescript/network/components/NetworkHeaderMenu.d.ts.map +1 -1
  55. package/lib/typescript/network/components/NetworkModal.d.ts +3 -0
  56. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  57. package/lib/typescript/network/conditions/NetworkConditionsControl.d.ts +12 -0
  58. package/lib/typescript/network/conditions/NetworkConditionsControl.d.ts.map +1 -0
  59. package/lib/typescript/network/conditions/NetworkConditionsControl.test.d.ts +2 -0
  60. package/lib/typescript/network/conditions/NetworkConditionsControl.test.d.ts.map +1 -0
  61. package/lib/typescript/network/conditions/NetworkConditionsIcon.d.ts +4 -0
  62. package/lib/typescript/network/conditions/NetworkConditionsIcon.d.ts.map +1 -0
  63. package/lib/typescript/network/conditions/NetworkFloatingTool.d.ts +15 -0
  64. package/lib/typescript/network/conditions/NetworkFloatingTool.d.ts.map +1 -0
  65. package/lib/typescript/network/conditions/NetworkFloatingTool.test.d.ts +2 -0
  66. package/lib/typescript/network/conditions/NetworkFloatingTool.test.d.ts.map +1 -0
  67. package/lib/typescript/network/conditions/store.d.ts +30 -0
  68. package/lib/typescript/network/conditions/store.d.ts.map +1 -0
  69. package/lib/typescript/network/conditions/wait.d.ts +4 -0
  70. package/lib/typescript/network/conditions/wait.d.ts.map +1 -0
  71. package/lib/typescript/network/conditions/xhr.d.ts +5 -0
  72. package/lib/typescript/network/conditions/xhr.d.ts.map +1 -0
  73. package/lib/typescript/network/hooks/useNetworkEvents.d.ts +1 -1
  74. package/lib/typescript/network/overrides/applyToXhr.d.ts +2 -0
  75. package/lib/typescript/network/overrides/applyToXhr.d.ts.map +1 -1
  76. package/lib/typescript/network/overrides/engine.d.ts +0 -10
  77. package/lib/typescript/network/overrides/engine.d.ts.map +1 -1
  78. package/lib/typescript/network/sync/networkSyncAdapter.d.ts +29 -0
  79. package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
  80. package/lib/typescript/network/types/index.d.ts +1 -0
  81. package/lib/typescript/network/types/index.d.ts.map +1 -1
  82. package/lib/typescript/network/utils/generateNetworkCopyText.d.ts.map +1 -1
  83. package/lib/typescript/network/utils/networkEventStore.d.ts +0 -15
  84. package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
  85. package/lib/typescript/network/utils/networkListener.d.ts +13 -28
  86. package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
  87. package/lib/typescript/network/utils/networkSavedStore.d.ts +2 -2
  88. package/lib/typescript/network/utils/selectSavedEvents.d.ts +2 -2
  89. package/lib/typescript/preset.d.ts +0 -16
  90. package/lib/typescript/preset.d.ts.map +1 -1
  91. package/package.json +2 -2
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ import { canSynthesize, completeNativeOverride } from "../overrides/applyToXhr";
4
+ /** Active simulation uses a send-to-completion deadline, including the wait. */
5
+ export function runConditionXhr(target, url, condition, outcome, dispatch) {
6
+ if (!canSynthesize(target)) throw new Error("Network conditions require React Native XMLHttpRequest.");
7
+ const xhr = target;
8
+ if (xhr.readyState !== 1 || xhr._sent) throw new Error("Request is not open or has already been sent.");
9
+ const started = Date.now();
10
+ let timeout = xhr.timeout;
11
+ let settled = false;
12
+ let dispatching = false;
13
+ let deadline;
14
+ let pending;
15
+ const originalAbort = xhr.abort;
16
+ const originalOpen = xhr.open;
17
+ const abortDescriptor = Object.getOwnPropertyDescriptor(xhr, "abort");
18
+ const openDescriptor = Object.getOwnPropertyDescriptor(xhr, "open");
19
+ const timeoutDescriptor = Object.getOwnPropertyDescriptor(xhr, "timeout");
20
+ const restore = (key, descriptor) => {
21
+ if (descriptor) Object.defineProperty(xhr, key, descriptor);else delete xhr[key];
22
+ };
23
+ const cleanup = () => {
24
+ if (settled) return;
25
+ settled = true;
26
+ clearTimeout(pending);
27
+ clearTimeout(deadline);
28
+ xhr.removeEventListener("loadend", cleanup);
29
+ restore("abort", abortDescriptor);
30
+ restore("open", openDescriptor);
31
+ restore("timeout", timeoutDescriptor);
32
+ xhr.timeout = timeout;
33
+ };
34
+ const fail = timedOut => {
35
+ if (settled) return;
36
+ // DONE suppresses abort events while the native transport is cancelled.
37
+ Object.assign(xhr, {
38
+ readyState: 4
39
+ });
40
+ originalAbort.call(xhr);
41
+ xhr._aborted = false;
42
+ xhr._hasError = true;
43
+ xhr._timedOut = timedOut;
44
+ xhr._response = timedOut ? "Request timed out (Buoy conditions)" : "Network request failed (Buoy Offline)";
45
+ xhr.setReadyState(4);
46
+ cleanup();
47
+ };
48
+ const expired = () => timeout > 0 && Date.now() - started >= timeout;
49
+ const scheduleDeadline = () => {
50
+ clearTimeout(deadline);
51
+ if (timeout > 0) deadline = setTimeout(() => fail(true), Math.max(0, timeout - (Date.now() - started)));
52
+ };
53
+ Object.defineProperty(xhr, "timeout", {
54
+ configurable: true,
55
+ get: () => dispatching ? 0 : timeout,
56
+ set: value => {
57
+ timeout = Math.max(0, Number(value) || 0);
58
+ scheduleDeadline();
59
+ }
60
+ });
61
+ Object.defineProperty(xhr, "abort", {
62
+ configurable: true,
63
+ value: () => {
64
+ if (!settled) {
65
+ originalAbort.call(xhr);
66
+ cleanup();
67
+ }
68
+ }
69
+ });
70
+ Object.defineProperty(xhr, "open", {
71
+ configurable: true,
72
+ value: (...args) => {
73
+ const result = Reflect.apply(originalOpen, xhr, args);
74
+ cleanup();
75
+ return result;
76
+ }
77
+ });
78
+ xhr._sent = true;
79
+ xhr._requestId = null;
80
+ xhr.addEventListener("loadend", cleanup);
81
+ scheduleDeadline();
82
+ pending = setTimeout(() => {
83
+ if (settled) return;
84
+ if (expired()) {
85
+ fail(true);
86
+ return;
87
+ }
88
+ if (condition.profile === "offline") {
89
+ fail(false);
90
+ return;
91
+ }
92
+ if (outcome && outcome.kind !== "delay") {
93
+ completeNativeOverride(xhr, url, outcome);
94
+ return;
95
+ }
96
+ xhr._sent = false;
97
+ dispatching = true;
98
+ try {
99
+ dispatch();
100
+ } catch {
101
+ fail(false);
102
+ } finally {
103
+ dispatching = false;
104
+ }
105
+ }, condition.delayMs + (outcome?.delayMs ?? 0));
106
+ }
@@ -14,7 +14,7 @@ import { searchGraphQLVariables } from "../utils/formatGraphQLVariables";
14
14
 
15
15
  /**
16
16
  * @deprecated Read the live limit with `useLimit("network.requests")` — this
17
- * only reports the FREE tier's value and is blind to the anonymous tier. Kept
17
+ * only reports the FREE tier's value and ignores the Weekend Pass. Kept
18
18
  * because it is part of the package's public exports.
19
19
  */
20
20
  export const FREE_TIER_REQUEST_LIMIT = TIER_LIMITS["network.requests"].free;
@@ -400,16 +400,12 @@ export function runOverride(xhr, url, outcome, passThrough) {
400
400
  // had already aborted. Claiming it means abort() dispatches DONE + 'abort'
401
401
  // and whatwg-fetch rejects with AbortError, as it should.
402
402
  internals._sent = true;
403
- if (outcome.kind !== "delay") {
404
- // Buoy's own `open()` swizzle overwrites RN's private `_requestId` with a
405
- // string id. RN normally clobbers it back with the native numeric id inside
406
- // `send()` — which we're skipping — so it would survive here, and
407
- // `abort()` would hand that string to `RCTNetworking.abortRequest()`.
408
- // Nulling it makes abort a clean no-op on the native side.
409
- internals._requestId = null;
410
- }
403
+
404
+ // There is no native request to cancel until a delayed send is dispatched.
405
+ internals._requestId = null;
411
406
  let timer = null;
412
407
  const onAbort = () => {
408
+ xhr.removeEventListener("abort", onAbort);
413
409
  if (timer !== null) {
414
410
  clearTimeout(timer);
415
411
  timer = null;
@@ -442,4 +438,9 @@ export function runOverride(xhr, url, outcome, passThrough) {
442
438
  // 'load' before they exist.
443
439
  timer = setTimeout(fire, outcome.delayMs);
444
440
  return true;
441
+ }
442
+
443
+ /** Complete an already scheduled native outcome without adding another timer. */
444
+ export function completeNativeOverride(xhr, url, outcome) {
445
+ if (outcome.kind === "fail") fail(xhr, outcome);else if (outcome.kind === "respond") respond(xhr, url, outcome);
445
446
  }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ import { hasAccountAccess } from "@buoy-gg/shared-ui";
3
4
  /**
4
5
  * The seam between the interceptor and the rule store.
5
6
  *
@@ -34,7 +35,7 @@ export function setOverrideHooks(next) {
34
35
  export function overridesCanApply() {
35
36
  // `__DEV__` is undefined outside React Native (unit tests, the desktop
36
37
  // bundle); only an explicit `false` — a real release build — disables it.
37
- return !(typeof __DEV__ !== "undefined" && !__DEV__);
38
+ return hasAccountAccess() && !(typeof __DEV__ !== "undefined" && !__DEV__);
38
39
  }
39
40
 
40
41
  /** Why overrides are inert, for a caller that has to explain it to a person. */
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ import { getNetworkCondition, setNetworkCondition, subscribeNetworkConditions, conditionsCanApply, networkConditionsRuntimeId } from "../conditions/store";
3
4
  import { networkEventStore } from "../utils/networkEventStore";
4
5
  import { flagsForEventId, networkSavedStore } from "../utils/networkSavedStore";
5
6
  import { overrideRulesStore } from "../overrides/overrideRulesStore";
@@ -7,6 +8,7 @@ import { draftFromEvent } from "../overrides/presets";
7
8
  import { inspectEngine, overridesCanApply, OVERRIDES_INACTIVE_REASON } from "../overrides/engine";
8
9
  import { getNetworkListenerCount, isNetworkInterceptionLive, isNetworkListening } from "../utils/networkListener";
9
10
  import { approxJsonSize, isOverWireBudget } from "@buoy-gg/shared-ui";
11
+ import { expandKeyedArrays, patchFromPath, refusalText, shapeKind, suggestedPatch, typedEditViolations } from "@buoy-gg/shared-ui";
10
12
 
11
13
  /**
12
14
  * Request/response bodies larger than this are NOT streamed in the list
@@ -308,13 +310,116 @@ base) {
308
310
  headers: rule.headers && typeof rule.headers === "object" ? rule.headers : base?.headers,
309
311
  // Objects are accepted and serialized so a caller can send JSON directly
310
312
  // instead of pre-stringifying it, which is the mistake everyone makes once.
311
- body: typeof rule.body === "string" ? rule.body : rule.body != null ? JSON.stringify(rule.body, null, 2) : base?.body,
313
+ body: patchedBody(rule, base),
312
314
  failKind: rule.failKind === "timeout" ? "timeout" : undefined,
313
315
  delayMs: typeof rule.delayMs === "number" ? rule.delayMs : undefined,
314
316
  times: typeof rule.times === "number" ? rule.times : null,
315
317
  alternate: rule.alternate === true
316
318
  };
317
319
  }
320
+ const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
321
+ /**
322
+ * Objects merge key by key; arrays and primitives are replaced. Collects two
323
+ * dangers instead of writing them: `shrunk` (a non-empty array shorter than
324
+ * the one it replaces) and `breaks` (an array/object field overwritten by null
325
+ * or a primitive — the shape the app renders, changed out from under it). The
326
+ * handler refuses either unless forced.
327
+ *
328
+ * This stays local while the typed-edit RULE is shared: it is the merge itself,
329
+ * not the check, and it reports network-specific dangers the store surfaces do
330
+ * not have. `shapeKind` comes from the shared module so both agree on what
331
+ * counts as an array.
332
+ */
333
+ function deepMerge(base, patch, path = "", issues = {
334
+ shrunk: [],
335
+ breaks: []
336
+ }) {
337
+ if (Array.isArray(base) && Array.isArray(patch)) {
338
+ if (patch.length > 0 && patch.length < base.length) {
339
+ issues.shrunk.push({
340
+ path: path || "(root)",
341
+ have: base.length,
342
+ sent: patch.length
343
+ });
344
+ }
345
+ return patch;
346
+ }
347
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
348
+ const out = {
349
+ ...base
350
+ };
351
+ for (const [k, v] of Object.entries(patch)) {
352
+ const p = path ? `${path}.${k}` : k;
353
+ if (k in base) {
354
+ const from = shapeKind(base[k]);
355
+ const to = shapeKind(v);
356
+ if ((from === "array" || from === "object") && from !== to) {
357
+ issues.breaks.push({
358
+ path: p,
359
+ from,
360
+ to
361
+ });
362
+ }
363
+ out[k] = deepMerge(base[k], v, p, issues);
364
+ } else {
365
+ out[k] = v;
366
+ }
367
+ }
368
+ return out;
369
+ }
370
+
371
+ /** Typed-edit violations a bodyPatch would cause against the seed body, or []. */
372
+ function patchViolations(rule, seed) {
373
+ if (rule.body != null || !isPlainObject(rule.bodyPatch) || typeof seed !== "string") return [];
374
+ try {
375
+ return typedEditViolations(JSON.parse(seed), rule.bodyPatch, {
376
+ noun: "response body"
377
+ });
378
+ } catch {
379
+ return [];
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The rule's body: explicit `body` wins, else `bodyPatch` deep-merged into the
385
+ * seeded body (from `fromRequestId`, or the rule being replaced), else the seed.
386
+ *
387
+ * `bodyPatch` exists for the same reason `setQueryData` has `merge`: a remote
388
+ * caller reads the real response through a wire that truncates big payloads,
389
+ * edits one field, and pastes the torn object back as `body`. The app then
390
+ * renders a response missing half its fields. With a patch the caller sends
391
+ * `{ stats: [...] }` and the other 30KB stay exactly as the server sent them.
392
+ */
393
+ function patchedBody(rule, base) {
394
+ if (typeof rule.body === "string") return rule.body;
395
+ if (rule.body != null) return JSON.stringify(rule.body, null, 2);
396
+ const seed = base?.body ?? (typeof rule.id === "string" ? overrideRulesStore.getState().rules.find(r => r.id === rule.id)?.body : undefined);
397
+ if (isPlainObject(rule.bodyPatch) && typeof seed === "string") {
398
+ try {
399
+ return JSON.stringify(deepMerge(JSON.parse(seed), rule.bodyPatch), null, 2);
400
+ } catch {
401
+ return seed;
402
+ }
403
+ }
404
+ return seed;
405
+ }
406
+
407
+ /**
408
+ * Replacing a rule by id inherits every field the caller did not send, so a
409
+ * remote driver can flip one thing — `{ id, bodyPatch }`, `{ id, status: 500 }`
410
+ * — without re-sending the urlPattern it never had to know.
411
+ */
412
+ function existingRule(params) {
413
+ if (!params || typeof params !== "object") return null;
414
+ const raw = params.rule ?? params;
415
+ if (!raw || typeof raw !== "object") return null;
416
+ const id = raw.id;
417
+ if (typeof id !== "string") return null;
418
+ const existing = overrideRulesStore.getState().rules.find(r => r.id === id);
419
+ return existing ? {
420
+ ...existing
421
+ } : null;
422
+ }
318
423
 
319
424
  /**
320
425
  * The body a remote surface never received, when it says so.
@@ -370,10 +475,13 @@ function baseFromRequest(params) {
370
475
  * `NetworkPanel`/`NetworkInline` accept either shape.
371
476
  */
372
477
  export const networkSyncAdapter = {
478
+ dispose: () => {
479
+ overrideRulesStore.setEnabled(false);
480
+ },
373
481
  // v5: honest request/responseSize + explicit request/responseBodyOmitted
374
482
  // flags, and a total snapshot budget so the list degrades instead of the
375
483
  // whole panel being dropped at the emit layer.
376
- version: 5,
484
+ version: 6,
377
485
  getSnapshot: () => {
378
486
  // Piggyback the interception health check on the watch loop: while a
379
487
  // dashboard/MCP watch is open this runs continuously, so a clobbered
@@ -382,6 +490,12 @@ export const networkSyncAdapter = {
382
490
  // reads when healthy.
383
491
  networkEventStore.ensureInterception();
384
492
  return {
493
+ conditions: {
494
+ version: 1,
495
+ runtimeId: networkConditionsRuntimeId,
496
+ profile: getNetworkCondition(),
497
+ available: conditionsCanApply()
498
+ },
385
499
  events: fitEventsToBudget(networkEventStore.getEvents()),
386
500
  /**
387
501
  * Response-override rules live on the DEVICE — that's the only place they
@@ -402,17 +516,32 @@ export const networkSyncAdapter = {
402
516
  // Pinning with no traffic in flight still has to reach the dashboard, so
403
517
  // both stores drive the snapshot.
404
518
  const unsubscribeEvents = networkEventStore.subscribeToEvents(onChange);
519
+ const unsubscribeConditions = subscribeNetworkConditions(onChange);
405
520
  const unsubscribeSaved = networkSavedStore.subscribe(onChange);
406
521
  // Same reasoning for rules: editing one with no traffic in flight must
407
522
  // still reach the dashboard. Subscribing also hydrates the rule list.
408
523
  const unsubscribeOverrides = overrideRulesStore.subscribe(onChange);
409
524
  return () => {
525
+ unsubscribeConditions();
410
526
  unsubscribeEvents();
411
527
  unsubscribeSaved();
412
528
  unsubscribeOverrides();
413
529
  };
414
530
  },
415
531
  actions: {
532
+ getNetworkConditions: () => ({
533
+ runtimeId: networkConditionsRuntimeId,
534
+ profile: getNetworkCondition(),
535
+ available: conditionsCanApply()
536
+ }),
537
+ setNetworkConditions: params => {
538
+ const profile = params && typeof params === "object" ? params.profile : undefined;
539
+ return {
540
+ runtimeId: networkConditionsRuntimeId,
541
+ profile: setNetworkCondition(profile),
542
+ available: conditionsCanApply()
543
+ };
544
+ },
416
545
  clearEvents: () => {
417
546
  networkEventStore.clearEvents();
418
547
  },
@@ -508,7 +637,89 @@ export const networkSyncAdapter = {
508
637
  },
509
638
  /** Create a rule, or replace one by id. */
510
639
  upsertOverrideRule: params => {
511
- const base = baseFromRequest(params) ?? preservedBody(params);
640
+ const base = baseFromRequest(params) ?? preservedBody(params) ?? existingRule(params);
641
+ const rawRule = params?.rule ?? params;
642
+ // `bodyPath` + `bodyValue` are sugar over `bodyPatch`, resolved against the
643
+ // real response before anything else looks at the rule — so the keyed
644
+ // expansion, the typed-edit guard and the merge below all run on an
645
+ // ordinary patch and gain no special cases. This is the same form the
646
+ // state tools take; it exists here because a response body is the surface
647
+ // where the caller is LEAST likely to have seen the shape (it read the
648
+ // body over a wire that truncates at 24,000 characters), and therefore
649
+ // most likely to get the nesting wrong.
650
+ if (rawRule && typeof rawRule.bodyPath === "string" && rawRule.body == null) {
651
+ const seed = base?.body ?? (typeof rawRule.id === "string" ? overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
652
+ if (typeof seed !== "string") {
653
+ return {
654
+ ok: false,
655
+ error: "bodyPath needs something to patch: send fromRequestId (the captured request whose real response it addresses) or the id of an existing rule."
656
+ };
657
+ }
658
+ if (rawRule.bodyValue === undefined) {
659
+ return {
660
+ ok: false,
661
+ error: `Refused: sent \`bodyPath\` without \`bodyValue\`. Say what to set "${rawRule.bodyPath}" to.`
662
+ };
663
+ }
664
+ let parsed;
665
+ try {
666
+ parsed = JSON.parse(seed);
667
+ } catch {
668
+ return {
669
+ ok: false,
670
+ error: "The captured response body is not JSON, so it cannot be addressed by path. Send a whole `body` instead."
671
+ };
672
+ }
673
+ const built = patchFromPath(parsed, rawRule.bodyPath, rawRule.bodyValue);
674
+ if (!built.ok) return {
675
+ ok: false,
676
+ error: `Refused: ${built.error}`
677
+ };
678
+ rawRule.bodyPatch = built.patch;
679
+ delete rawRule.bodyPath;
680
+ delete rawRule.bodyValue;
681
+ }
682
+ if (rawRule && isPlainObject(rawRule.bodyPatch) && rawRule.body == null) {
683
+ const seed = base?.body ?? (typeof rawRule.id === "string" ? overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
684
+ if (typeof seed !== "string") {
685
+ return {
686
+ ok: false,
687
+ error: "bodyPatch needs something to patch: send fromRequestId (the captured request whose real response the patch is merged into) or the id of an existing rule."
688
+ };
689
+ }
690
+ if (rawRule.force !== true) {
691
+ // Address list items in the real response by their own id rather than
692
+ // resending the array: {results:{pikachu:{name:"test123"}}}. Expanded
693
+ // against the seed BEFORE the guard and before the merge, so the
694
+ // typed-edit rule still holds over the result. This is the only form
695
+ // that is correct when the caller read the response through a wire
696
+ // that truncated it — see packages/shared/src/sync/keyedArrays.ts.
697
+ let keyedError;
698
+ try {
699
+ const expanded = expandKeyedArrays(JSON.parse(seed), rawRule.bodyPatch);
700
+ keyedError = expanded.error;
701
+ if (expanded.notes.length) rawRule.bodyPatch = expanded.patch;
702
+ } catch {
703
+ /* a non-JSON seed has no lists to address; leave the patch alone */
704
+ }
705
+ // See keyedArrays.ts: an added item whose id disagrees with the key
706
+ // it was sent under is ambiguous, and only this layer can say so.
707
+ if (keyedError) {
708
+ return {
709
+ ok: false,
710
+ error: `Refused: ${keyedError}`
711
+ };
712
+ }
713
+ const violations = patchViolations(rawRule, seed);
714
+ if (violations.length > 0) {
715
+ return {
716
+ ok: false,
717
+ error: refusalText(violations, "response body", suggestedPatch(rawRule.bodyPatch, violations)),
718
+ violations
719
+ };
720
+ }
721
+ }
722
+ }
512
723
  const draft = readRuleDraft(params, base);
513
724
  if (!draft) {
514
725
  const wanted = params?.fromRequestId;
@@ -538,8 +749,19 @@ export const networkSyncAdapter = {
538
749
  enabled: rule.enabled,
539
750
  kind: rule.kind,
540
751
  status: rule.status,
752
+ times: rule.times,
541
753
  bodySize: rule.body ? rule.body.length : 0
542
754
  },
755
+ // A rule with no budget overrides EVERY matching request until someone
756
+ // deletes it, and that is rarely what a one-off ask meant. Measured on
757
+ // `api-sim/next-call-fails-once` ("just the next one, then let it work
758
+ // again"): ten of fourteen attempts left the budget off and got a rule
759
+ // that never expires. Saying so is not a nag — it is the difference
760
+ // between a caller that adds `times` on its next turn and one that walks
761
+ // away leaving the app permanently broken.
762
+ ...(rule.times === undefined || rule.times === null ? {
763
+ lingers: `This rule has no \`times\` budget, so it overrides EVERY matching request until it is disabled or deleted (id \`${rule.id}\`). If you meant only the next one, set times:1.`
764
+ } : {}),
543
765
  ...inactiveStamp()
544
766
  };
545
767
  },
@@ -77,6 +77,7 @@ export function generateNetworkCopyText(events, copySettings) {
77
77
  obj.statusText = event.statusText;
78
78
  }
79
79
  if (copySettings.includeDuration) obj.duration = event.duration;
80
+ if (event.condition) obj.condition = event.condition;
80
81
  if (copySettings.includeTimestamp) obj.timestamp = event.timestamp;
81
82
  if (copySettings.includeClient) obj.requestClient = event.requestClient;
82
83
  if (copySettings.includeSizes) {
@@ -115,6 +116,7 @@ export function generateNetworkCopyText(events, copySettings) {
115
116
  if (copySettings.includeClient && event.requestClient) {
116
117
  md += `**Client:** ${event.requestClient}\n`;
117
118
  }
119
+ if (event.condition) md += `Conditions: ${event.condition.profile} (+${event.condition.delayMs} ms)\n`;
118
120
  if (copySettings.includeDuration && event.duration) {
119
121
  md += `**Duration:** ${event.duration}ms\n`;
120
122
  }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ import { getAccountAccess } from "@buoy-gg/shared-ui";
3
4
  /**
4
5
  * Network Event Store
5
6
  *
@@ -43,14 +44,6 @@ import { networkSavedStore } from "./networkSavedStore";
43
44
  const BOOT_BUFFER_MAX = 200;
44
45
  class NetworkEventStore extends BaseEventStore {
45
46
  pendingRequests = new Map();
46
- /**
47
- * Recently seen `METHOD:url` keys → when, and which event id we kept.
48
- *
49
- * The timestamp powers the dual-interception dedup below. The id is what lets
50
- * a DROPPED duplicate still contribute something the kept event doesn't know
51
- * — see `applyOverrideMark`.
52
- */
53
- recentRequests = new Map();
54
47
  rawListenerUnsubscribe = null;
55
48
  /** Raw events captured before the first consumer subscribed — see startBootCapture. */
56
49
  bootBuffer = [];
@@ -82,6 +75,7 @@ class NetworkEventStore extends BaseEventStore {
82
75
  * state therefore never gains events it didn't ask for).
83
76
  */
84
77
  startBootCapture() {
78
+ if (getAccountAccess().status === "locked") return;
85
79
  if (this.mirrorMode || this.bootBufferUnsubscribe || this.isCapturing()) {
86
80
  return;
87
81
  }
@@ -136,6 +130,7 @@ class NetworkEventStore extends BaseEventStore {
136
130
  * Stop capturing network events
137
131
  */
138
132
  stopCapturing() {
133
+ this.takeBootBuffer();
139
134
  // Remove our raw listener
140
135
  if (this.rawListenerUnsubscribe) {
141
136
  this.rawListenerUnsubscribe();
@@ -202,57 +197,11 @@ class NetworkEventStore extends BaseEventStore {
202
197
  * Process a network listener event
203
198
  */
204
199
  processNetworkEvent(event) {
200
+ if (getAccountAccess().status === "locked") return;
205
201
  const {
206
202
  request
207
203
  } = event;
208
204
  if (event.type === "request") {
209
- // Check for duplicate request based on URL, method, and timing.
210
- // The clock is the event's CAPTURE time, not Date.now(): boot-buffered
211
- // events are flushed in one synchronous burst, and wall-clock "now"
212
- // would put two genuinely separate boot requests to the same endpoint
213
- // inside the 50ms dual-interception window, silently dropping one. For
214
- // live events the two clocks are the same (the raw event is emitted
215
- // synchronously at capture).
216
- const requestKey = `${request.method}:${request.url}`;
217
- const now = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
218
- const recent = this.recentRequests.get(requestKey);
219
-
220
- // If same request within 50ms, likely a duplicate from XHR/fetch dual
221
- // interception. The age must be non-negative: Date.now() is not
222
- // monotonic, and after a backwards clock adjustment (iOS time sync) a
223
- // future-dated entry would satisfy `< 50` for EVERY request to this
224
- // endpoint until the clock caught up — capture going silent for minutes
225
- // to hours, "fixed" only by Clear wiping this map (field-reported). A
226
- // negative age falls through and re-stamps the entry with the new clock.
227
- if (recent && now - recent.at >= 0 && now - recent.at < 50) {
228
- // …but the duplicate is not always redundant. On React Native a
229
- // `fetch()` is seen twice: once by the fetch wrapper, which is what
230
- // creates this event, and again by the XHR wrapper underneath it —
231
- // and ONLY the XHR layer resolves overrides, because that's where the
232
- // request can actually be intercepted. So the mark arrives on the copy
233
- // we're about to throw away. Move it across before dropping.
234
- if (event.override) {
235
- this.applyOverrideMark(recent.id, event.override);
236
- }
237
- return;
238
- }
239
- this.recentRequests.set(requestKey, {
240
- at: now,
241
- id: request.id
242
- });
243
-
244
- // Clean up old entries to prevent memory leak. Future-dated entries are
245
- // stale by definition (clock moved backwards) — drop those too, or they
246
- // survive every sweep until the clock overtakes them.
247
- if (this.recentRequests.size > 100) {
248
- const cutoff = now - 5000;
249
- for (const [key, entry] of this.recentRequests.entries()) {
250
- if (entry.at < cutoff || entry.at > now) {
251
- this.recentRequests.delete(key);
252
- }
253
- }
254
- }
255
-
256
205
  // Build full URL with query params if present
257
206
  const queryString = request.params ? `?${new URLSearchParams(request.params).toString()}` : "";
258
207
  const fullUrl = `${request.url}${queryString}`;
@@ -284,7 +233,8 @@ class NetworkEventStore extends BaseEventStore {
284
233
  graphqlVariables,
285
234
  // Present when this request came straight through the XHR wrapper
286
235
  // (axios, raw XHR). For `fetch` it arrives later — see the dedup branch.
287
- override: event.override
236
+ override: event.override,
237
+ condition: event.condition
288
238
  };
289
239
  this.pendingRequests.set(request.id, networkEvent);
290
240
  this.events = [networkEvent, ...this.events].slice(0, this.maxEvents);
@@ -328,26 +278,6 @@ class NetworkEventStore extends BaseEventStore {
328
278
  }
329
279
  }
330
280
  }
331
-
332
- /**
333
- * Stamp an override mark onto an already-recorded event.
334
- *
335
- * Only ever moves a mark from a dropped duplicate onto the event that was
336
- * kept, so it never overwrites one that is already there.
337
- */
338
- applyOverrideMark(id, override) {
339
- const index = this.events.findIndex(e => e.id === id);
340
- if (index === -1 || this.events[index].override) return;
341
- const updatedEvent = {
342
- ...this.events[index],
343
- override
344
- };
345
- this.events = this.events.map((e, i) => i === index ? updatedEvent : e);
346
- const pending = this.pendingRequests.get(id);
347
- if (pending) this.pendingRequests.set(id, updatedEvent);
348
- this.notify(updatedEvent);
349
- this.notifyArrayListeners();
350
- }
351
281
  extractHost(url) {
352
282
  try {
353
283
  const urlObj = new URL(url);
@@ -385,7 +315,6 @@ class NetworkEventStore extends BaseEventStore {
385
315
  clearEvents() {
386
316
  super.clearEvents();
387
317
  this.pendingRequests.clear();
388
- this.recentRequests.clear();
389
318
  }
390
319
 
391
320
  /**