@buoy-gg/network 7.0.13 → 7.0.14

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 (123) hide show
  1. package/lib/commonjs/index.js +39 -0
  2. package/lib/commonjs/network/components/NetworkEventDetailView.js +30 -3
  3. package/lib/commonjs/network/components/NetworkEventItemCompact.js +4 -0
  4. package/lib/commonjs/network/components/NetworkHeaderLive.js +26 -6
  5. package/lib/commonjs/network/components/NetworkHeaderMenu.js +183 -0
  6. package/lib/commonjs/network/components/NetworkListScreen.js +126 -5
  7. package/lib/commonjs/network/components/NetworkModal.js +136 -29
  8. package/lib/commonjs/network/components/NetworkNumberPrompt.js +178 -0
  9. package/lib/commonjs/network/components/NetworkOverrideEditor.js +797 -0
  10. package/lib/commonjs/network/components/NetworkOverrideHeaderButton.js +102 -0
  11. package/lib/commonjs/network/components/NetworkOverridesButton.js +88 -0
  12. package/lib/commonjs/network/components/NetworkOverridesScreen.js +489 -0
  13. package/lib/commonjs/network/components/response-editor/Explorer.js +973 -0
  14. package/lib/commonjs/network/components/response-editor/deleteNestedDataByPath.js +56 -0
  15. package/lib/commonjs/network/components/response-editor/editorContext.js +33 -0
  16. package/lib/commonjs/network/components/response-editor/updateNestedDataByPath.js +61 -0
  17. package/lib/commonjs/network/hooks/useOverrideRules.js +36 -0
  18. package/lib/commonjs/network/overrides/applyToFetch.js +80 -0
  19. package/lib/commonjs/network/overrides/applyToXhr.js +190 -0
  20. package/lib/commonjs/network/overrides/engine.js +70 -0
  21. package/lib/commonjs/network/overrides/matchRule.js +81 -0
  22. package/lib/commonjs/network/overrides/overrideRulesStore.js +550 -0
  23. package/lib/commonjs/network/overrides/presets.js +70 -0
  24. package/lib/commonjs/network/overrides/resolveOverride.js +158 -0
  25. package/lib/commonjs/network/overrides/types.js +85 -0
  26. package/lib/commonjs/network/sync/networkSyncAdapter.js +230 -1
  27. package/lib/commonjs/network/types/index.js +5 -1
  28. package/lib/commonjs/network/utils/networkEventStore.js +52 -6
  29. package/lib/commonjs/network/utils/networkListener.js +90 -10
  30. package/lib/commonjs/preset.js +11 -0
  31. package/lib/module/index.js +15 -0
  32. package/lib/module/network/components/NetworkEventDetailView.js +31 -4
  33. package/lib/module/network/components/NetworkEventItemCompact.js +5 -1
  34. package/lib/module/network/components/NetworkHeaderLive.js +26 -6
  35. package/lib/module/network/components/NetworkHeaderMenu.js +177 -0
  36. package/lib/module/network/components/NetworkListScreen.js +127 -6
  37. package/lib/module/network/components/NetworkModal.js +138 -31
  38. package/lib/module/network/components/NetworkNumberPrompt.js +174 -0
  39. package/lib/module/network/components/NetworkOverrideEditor.js +794 -0
  40. package/lib/module/network/components/NetworkOverrideHeaderButton.js +99 -0
  41. package/lib/module/network/components/NetworkOverridesButton.js +85 -0
  42. package/lib/module/network/components/NetworkOverridesScreen.js +485 -0
  43. package/lib/module/network/components/response-editor/Explorer.js +972 -0
  44. package/lib/module/network/components/response-editor/deleteNestedDataByPath.js +51 -0
  45. package/lib/module/network/components/response-editor/editorContext.js +29 -0
  46. package/lib/module/network/components/response-editor/updateNestedDataByPath.js +56 -0
  47. package/lib/module/network/hooks/useOverrideRules.js +32 -0
  48. package/lib/module/network/overrides/applyToFetch.js +72 -0
  49. package/lib/module/network/overrides/applyToXhr.js +185 -0
  50. package/lib/module/network/overrides/engine.js +64 -0
  51. package/lib/module/network/overrides/matchRule.js +75 -0
  52. package/lib/module/network/overrides/overrideRulesStore.js +547 -0
  53. package/lib/module/network/overrides/presets.js +65 -0
  54. package/lib/module/network/overrides/resolveOverride.js +152 -0
  55. package/lib/module/network/overrides/types.js +80 -0
  56. package/lib/module/network/sync/networkSyncAdapter.js +230 -1
  57. package/lib/module/network/types/index.js +3 -1
  58. package/lib/module/network/utils/networkEventStore.js +52 -6
  59. package/lib/module/network/utils/networkListener.js +91 -10
  60. package/lib/module/preset.js +13 -1
  61. package/lib/typescript/index.d.ts +8 -0
  62. package/lib/typescript/index.d.ts.map +1 -1
  63. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  64. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts +6 -0
  65. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts.map +1 -1
  66. package/lib/typescript/network/components/NetworkHeaderLive.d.ts +8 -1
  67. package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
  68. package/lib/typescript/network/components/NetworkHeaderMenu.d.ts +46 -0
  69. package/lib/typescript/network/components/NetworkHeaderMenu.d.ts.map +1 -0
  70. package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
  71. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  72. package/lib/typescript/network/components/NetworkNumberPrompt.d.ts +33 -0
  73. package/lib/typescript/network/components/NetworkNumberPrompt.d.ts.map +1 -0
  74. package/lib/typescript/network/components/NetworkOverrideEditor.d.ts +17 -0
  75. package/lib/typescript/network/components/NetworkOverrideEditor.d.ts.map +1 -0
  76. package/lib/typescript/network/components/NetworkOverrideHeaderButton.d.ts +27 -0
  77. package/lib/typescript/network/components/NetworkOverrideHeaderButton.d.ts.map +1 -0
  78. package/lib/typescript/network/components/NetworkOverridesButton.d.ts +17 -0
  79. package/lib/typescript/network/components/NetworkOverridesButton.d.ts.map +1 -0
  80. package/lib/typescript/network/components/NetworkOverridesScreen.d.ts +29 -0
  81. package/lib/typescript/network/components/NetworkOverridesScreen.d.ts.map +1 -0
  82. package/lib/typescript/network/components/response-editor/Explorer.d.ts +17 -0
  83. package/lib/typescript/network/components/response-editor/Explorer.d.ts.map +1 -0
  84. package/lib/typescript/network/components/response-editor/deleteNestedDataByPath.d.ts +9 -0
  85. package/lib/typescript/network/components/response-editor/deleteNestedDataByPath.d.ts.map +1 -0
  86. package/lib/typescript/network/components/response-editor/editorContext.d.ts +29 -0
  87. package/lib/typescript/network/components/response-editor/editorContext.d.ts.map +1 -0
  88. package/lib/typescript/network/components/response-editor/updateNestedDataByPath.d.ts +10 -0
  89. package/lib/typescript/network/components/response-editor/updateNestedDataByPath.d.ts.map +1 -0
  90. package/lib/typescript/network/hooks/useOverrideRules.d.ts +29 -0
  91. package/lib/typescript/network/hooks/useOverrideRules.d.ts.map +1 -0
  92. package/lib/typescript/network/overrides/__tests__/applyToFetch.test.d.ts +16 -0
  93. package/lib/typescript/network/overrides/__tests__/applyToFetch.test.d.ts.map +1 -0
  94. package/lib/typescript/network/overrides/__tests__/applyToXhr.test.d.ts +19 -0
  95. package/lib/typescript/network/overrides/__tests__/applyToXhr.test.d.ts.map +1 -0
  96. package/lib/typescript/network/overrides/__tests__/resolveOverride.test.d.ts +22 -0
  97. package/lib/typescript/network/overrides/__tests__/resolveOverride.test.d.ts.map +1 -0
  98. package/lib/typescript/network/overrides/applyToFetch.d.ts +50 -0
  99. package/lib/typescript/network/overrides/applyToFetch.d.ts.map +1 -0
  100. package/lib/typescript/network/overrides/applyToXhr.d.ts +43 -0
  101. package/lib/typescript/network/overrides/applyToXhr.d.ts.map +1 -0
  102. package/lib/typescript/network/overrides/engine.d.ts +47 -0
  103. package/lib/typescript/network/overrides/engine.d.ts.map +1 -0
  104. package/lib/typescript/network/overrides/matchRule.d.ts +22 -0
  105. package/lib/typescript/network/overrides/matchRule.d.ts.map +1 -0
  106. package/lib/typescript/network/overrides/overrideRulesStore.d.ts +205 -0
  107. package/lib/typescript/network/overrides/overrideRulesStore.d.ts.map +1 -0
  108. package/lib/typescript/network/overrides/presets.d.ts +38 -0
  109. package/lib/typescript/network/overrides/presets.d.ts.map +1 -0
  110. package/lib/typescript/network/overrides/resolveOverride.d.ts +46 -0
  111. package/lib/typescript/network/overrides/resolveOverride.d.ts.map +1 -0
  112. package/lib/typescript/network/overrides/types.d.ts +169 -0
  113. package/lib/typescript/network/overrides/types.d.ts.map +1 -0
  114. package/lib/typescript/network/sync/networkSyncAdapter.d.ts +73 -0
  115. package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
  116. package/lib/typescript/network/types/index.d.ts +10 -0
  117. package/lib/typescript/network/types/index.d.ts.map +1 -1
  118. package/lib/typescript/network/utils/networkEventStore.d.ts +14 -0
  119. package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
  120. package/lib/typescript/network/utils/networkListener.d.ts +3 -0
  121. package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
  122. package/lib/typescript/preset.d.ts.map +1 -1
  123. package/package.json +2 -2
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Decide whether a request is overridden — the whole decision, in one pure
5
+ * function.
6
+ *
7
+ * Pure on purpose: this is the piece worth unit-testing, and it runs inside the
8
+ * app's own `XMLHttpRequest.send()`, so it must be cheap, total, and incapable
9
+ * of throwing. Everything that could surprise (defaults, clamping, content-type
10
+ * inference) is resolved HERE, so `applyToXhr` never makes a decision — it just
11
+ * performs the outcome.
12
+ */
13
+
14
+ import { ruleMatches } from "./matchRule";
15
+ import { STATUS_MAX, STATUS_MIN } from "./types";
16
+
17
+ /**
18
+ * Traffic that must never be overridden, whatever the user's rules say.
19
+ *
20
+ * A `*` pattern is a completely reasonable thing for a developer to write
21
+ * ("break everything and see what the app does"), and without this it would
22
+ * also break Buoy's own licence checks — turning "I tested an error state" into
23
+ * "the dev tools stopped working and I don't know why". Metro/debugger traffic
24
+ * is already excluded upstream by `networkListener.shouldIgnoreUrl`.
25
+ */
26
+ const NEVER_OVERRIDE = [/(^|\.)keygen\.sh/i];
27
+ function isProtectedUrl(url) {
28
+ return NEVER_OVERRIDE.some(pattern => pattern.test(url));
29
+ }
30
+
31
+ /**
32
+ * Guess a `content-type` when the rule didn't set one.
33
+ *
34
+ * Getting this wrong is not cosmetic: a client that trusts the header (axios's
35
+ * default transform, any `response.json()` wrapper) will hand the app a string
36
+ * where it expected an object, and the override looks broken rather than the
37
+ * header looking wrong.
38
+ */
39
+ function inferContentType(body) {
40
+ const trimmed = body.trim();
41
+ if (trimmed === "") return "text/plain; charset=utf-8";
42
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
43
+ try {
44
+ JSON.parse(trimmed);
45
+ return "application/json";
46
+ } catch {
47
+ // Looks like JSON, isn't. Fall through — mislabelling it would be worse.
48
+ }
49
+ }
50
+ return "text/plain; charset=utf-8";
51
+ }
52
+ function hasHeader(headers, name) {
53
+ const wanted = name.toLowerCase();
54
+ return Object.keys(headers).some(key => key.toLowerCase() === wanted);
55
+ }
56
+
57
+ /** Normalize a matched rule into the exact instructions the interceptor needs. */
58
+ function toOutcome(rule) {
59
+ const delayMs = Math.max(0, rule.delayMs ?? 0);
60
+ if (rule.kind === "delay") {
61
+ return {
62
+ kind: "delay",
63
+ rule,
64
+ delayMs
65
+ };
66
+ }
67
+ if (rule.kind === "fail") {
68
+ return {
69
+ kind: "fail",
70
+ rule,
71
+ delayMs,
72
+ failKind: rule.failKind ?? "network"
73
+ };
74
+ }
75
+ const body = rule.body ?? "";
76
+ const headers = {
77
+ ...(rule.headers ?? {})
78
+ };
79
+ if (!hasHeader(headers, "content-type")) {
80
+ headers["content-type"] = inferContentType(body);
81
+ }
82
+ return {
83
+ kind: "respond",
84
+ rule,
85
+ delayMs,
86
+ // Clamped, not rejected: whatwg-fetch's Response constructor THROWS outside
87
+ // [200, 599], which would crash the app's own fetch instead of overriding
88
+ // it. The editor constrains the input; this guarantees it.
89
+ status: Math.min(STATUS_MAX, Math.max(STATUS_MIN, rule.status ?? 200)),
90
+ statusText: rule.statusText ?? "",
91
+ headers,
92
+ body
93
+ };
94
+ }
95
+
96
+ /** A rule that has burned through its `times` budget no longer applies. */
97
+ export function isSpent(rule) {
98
+ return typeof rule.times === "number" && rule.hits >= rule.times;
99
+ }
100
+
101
+ /**
102
+ * Is this rule in a phase where it should actually fire?
103
+ *
104
+ * Only `alternate` can answer no. It reads the CURRENT `seen` count, before
105
+ * this match is recorded, so an untouched rule (`seen === 0`) lets the first
106
+ * request through and overrides the second.
107
+ */
108
+ export function shouldApply(rule) {
109
+ if (!rule.alternate) return true;
110
+ return rule.seen % 2 === 1;
111
+ }
112
+
113
+ /**
114
+ * The first enabled rule whose pattern covers this request, phase ignored.
115
+ *
116
+ * Separate from `resolveOverride` because an alternating rule has to advance
117
+ * its phase on the requests it deliberately skips — the caller needs the rule
118
+ * back even when the answer is "not this time".
119
+ */
120
+ export function findMatchingRule(url, method, rules) {
121
+ if (rules.length === 0) return null;
122
+ // Chrome skips OPTIONS too (`NetworkPersistenceManager.ts:953`). A faked
123
+ // preflight fails CORS in a way that looks nothing like the rule you wrote.
124
+ if (method.toUpperCase() === "OPTIONS") return null;
125
+ if (isProtectedUrl(url)) return null;
126
+ for (const rule of rules) {
127
+ if (!rule.enabled) continue;
128
+ if (isSpent(rule)) continue;
129
+ if (!ruleMatches(rule, url, method)) continue;
130
+ return rule;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * First ENABLED matching rule wins outright — no merging across rules.
137
+ *
138
+ * Chrome cascades header overrides from the most general path segment to the
139
+ * most specific, but that only exists because its rules are directory-nested
140
+ * files. A flat list with a cascade would be a rule whose effect you cannot see
141
+ * by reading it, which is exactly the wrong trade on a phone screen.
142
+ *
143
+ * @param url Full request URL, query string included.
144
+ * @param method HTTP method as the caller sent it (case-insensitive).
145
+ * @param rules Ordered rule list; earlier entries win.
146
+ */
147
+ export function resolveOverride(url, method, rules) {
148
+ const rule = findMatchingRule(url, method, rules);
149
+ if (!rule || !shouldApply(rule)) return null;
150
+ return toOutcome(rule);
151
+ }
152
+ export { toOutcome };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Network response overrides — the rule model.
5
+ *
6
+ * This is Buoy's answer to Chrome DevTools' "Local Overrides", reshaped for
7
+ * mobile. Two deliberate differences from Chrome are worth knowing, because
8
+ * they explain the whole shape of this file:
9
+ *
10
+ * 1. Chrome's rules ARE a directory tree — a body override is a file at
11
+ * `<host>/<path>`, header overrides live in per-directory `.headers` files,
12
+ * and its general→specific header cascade exists only to hang off that
13
+ * nesting. We have no filesystem, so rules are a flat ordered list and the
14
+ * FIRST enabled match wins outright. That's what Charles/Proxyman/MSW do,
15
+ * and it's what a developer can reason about on a phone screen.
16
+ *
17
+ * 2. Chrome CANNOT change a status code. `NetworkManager.ts` computes
18
+ * `responseCode = isBodyOverridden ? 200 : (responseStatusCode || 200)`, so
19
+ * overriding a body forces 200, and there is no latency or failure
20
+ * simulation at all. Chrome's feature is "serve different bytes". Forcing a
21
+ * 500, a timeout or a 3s stall is the entire mobile use case, so `status`,
22
+ * `delayMs` and `kind: "fail"` below are the point of the exercise.
23
+ */
24
+
25
+ /**
26
+ * What a rule does when it matches.
27
+ *
28
+ * - `respond` — synthesize a response. The request never leaves the device.
29
+ * - `fail` — synthesize a transport failure (offline / timeout). Also never
30
+ * leaves the device.
31
+ * - `delay` — let the real request run, just late. Pure latency injection.
32
+ */
33
+
34
+ /**
35
+ * Which transport failure to fake.
36
+ *
37
+ * `network` surfaces the way a real offline request does — `fetch` rejects with
38
+ * `TypeError: Network request failed`. This is also how you simulate "status 0":
39
+ * you can't, via `respond`, because whatwg-fetch's `Response` constructor throws
40
+ * a RangeError outside 200–599 (see STATUS_MIN/STATUS_MAX).
41
+ */
42
+
43
+ /**
44
+ * Status codes a synthesized response may use.
45
+ *
46
+ * NOT an arbitrary product decision: React Native's `fetch` is the whatwg-fetch
47
+ * polyfill, whose `Response` constructor throws
48
+ * `RangeError: The status provided is outside the range [200, 599]`. A rule set
49
+ * to 0 would therefore crash the app's own fetch call rather than override it.
50
+ * Use `kind: "fail"` for connection-level failures.
51
+ */
52
+ export const STATUS_MIN = 200;
53
+ export const STATUS_MAX = 599;
54
+
55
+ /** Bounds the persisted list so a runaway UI can't fill storage. */
56
+ export const MAX_RULES = 50;
57
+
58
+ /** Free tier gets one active rule; Pro is unlimited (up to MAX_RULES). */
59
+ export const FREE_TIER_RULE_LIMIT = 1;
60
+
61
+ /**
62
+ * Stamped onto the `NetworkEvent` a rule affected, so the list can badge the
63
+ * row and the detail view can offer "edit / disable the rule that did this".
64
+ */
65
+
66
+ /**
67
+ * A matched rule, normalized into exactly what the interceptor needs — defaults
68
+ * resolved, status clamped, content-type inferred. Keeping normalization in the
69
+ * resolver means `applyToXhr` never has to make a decision.
70
+ */
71
+
72
+ /** The marker recorded for an outcome. */
73
+ export function markForOutcome(outcome) {
74
+ return {
75
+ ruleId: outcome.rule.id,
76
+ ruleName: outcome.rule.name,
77
+ kind: outcome.kind,
78
+ delayMs: outcome.delayMs || undefined
79
+ };
80
+ }
@@ -2,6 +2,10 @@
2
2
 
3
3
  import { networkEventStore } from "../utils/networkEventStore";
4
4
  import { flagsForEventId, networkSavedStore } from "../utils/networkSavedStore";
5
+ import { overrideRulesStore } from "../overrides/overrideRulesStore";
6
+ import { draftFromEvent } from "../overrides/presets";
7
+ import { inspectEngine } from "../overrides/engine";
8
+ import { getNetworkListenerCount, isNetworkListening } from "../utils/networkListener";
5
9
  /**
6
10
  * Request/response bodies larger than this (using the cached size proxy already
7
11
  * on the event — no per-snapshot stringify) are NOT streamed in the list
@@ -27,9 +31,116 @@ const stripLargeBodies = event => {
27
31
  if (stripResponse) stripped.responseData = undefined;
28
32
  return stripped;
29
33
  };
34
+
35
+ /**
36
+ * Drop oversized rule bodies from the snapshot, flagging what was dropped.
37
+ *
38
+ * Same limit and same reasoning as `stripLargeBodies` above — and the same
39
+ * hazard, only worse: an override body is authored once and then rides EVERY
40
+ * snapshot for as long as the rule exists, whereas an event ages out of the
41
+ * list. `draftFromEvent` seeds a rule with the real response, so 500KB is an
42
+ * ordinary size here, not a pathological one.
43
+ */
44
+ const stripLargeRuleBodies = state => {
45
+ let changed = false;
46
+ const rules = state.rules.map(rule => {
47
+ if (!rule.body || rule.body.length <= SNAPSHOT_BODY_INLINE_LIMIT) {
48
+ return rule;
49
+ }
50
+ changed = true;
51
+ return {
52
+ ...rule,
53
+ body: undefined,
54
+ bodyOmitted: true
55
+ };
56
+ });
57
+ // Identity is preserved when nothing was stripped: the desktop mirror feeds
58
+ // this straight into a `useEffect` dependency, and a fresh object every
59
+ // snapshot would re-run it several times a second.
60
+ return changed ? {
61
+ ...state,
62
+ rules
63
+ } : state;
64
+ };
30
65
  const resolveEvent = id => networkEventStore.getEventById(id) ?? networkSavedStore.getEventById(id);
31
66
  const readId = params => params && typeof params === "object" ? params.id : undefined;
32
67
  const readBool = (params, key) => !!(params && typeof params === "object" && params[key]);
68
+ const VALID_KINDS = ["respond", "fail", "delay"];
69
+
70
+ /**
71
+ * Turn an override rule sent over the wire into a draft the store will accept.
72
+ *
73
+ * Validated rather than trusted: these params arrive from the dashboard and
74
+ * from MCP clients, and a malformed rule doesn't just fail — it gets PERSISTED
75
+ * and then re-applied to real traffic on every launch. `urlPattern` is the one
76
+ * field with no sane default, so its absence rejects the whole call.
77
+ */
78
+ function readRuleDraft(params,
79
+ /**
80
+ * Prefill from a captured request, as the UI's Override button does. Any
81
+ * field the caller sent explicitly still wins over it.
82
+ */
83
+ base) {
84
+ if (!params || typeof params !== "object") return null;
85
+ const raw = params.rule ?? params;
86
+ if (!raw || typeof raw !== "object") return null;
87
+ const rule = raw;
88
+ const urlPattern = typeof rule.urlPattern === "string" && rule.urlPattern.trim() ? rule.urlPattern.trim() : base?.urlPattern ?? "";
89
+ if (!urlPattern) return null;
90
+ const kind = VALID_KINDS.includes(rule.kind) ? rule.kind : base?.kind ?? "respond";
91
+ return {
92
+ id: typeof rule.id === "string" ? rule.id : undefined,
93
+ enabled: rule.enabled !== false,
94
+ name: typeof rule.name === "string" ? rule.name : base?.name,
95
+ urlPattern,
96
+ methods: Array.isArray(rule.methods) ? rule.methods.filter(m => typeof m === "string").map(m => m.toUpperCase()) : base?.methods,
97
+ kind,
98
+ status: typeof rule.status === "number" ? rule.status : base?.status,
99
+ statusText: typeof rule.statusText === "string" ? rule.statusText : base?.statusText,
100
+ headers: rule.headers && typeof rule.headers === "object" ? rule.headers : base?.headers,
101
+ // Objects are accepted and serialized so a caller can send JSON directly
102
+ // instead of pre-stringifying it, which is the mistake everyone makes once.
103
+ body: typeof rule.body === "string" ? rule.body : rule.body != null ? JSON.stringify(rule.body, null, 2) : base?.body,
104
+ failKind: rule.failKind === "timeout" ? "timeout" : undefined,
105
+ delayMs: typeof rule.delayMs === "number" ? rule.delayMs : undefined,
106
+ times: typeof rule.times === "number" ? rule.times : null,
107
+ alternate: rule.alternate === true
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The body a remote surface never received, when it says so.
113
+ *
114
+ * A dashboard editing a rule whose body was stripped from the snapshot has
115
+ * nothing to send back for it. Without this the save would write `body:
116
+ * undefined` and silently destroy the payload — the one field a rule exists for.
117
+ */
118
+ function preservedBody(params) {
119
+ if (!params || typeof params !== "object") return null;
120
+ const raw = params.rule ?? params;
121
+ if (!raw || typeof raw !== "object") return null;
122
+ const rule = raw;
123
+ if (rule.bodyOmitted !== true || typeof rule.id !== "string") return null;
124
+ const existing = overrideRulesStore.getState().rules.find(candidate => candidate.id === rule.id);
125
+ return existing ? {
126
+ body: existing.body
127
+ } : null;
128
+ }
129
+
130
+ /**
131
+ * Resolve `fromRequestId` into the same draft the UI's Override button builds.
132
+ *
133
+ * Worth routing through `draftFromEvent` rather than letting a caller paste the
134
+ * URL: it drops the query string to `*`, which is the difference between a rule
135
+ * that fires and one that sits at 0 hits against a cache-busting client.
136
+ */
137
+ function baseFromRequest(params) {
138
+ if (!params || typeof params !== "object") return null;
139
+ const id = params.fromRequestId;
140
+ if (typeof id !== "string" || !id) return null;
141
+ const event = resolveEvent(id);
142
+ return event ? draftFromEvent(event) : null;
143
+ }
33
144
 
34
145
  /**
35
146
  * Sync adapter for the network tool, consumed by @buoy-gg/external-sync's
@@ -49,9 +160,15 @@ const readBool = (params, key) => !!(params && typeof params === "object" && par
49
160
  * `NetworkPanel`/`NetworkInline` accept either shape.
50
161
  */
51
162
  export const networkSyncAdapter = {
52
- version: 3,
163
+ version: 4,
53
164
  getSnapshot: () => ({
54
165
  events: networkEventStore.getEvents().map(stripLargeBodies),
166
+ /**
167
+ * Response-override rules live on the DEVICE — that's the only place they
168
+ * can run, since a rule is applied inside the app's own XMLHttpRequest.
169
+ * Remote surfaces mirror this and forward their edits back as actions.
170
+ */
171
+ overrides: stripLargeRuleBodies(overrideRulesStore.getState()),
55
172
  // Saved snapshots go through the same body strip. They are few (capped in
56
173
  // the store) but they ride on EVERY snapshot, so a fat one would land in
57
174
  // the same 200ms loop that the body-stripping exists to protect.
@@ -65,9 +182,13 @@ export const networkSyncAdapter = {
65
182
  // both stores drive the snapshot.
66
183
  const unsubscribeEvents = networkEventStore.subscribeToEvents(onChange);
67
184
  const unsubscribeSaved = networkSavedStore.subscribe(onChange);
185
+ // Same reasoning for rules: editing one with no traffic in flight must
186
+ // still reach the dashboard. Subscribing also hydrates the rule list.
187
+ const unsubscribeOverrides = overrideRulesStore.subscribe(onChange);
68
188
  return () => {
69
189
  unsubscribeEvents();
70
190
  unsubscribeSaved();
191
+ unsubscribeOverrides();
71
192
  };
72
193
  },
73
194
  actions: {
@@ -138,6 +259,114 @@ export const networkSyncAdapter = {
138
259
  return {
139
260
  ok: true
140
261
  };
262
+ },
263
+ // =========================================================================
264
+ // RESPONSE OVERRIDES
265
+ // =========================================================================
266
+
267
+ /** Master switch — turns every rule off without losing any of them. */
268
+ setOverridesEnabled: params => {
269
+ overrideRulesStore.setEnabled(readBool(params, "enabled"));
270
+ return {
271
+ ok: true,
272
+ enabled: overrideRulesStore.getState().enabled
273
+ };
274
+ },
275
+ /** Create a rule, or replace one by id. */
276
+ upsertOverrideRule: params => {
277
+ const base = baseFromRequest(params) ?? preservedBody(params);
278
+ const draft = readRuleDraft(params, base);
279
+ if (!draft) {
280
+ const wanted = params?.fromRequestId;
281
+ return {
282
+ ok: false,
283
+ error: wanted ? `No captured request \`${wanted}\` — it may have been cleared. Send a urlPattern instead.` : "A rule needs a non-empty urlPattern."
284
+ };
285
+ }
286
+ const rule = overrideRulesStore.upsertRule(draft);
287
+ if (!rule) return {
288
+ ok: false,
289
+ error: "Override rule limit reached."
290
+ };
291
+ // A rule pushed from a remote surface is meant to take effect now;
292
+ // leaving it dark under an OFF master switch reads as a silent failure.
293
+ if (rule.enabled) overrideRulesStore.setEnabled(true);
294
+ // A RECEIPT, not the rule. `fromRequestId` seeds the real response as the
295
+ // body, so echoing the whole rule sent 380KB back down the socket for a
296
+ // Pokémon payload — and no caller reads it: the desktop mirror gets the
297
+ // rule from the next snapshot, and MCP only needs the id to report and
298
+ // remove it.
299
+ return {
300
+ ok: true,
301
+ rule: {
302
+ id: rule.id,
303
+ urlPattern: rule.urlPattern,
304
+ enabled: rule.enabled,
305
+ kind: rule.kind,
306
+ status: rule.status,
307
+ bodySize: rule.body ? rule.body.length : 0
308
+ }
309
+ };
310
+ },
311
+ setOverrideRuleEnabled: params => {
312
+ const id = readId(params);
313
+ if (!id) return {
314
+ ok: false,
315
+ error: "Missing rule id."
316
+ };
317
+ overrideRulesStore.setRuleEnabled(id, readBool(params, "enabled"));
318
+ return {
319
+ ok: true
320
+ };
321
+ },
322
+ deleteOverrideRule: params => {
323
+ const id = readId(params);
324
+ if (!id) return {
325
+ ok: false,
326
+ error: "Missing rule id."
327
+ };
328
+ overrideRulesStore.removeRule(id);
329
+ return {
330
+ ok: true
331
+ };
332
+ },
333
+ clearOverrideRules: () => {
334
+ overrideRulesStore.clearRules();
335
+ return {
336
+ ok: true
337
+ };
338
+ },
339
+ /** Read the rules back — hit counts included, which the snapshot coalesces. */
340
+ listOverrideRules: () => overrideRulesStore.getState(),
341
+ /**
342
+ * The full body of one rule, for the surfaces the snapshot withheld it from
343
+ * (see `stripLargeRuleBodies`). Fetched when the user opens the rule, which
344
+ * is the only moment it's needed.
345
+ */
346
+ getOverrideRuleBody: params => {
347
+ const id = readId(params);
348
+ if (!id) return null;
349
+ // `getState().rules`, not `getRules()` — the latter reports nothing while
350
+ // the master switch is off, and a rule is perfectly editable then.
351
+ const rule = overrideRulesStore.getState().rules.find(candidate => candidate.id === id);
352
+ if (!rule) return null;
353
+ return {
354
+ body: rule.body ?? null
355
+ };
356
+ },
357
+ /** Why isn't my rule firing? Reports the engine's own view of the world. */
358
+ debugOverrides: params => {
359
+ const url = (params && typeof params === "object" ? params.url : undefined) ?? "https://example.com/";
360
+ return {
361
+ interceptorInstalled: isNetworkListening(),
362
+ listenerCount: getNetworkListenerCount(),
363
+ engine: inspectEngine(url, "GET"),
364
+ store: {
365
+ enabled: overrideRulesStore.getState().enabled,
366
+ ruleCount: overrideRulesStore.getState().rules.length,
367
+ rulesVisibleToEngine: overrideRulesStore.getRules().length
368
+ }
369
+ };
141
370
  }
142
371
  }
143
372
  };
@@ -1 +1,3 @@
1
- "use strict";
1
+ "use strict";
2
+
3
+ export {};
@@ -36,6 +36,13 @@ import { networkSavedStore } from "./networkSavedStore";
36
36
 
37
37
  class NetworkEventStore extends BaseEventStore {
38
38
  pendingRequests = new Map();
39
+ /**
40
+ * Recently seen `METHOD:url` keys → when, and which event id we kept.
41
+ *
42
+ * The timestamp powers the dual-interception dedup below. The id is what lets
43
+ * a DROPPED duplicate still contribute something the kept event doesn't know
44
+ * — see `applyOverrideMark`.
45
+ */
39
46
  recentRequests = new Map();
40
47
  rawListenerUnsubscribe = null;
41
48
  constructor() {
@@ -99,19 +106,31 @@ class NetworkEventStore extends BaseEventStore {
99
106
  // Check for duplicate request based on URL, method, and timing
100
107
  const requestKey = `${request.method}:${request.url}`;
101
108
  const now = Date.now();
102
- const lastRequestTime = this.recentRequests.get(requestKey);
109
+ const recent = this.recentRequests.get(requestKey);
103
110
 
104
111
  // If same request within 50ms, likely a duplicate from XHR/fetch dual interception
105
- if (lastRequestTime && now - lastRequestTime < 50) {
112
+ if (recent && now - recent.at < 50) {
113
+ // …but the duplicate is not always redundant. On React Native a
114
+ // `fetch()` is seen twice: once by the fetch wrapper, which is what
115
+ // creates this event, and again by the XHR wrapper underneath it —
116
+ // and ONLY the XHR layer resolves overrides, because that's where the
117
+ // request can actually be intercepted. So the mark arrives on the copy
118
+ // we're about to throw away. Move it across before dropping.
119
+ if (event.override) {
120
+ this.applyOverrideMark(recent.id, event.override);
121
+ }
106
122
  return;
107
123
  }
108
- this.recentRequests.set(requestKey, now);
124
+ this.recentRequests.set(requestKey, {
125
+ at: now,
126
+ id: request.id
127
+ });
109
128
 
110
129
  // Clean up old entries to prevent memory leak
111
130
  if (this.recentRequests.size > 100) {
112
131
  const cutoff = now - 5000;
113
- for (const [key, time] of this.recentRequests.entries()) {
114
- if (time < cutoff) {
132
+ for (const [key, entry] of this.recentRequests.entries()) {
133
+ if (entry.at < cutoff) {
115
134
  this.recentRequests.delete(key);
116
135
  }
117
136
  }
@@ -145,7 +164,10 @@ class NetworkEventStore extends BaseEventStore {
145
164
  responseHeaders: {},
146
165
  requestClient: request.client,
147
166
  operationName,
148
- graphqlVariables
167
+ graphqlVariables,
168
+ // Present when this request came straight through the XHR wrapper
169
+ // (axios, raw XHR). For `fetch` it arrives later — see the dedup branch.
170
+ override: event.override
149
171
  };
150
172
  this.pendingRequests.set(request.id, networkEvent);
151
173
  this.events = [networkEvent, ...this.events].slice(0, this.maxEvents);
@@ -174,6 +196,10 @@ class NetworkEventStore extends BaseEventStore {
174
196
  updatedEvent.error = event.error.message;
175
197
  updatedEvent.status = updatedEvent.status || 0;
176
198
  }
199
+
200
+ // A pure-XHR request carries its mark all the way through; a `fetch`
201
+ // one was marked at request time and is preserved by the spread above.
202
+ if (event.override) updatedEvent.override = event.override;
177
203
  this.events = this.events.map((e, i) => i === index ? updatedEvent : e);
178
204
  this.pendingRequests.delete(request.id);
179
205
  // Refresh any pinned/saved snapshot of this request — a request pinned
@@ -185,6 +211,26 @@ class NetworkEventStore extends BaseEventStore {
185
211
  }
186
212
  }
187
213
  }
214
+
215
+ /**
216
+ * Stamp an override mark onto an already-recorded event.
217
+ *
218
+ * Only ever moves a mark from a dropped duplicate onto the event that was
219
+ * kept, so it never overwrites one that is already there.
220
+ */
221
+ applyOverrideMark(id, override) {
222
+ const index = this.events.findIndex(e => e.id === id);
223
+ if (index === -1 || this.events[index].override) return;
224
+ const updatedEvent = {
225
+ ...this.events[index],
226
+ override
227
+ };
228
+ this.events = this.events.map((e, i) => i === index ? updatedEvent : e);
229
+ const pending = this.pendingRequests.get(id);
230
+ if (pending) this.pendingRequests.set(id, updatedEvent);
231
+ this.notify(updatedEvent);
232
+ this.notifyArrayListeners();
233
+ }
188
234
  extractHost(url) {
189
235
  try {
190
236
  const urlObj = new URL(url);