@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.
- package/README.md +45 -12
- package/lib/commonjs/index.js +26 -1
- package/lib/commonjs/index.web.js +26 -1
- package/lib/commonjs/network/components/NetworkEventDetailView.js +6 -1
- package/lib/commonjs/network/components/NetworkHeaderMenu.js +4 -0
- package/lib/commonjs/network/components/NetworkModal.js +24 -8
- package/lib/commonjs/network/conditions/NetworkConditionsControl.js +235 -0
- package/lib/commonjs/network/conditions/NetworkConditionsControl.test.js +136 -0
- package/lib/commonjs/network/conditions/NetworkConditionsIcon.js +33 -0
- package/lib/commonjs/network/conditions/NetworkFloatingTool.js +310 -0
- package/lib/commonjs/network/conditions/NetworkFloatingTool.test.js +219 -0
- package/lib/commonjs/network/conditions/store.js +75 -0
- package/lib/commonjs/network/conditions/wait.js +43 -0
- package/lib/commonjs/network/conditions/xhr.js +110 -0
- package/lib/commonjs/network/hooks/useNetworkEvents.js +1 -1
- package/lib/commonjs/network/overrides/applyToXhr.js +10 -8
- package/lib/commonjs/network/overrides/engine.js +2 -1
- package/lib/commonjs/network/sync/networkSyncAdapter.js +224 -3
- package/lib/commonjs/network/utils/generateNetworkCopyText.js +2 -0
- package/lib/commonjs/network/utils/networkEventStore.js +5 -77
- package/lib/commonjs/network/utils/networkListener.js +245 -46
- package/lib/commonjs/network/utils/networkSavedStore.js +2 -2
- package/lib/commonjs/network/utils/selectSavedEvents.js +2 -2
- package/lib/commonjs/preset.js +2 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.web.js +2 -1
- package/lib/module/network/components/NetworkEventDetailView.js +6 -1
- package/lib/module/network/components/NetworkHeaderMenu.js +4 -0
- package/lib/module/network/components/NetworkModal.js +25 -9
- package/lib/module/network/conditions/NetworkConditionsControl.js +231 -0
- package/lib/module/network/conditions/NetworkConditionsControl.test.js +135 -0
- package/lib/module/network/conditions/NetworkConditionsIcon.js +29 -0
- package/lib/module/network/conditions/NetworkFloatingTool.js +304 -0
- package/lib/module/network/conditions/NetworkFloatingTool.test.js +218 -0
- package/lib/module/network/conditions/store.js +65 -0
- package/lib/module/network/conditions/wait.js +38 -0
- package/lib/module/network/conditions/xhr.js +106 -0
- package/lib/module/network/hooks/useNetworkEvents.js +1 -1
- package/lib/module/network/overrides/applyToXhr.js +9 -8
- package/lib/module/network/overrides/engine.js +2 -1
- package/lib/module/network/sync/networkSyncAdapter.js +225 -3
- package/lib/module/network/utils/generateNetworkCopyText.js +2 -0
- package/lib/module/network/utils/networkEventStore.js +6 -77
- package/lib/module/network/utils/networkListener.js +246 -47
- package/lib/module/network/utils/networkSavedStore.js +2 -2
- package/lib/module/network/utils/selectSavedEvents.js +2 -2
- package/lib/module/preset.js +3 -2
- package/lib/typescript/index.d.ts +2 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +2 -0
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkHeaderMenu.d.ts +2 -1
- package/lib/typescript/network/components/NetworkHeaderMenu.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkModal.d.ts +3 -0
- package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
- package/lib/typescript/network/conditions/NetworkConditionsControl.d.ts +12 -0
- package/lib/typescript/network/conditions/NetworkConditionsControl.d.ts.map +1 -0
- package/lib/typescript/network/conditions/NetworkConditionsControl.test.d.ts +2 -0
- package/lib/typescript/network/conditions/NetworkConditionsControl.test.d.ts.map +1 -0
- package/lib/typescript/network/conditions/NetworkConditionsIcon.d.ts +4 -0
- package/lib/typescript/network/conditions/NetworkConditionsIcon.d.ts.map +1 -0
- package/lib/typescript/network/conditions/NetworkFloatingTool.d.ts +15 -0
- package/lib/typescript/network/conditions/NetworkFloatingTool.d.ts.map +1 -0
- package/lib/typescript/network/conditions/NetworkFloatingTool.test.d.ts +2 -0
- package/lib/typescript/network/conditions/NetworkFloatingTool.test.d.ts.map +1 -0
- package/lib/typescript/network/conditions/store.d.ts +30 -0
- package/lib/typescript/network/conditions/store.d.ts.map +1 -0
- package/lib/typescript/network/conditions/wait.d.ts +4 -0
- package/lib/typescript/network/conditions/wait.d.ts.map +1 -0
- package/lib/typescript/network/conditions/xhr.d.ts +5 -0
- package/lib/typescript/network/conditions/xhr.d.ts.map +1 -0
- package/lib/typescript/network/hooks/useNetworkEvents.d.ts +1 -1
- package/lib/typescript/network/overrides/applyToXhr.d.ts +2 -0
- package/lib/typescript/network/overrides/applyToXhr.d.ts.map +1 -1
- package/lib/typescript/network/overrides/engine.d.ts +0 -10
- package/lib/typescript/network/overrides/engine.d.ts.map +1 -1
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts +29 -0
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/network/types/index.d.ts +1 -0
- package/lib/typescript/network/types/index.d.ts.map +1 -1
- package/lib/typescript/network/utils/generateNetworkCopyText.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkEventStore.d.ts +0 -15
- package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkListener.d.ts +13 -28
- package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkSavedStore.d.ts +2 -2
- package/lib/typescript/network/utils/selectSavedEvents.d.ts +2 -2
- package/lib/typescript/preset.d.ts +0 -16
- package/lib/typescript/preset.d.ts.map +1 -1
- 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
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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:
|
|
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:
|
|
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
|
/**
|