@buoy-gg/network 7.0.13 → 7.0.15
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/lib/commonjs/index.js +39 -0
- package/lib/commonjs/network/components/NetworkEventDetailView.js +30 -3
- package/lib/commonjs/network/components/NetworkEventItemCompact.js +4 -0
- package/lib/commonjs/network/components/NetworkHeaderLive.js +26 -6
- package/lib/commonjs/network/components/NetworkHeaderMenu.js +183 -0
- package/lib/commonjs/network/components/NetworkListScreen.js +126 -5
- package/lib/commonjs/network/components/NetworkModal.js +136 -29
- package/lib/commonjs/network/components/NetworkNumberPrompt.js +178 -0
- package/lib/commonjs/network/components/NetworkOverrideEditor.js +797 -0
- package/lib/commonjs/network/components/NetworkOverrideHeaderButton.js +102 -0
- package/lib/commonjs/network/components/NetworkOverridesButton.js +88 -0
- package/lib/commonjs/network/components/NetworkOverridesScreen.js +489 -0
- package/lib/commonjs/network/components/response-editor/Explorer.js +973 -0
- package/lib/commonjs/network/components/response-editor/deleteNestedDataByPath.js +56 -0
- package/lib/commonjs/network/components/response-editor/editorContext.js +33 -0
- package/lib/commonjs/network/components/response-editor/updateNestedDataByPath.js +61 -0
- package/lib/commonjs/network/hooks/useOverrideRules.js +36 -0
- package/lib/commonjs/network/overrides/applyToFetch.js +80 -0
- package/lib/commonjs/network/overrides/applyToXhr.js +190 -0
- package/lib/commonjs/network/overrides/engine.js +70 -0
- package/lib/commonjs/network/overrides/matchRule.js +81 -0
- package/lib/commonjs/network/overrides/overrideRulesStore.js +550 -0
- package/lib/commonjs/network/overrides/presets.js +70 -0
- package/lib/commonjs/network/overrides/resolveOverride.js +158 -0
- package/lib/commonjs/network/overrides/types.js +85 -0
- package/lib/commonjs/network/sync/networkSyncAdapter.js +230 -1
- package/lib/commonjs/network/types/index.js +5 -1
- package/lib/commonjs/network/utils/networkEventStore.js +52 -6
- package/lib/commonjs/network/utils/networkListener.js +90 -10
- package/lib/commonjs/preset.js +11 -0
- package/lib/module/index.js +15 -0
- package/lib/module/network/components/NetworkEventDetailView.js +31 -4
- package/lib/module/network/components/NetworkEventItemCompact.js +5 -1
- package/lib/module/network/components/NetworkHeaderLive.js +26 -6
- package/lib/module/network/components/NetworkHeaderMenu.js +177 -0
- package/lib/module/network/components/NetworkListScreen.js +127 -6
- package/lib/module/network/components/NetworkModal.js +138 -31
- package/lib/module/network/components/NetworkNumberPrompt.js +174 -0
- package/lib/module/network/components/NetworkOverrideEditor.js +794 -0
- package/lib/module/network/components/NetworkOverrideHeaderButton.js +99 -0
- package/lib/module/network/components/NetworkOverridesButton.js +85 -0
- package/lib/module/network/components/NetworkOverridesScreen.js +485 -0
- package/lib/module/network/components/response-editor/Explorer.js +972 -0
- package/lib/module/network/components/response-editor/deleteNestedDataByPath.js +51 -0
- package/lib/module/network/components/response-editor/editorContext.js +29 -0
- package/lib/module/network/components/response-editor/updateNestedDataByPath.js +56 -0
- package/lib/module/network/hooks/useOverrideRules.js +32 -0
- package/lib/module/network/overrides/applyToFetch.js +72 -0
- package/lib/module/network/overrides/applyToXhr.js +185 -0
- package/lib/module/network/overrides/engine.js +64 -0
- package/lib/module/network/overrides/matchRule.js +75 -0
- package/lib/module/network/overrides/overrideRulesStore.js +547 -0
- package/lib/module/network/overrides/presets.js +65 -0
- package/lib/module/network/overrides/resolveOverride.js +152 -0
- package/lib/module/network/overrides/types.js +80 -0
- package/lib/module/network/sync/networkSyncAdapter.js +230 -1
- package/lib/module/network/types/index.js +3 -1
- package/lib/module/network/utils/networkEventStore.js +52 -6
- package/lib/module/network/utils/networkListener.js +91 -10
- package/lib/module/preset.js +13 -1
- package/lib/typescript/index.d.ts +8 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkEventItemCompact.d.ts +6 -0
- package/lib/typescript/network/components/NetworkEventItemCompact.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkHeaderLive.d.ts +8 -1
- package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkHeaderMenu.d.ts +46 -0
- package/lib/typescript/network/components/NetworkHeaderMenu.d.ts.map +1 -0
- package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkNumberPrompt.d.ts +33 -0
- package/lib/typescript/network/components/NetworkNumberPrompt.d.ts.map +1 -0
- package/lib/typescript/network/components/NetworkOverrideEditor.d.ts +17 -0
- package/lib/typescript/network/components/NetworkOverrideEditor.d.ts.map +1 -0
- package/lib/typescript/network/components/NetworkOverrideHeaderButton.d.ts +27 -0
- package/lib/typescript/network/components/NetworkOverrideHeaderButton.d.ts.map +1 -0
- package/lib/typescript/network/components/NetworkOverridesButton.d.ts +17 -0
- package/lib/typescript/network/components/NetworkOverridesButton.d.ts.map +1 -0
- package/lib/typescript/network/components/NetworkOverridesScreen.d.ts +29 -0
- package/lib/typescript/network/components/NetworkOverridesScreen.d.ts.map +1 -0
- package/lib/typescript/network/components/response-editor/Explorer.d.ts +17 -0
- package/lib/typescript/network/components/response-editor/Explorer.d.ts.map +1 -0
- package/lib/typescript/network/components/response-editor/deleteNestedDataByPath.d.ts +9 -0
- package/lib/typescript/network/components/response-editor/deleteNestedDataByPath.d.ts.map +1 -0
- package/lib/typescript/network/components/response-editor/editorContext.d.ts +29 -0
- package/lib/typescript/network/components/response-editor/editorContext.d.ts.map +1 -0
- package/lib/typescript/network/components/response-editor/updateNestedDataByPath.d.ts +10 -0
- package/lib/typescript/network/components/response-editor/updateNestedDataByPath.d.ts.map +1 -0
- package/lib/typescript/network/hooks/useOverrideRules.d.ts +29 -0
- package/lib/typescript/network/hooks/useOverrideRules.d.ts.map +1 -0
- package/lib/typescript/network/overrides/__tests__/applyToFetch.test.d.ts +16 -0
- package/lib/typescript/network/overrides/__tests__/applyToFetch.test.d.ts.map +1 -0
- package/lib/typescript/network/overrides/__tests__/applyToXhr.test.d.ts +19 -0
- package/lib/typescript/network/overrides/__tests__/applyToXhr.test.d.ts.map +1 -0
- package/lib/typescript/network/overrides/__tests__/resolveOverride.test.d.ts +22 -0
- package/lib/typescript/network/overrides/__tests__/resolveOverride.test.d.ts.map +1 -0
- package/lib/typescript/network/overrides/applyToFetch.d.ts +50 -0
- package/lib/typescript/network/overrides/applyToFetch.d.ts.map +1 -0
- package/lib/typescript/network/overrides/applyToXhr.d.ts +43 -0
- package/lib/typescript/network/overrides/applyToXhr.d.ts.map +1 -0
- package/lib/typescript/network/overrides/engine.d.ts +47 -0
- package/lib/typescript/network/overrides/engine.d.ts.map +1 -0
- package/lib/typescript/network/overrides/matchRule.d.ts +22 -0
- package/lib/typescript/network/overrides/matchRule.d.ts.map +1 -0
- package/lib/typescript/network/overrides/overrideRulesStore.d.ts +205 -0
- package/lib/typescript/network/overrides/overrideRulesStore.d.ts.map +1 -0
- package/lib/typescript/network/overrides/presets.d.ts +38 -0
- package/lib/typescript/network/overrides/presets.d.ts.map +1 -0
- package/lib/typescript/network/overrides/resolveOverride.d.ts +46 -0
- package/lib/typescript/network/overrides/resolveOverride.d.ts.map +1 -0
- package/lib/typescript/network/overrides/types.d.ts +169 -0
- package/lib/typescript/network/overrides/types.d.ts.map +1 -0
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts +73 -0
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/network/types/index.d.ts +10 -0
- package/lib/typescript/network/types/index.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkEventStore.d.ts +14 -0
- package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkListener.d.ts +3 -0
- package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
- package/lib/typescript/preset.d.ts.map +1 -1
- 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:
|
|
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
|
};
|
|
@@ -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
|
|
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 (
|
|
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,
|
|
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,
|
|
114
|
-
if (
|
|
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);
|