@buoy-gg/network 7.0.35 → 7.0.36
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/network/sync/networkSyncAdapter.js +198 -2
- package/lib/commonjs/network/utils/networkListener.js +156 -2
- package/lib/module/network/sync/networkSyncAdapter.js +199 -2
- package/lib/module/network/utils/networkListener.js +156 -2
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts +12 -0
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkListener.d.ts +11 -27
- package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -311,13 +311,116 @@ base) {
|
|
|
311
311
|
headers: rule.headers && typeof rule.headers === "object" ? rule.headers : base?.headers,
|
|
312
312
|
// Objects are accepted and serialized so a caller can send JSON directly
|
|
313
313
|
// instead of pre-stringifying it, which is the mistake everyone makes once.
|
|
314
|
-
body:
|
|
314
|
+
body: patchedBody(rule, base),
|
|
315
315
|
failKind: rule.failKind === "timeout" ? "timeout" : undefined,
|
|
316
316
|
delayMs: typeof rule.delayMs === "number" ? rule.delayMs : undefined,
|
|
317
317
|
times: typeof rule.times === "number" ? rule.times : null,
|
|
318
318
|
alternate: rule.alternate === true
|
|
319
319
|
};
|
|
320
320
|
}
|
|
321
|
+
const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
|
|
322
|
+
/**
|
|
323
|
+
* Objects merge key by key; arrays and primitives are replaced. Collects two
|
|
324
|
+
* dangers instead of writing them: `shrunk` (a non-empty array shorter than
|
|
325
|
+
* the one it replaces) and `breaks` (an array/object field overwritten by null
|
|
326
|
+
* or a primitive — the shape the app renders, changed out from under it). The
|
|
327
|
+
* handler refuses either unless forced.
|
|
328
|
+
*
|
|
329
|
+
* This stays local while the typed-edit RULE is shared: it is the merge itself,
|
|
330
|
+
* not the check, and it reports network-specific dangers the store surfaces do
|
|
331
|
+
* not have. `shapeKind` comes from the shared module so both agree on what
|
|
332
|
+
* counts as an array.
|
|
333
|
+
*/
|
|
334
|
+
function deepMerge(base, patch, path = "", issues = {
|
|
335
|
+
shrunk: [],
|
|
336
|
+
breaks: []
|
|
337
|
+
}) {
|
|
338
|
+
if (Array.isArray(base) && Array.isArray(patch)) {
|
|
339
|
+
if (patch.length > 0 && patch.length < base.length) {
|
|
340
|
+
issues.shrunk.push({
|
|
341
|
+
path: path || "(root)",
|
|
342
|
+
have: base.length,
|
|
343
|
+
sent: patch.length
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return patch;
|
|
347
|
+
}
|
|
348
|
+
if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
|
|
349
|
+
const out = {
|
|
350
|
+
...base
|
|
351
|
+
};
|
|
352
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
353
|
+
const p = path ? `${path}.${k}` : k;
|
|
354
|
+
if (k in base) {
|
|
355
|
+
const from = (0, _sharedUi.shapeKind)(base[k]);
|
|
356
|
+
const to = (0, _sharedUi.shapeKind)(v);
|
|
357
|
+
if ((from === "array" || from === "object") && from !== to) {
|
|
358
|
+
issues.breaks.push({
|
|
359
|
+
path: p,
|
|
360
|
+
from,
|
|
361
|
+
to
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
out[k] = deepMerge(base[k], v, p, issues);
|
|
365
|
+
} else {
|
|
366
|
+
out[k] = v;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Typed-edit violations a bodyPatch would cause against the seed body, or []. */
|
|
373
|
+
function patchViolations(rule, seed) {
|
|
374
|
+
if (rule.body != null || !isPlainObject(rule.bodyPatch) || typeof seed !== "string") return [];
|
|
375
|
+
try {
|
|
376
|
+
return (0, _sharedUi.typedEditViolations)(JSON.parse(seed), rule.bodyPatch, {
|
|
377
|
+
noun: "response body"
|
|
378
|
+
});
|
|
379
|
+
} catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The rule's body: explicit `body` wins, else `bodyPatch` deep-merged into the
|
|
386
|
+
* seeded body (from `fromRequestId`, or the rule being replaced), else the seed.
|
|
387
|
+
*
|
|
388
|
+
* `bodyPatch` exists for the same reason `setQueryData` has `merge`: a remote
|
|
389
|
+
* caller reads the real response through a wire that truncates big payloads,
|
|
390
|
+
* edits one field, and pastes the torn object back as `body`. The app then
|
|
391
|
+
* renders a response missing half its fields. With a patch the caller sends
|
|
392
|
+
* `{ stats: [...] }` and the other 30KB stay exactly as the server sent them.
|
|
393
|
+
*/
|
|
394
|
+
function patchedBody(rule, base) {
|
|
395
|
+
if (typeof rule.body === "string") return rule.body;
|
|
396
|
+
if (rule.body != null) return JSON.stringify(rule.body, null, 2);
|
|
397
|
+
const seed = base?.body ?? (typeof rule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rule.id)?.body : undefined);
|
|
398
|
+
if (isPlainObject(rule.bodyPatch) && typeof seed === "string") {
|
|
399
|
+
try {
|
|
400
|
+
return JSON.stringify(deepMerge(JSON.parse(seed), rule.bodyPatch), null, 2);
|
|
401
|
+
} catch {
|
|
402
|
+
return seed;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return seed;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Replacing a rule by id inherits every field the caller did not send, so a
|
|
410
|
+
* remote driver can flip one thing — `{ id, bodyPatch }`, `{ id, status: 500 }`
|
|
411
|
+
* — without re-sending the urlPattern it never had to know.
|
|
412
|
+
*/
|
|
413
|
+
function existingRule(params) {
|
|
414
|
+
if (!params || typeof params !== "object") return null;
|
|
415
|
+
const raw = params.rule ?? params;
|
|
416
|
+
if (!raw || typeof raw !== "object") return null;
|
|
417
|
+
const id = raw.id;
|
|
418
|
+
if (typeof id !== "string") return null;
|
|
419
|
+
const existing = _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === id);
|
|
420
|
+
return existing ? {
|
|
421
|
+
...existing
|
|
422
|
+
} : null;
|
|
423
|
+
}
|
|
321
424
|
|
|
322
425
|
/**
|
|
323
426
|
* The body a remote surface never received, when it says so.
|
|
@@ -511,7 +614,89 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
|
|
|
511
614
|
},
|
|
512
615
|
/** Create a rule, or replace one by id. */
|
|
513
616
|
upsertOverrideRule: params => {
|
|
514
|
-
const base = baseFromRequest(params) ?? preservedBody(params);
|
|
617
|
+
const base = baseFromRequest(params) ?? preservedBody(params) ?? existingRule(params);
|
|
618
|
+
const rawRule = params?.rule ?? params;
|
|
619
|
+
// `bodyPath` + `bodyValue` are sugar over `bodyPatch`, resolved against the
|
|
620
|
+
// real response before anything else looks at the rule — so the keyed
|
|
621
|
+
// expansion, the typed-edit guard and the merge below all run on an
|
|
622
|
+
// ordinary patch and gain no special cases. This is the same form the
|
|
623
|
+
// state tools take; it exists here because a response body is the surface
|
|
624
|
+
// where the caller is LEAST likely to have seen the shape (it read the
|
|
625
|
+
// body over a wire that truncates at 24,000 characters), and therefore
|
|
626
|
+
// most likely to get the nesting wrong.
|
|
627
|
+
if (rawRule && typeof rawRule.bodyPath === "string" && rawRule.body == null) {
|
|
628
|
+
const seed = base?.body ?? (typeof rawRule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
|
|
629
|
+
if (typeof seed !== "string") {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
error: "bodyPath needs something to patch: send fromRequestId (the captured request whose real response it addresses) or the id of an existing rule."
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (rawRule.bodyValue === undefined) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
error: `Refused: sent \`bodyPath\` without \`bodyValue\`. Say what to set "${rawRule.bodyPath}" to.`
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
let parsed;
|
|
642
|
+
try {
|
|
643
|
+
parsed = JSON.parse(seed);
|
|
644
|
+
} catch {
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
error: "The captured response body is not JSON, so it cannot be addressed by path. Send a whole `body` instead."
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
const built = (0, _sharedUi.patchFromPath)(parsed, rawRule.bodyPath, rawRule.bodyValue);
|
|
651
|
+
if (!built.ok) return {
|
|
652
|
+
ok: false,
|
|
653
|
+
error: `Refused: ${built.error}`
|
|
654
|
+
};
|
|
655
|
+
rawRule.bodyPatch = built.patch;
|
|
656
|
+
delete rawRule.bodyPath;
|
|
657
|
+
delete rawRule.bodyValue;
|
|
658
|
+
}
|
|
659
|
+
if (rawRule && isPlainObject(rawRule.bodyPatch) && rawRule.body == null) {
|
|
660
|
+
const seed = base?.body ?? (typeof rawRule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
|
|
661
|
+
if (typeof seed !== "string") {
|
|
662
|
+
return {
|
|
663
|
+
ok: false,
|
|
664
|
+
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."
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
if (rawRule.force !== true) {
|
|
668
|
+
// Address list items in the real response by their own id rather than
|
|
669
|
+
// resending the array: {results:{pikachu:{name:"test123"}}}. Expanded
|
|
670
|
+
// against the seed BEFORE the guard and before the merge, so the
|
|
671
|
+
// typed-edit rule still holds over the result. This is the only form
|
|
672
|
+
// that is correct when the caller read the response through a wire
|
|
673
|
+
// that truncated it — see packages/shared/src/sync/keyedArrays.ts.
|
|
674
|
+
let keyedError;
|
|
675
|
+
try {
|
|
676
|
+
const expanded = (0, _sharedUi.expandKeyedArrays)(JSON.parse(seed), rawRule.bodyPatch);
|
|
677
|
+
keyedError = expanded.error;
|
|
678
|
+
if (expanded.notes.length) rawRule.bodyPatch = expanded.patch;
|
|
679
|
+
} catch {
|
|
680
|
+
/* a non-JSON seed has no lists to address; leave the patch alone */
|
|
681
|
+
}
|
|
682
|
+
// See keyedArrays.ts: an added item whose id disagrees with the key
|
|
683
|
+
// it was sent under is ambiguous, and only this layer can say so.
|
|
684
|
+
if (keyedError) {
|
|
685
|
+
return {
|
|
686
|
+
ok: false,
|
|
687
|
+
error: `Refused: ${keyedError}`
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
const violations = patchViolations(rawRule, seed);
|
|
691
|
+
if (violations.length > 0) {
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
error: (0, _sharedUi.refusalText)(violations, "response body", (0, _sharedUi.suggestedPatch)(rawRule.bodyPatch, violations)),
|
|
695
|
+
violations
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
515
700
|
const draft = readRuleDraft(params, base);
|
|
516
701
|
if (!draft) {
|
|
517
702
|
const wanted = params?.fromRequestId;
|
|
@@ -541,8 +726,19 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
|
|
|
541
726
|
enabled: rule.enabled,
|
|
542
727
|
kind: rule.kind,
|
|
543
728
|
status: rule.status,
|
|
729
|
+
times: rule.times,
|
|
544
730
|
bodySize: rule.body ? rule.body.length : 0
|
|
545
731
|
},
|
|
732
|
+
// A rule with no budget overrides EVERY matching request until someone
|
|
733
|
+
// deletes it, and that is rarely what a one-off ask meant. Measured on
|
|
734
|
+
// `api-sim/next-call-fails-once` ("just the next one, then let it work
|
|
735
|
+
// again"): ten of fourteen attempts left the budget off and got a rule
|
|
736
|
+
// that never expires. Saying so is not a nag — it is the difference
|
|
737
|
+
// between a caller that adds `times` on its next turn and one that walks
|
|
738
|
+
// away leaving the app permanently broken.
|
|
739
|
+
...(rule.times === undefined || rule.times === null ? {
|
|
740
|
+
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.`
|
|
741
|
+
} : {}),
|
|
546
742
|
...inactiveStamp()
|
|
547
743
|
};
|
|
548
744
|
},
|
|
@@ -71,6 +71,46 @@ const BUOY_XHR_INTERCEPTOR_FLAG = exports.BUOY_XHR_INTERCEPTOR_FLAG = "__buoyNet
|
|
|
71
71
|
* @performance Uses lazy singleton pattern to minimize memory footprint
|
|
72
72
|
* @performance Includes URL filtering to ignore development traffic
|
|
73
73
|
*/
|
|
74
|
+
/**
|
|
75
|
+
* Hosts another Buoy package has claimed as its own traffic.
|
|
76
|
+
*
|
|
77
|
+
* A GLOBAL, not an import: `@buoy-gg/network` cannot depend on `agent-core`
|
|
78
|
+
* (Ask Buoy is optional and this package is older), and `agent-core` ships
|
|
79
|
+
* with no dependencies at all. `registerSelfEndpoint` in agent-core writes the
|
|
80
|
+
* set; this reads it. Absent, undefined or the wrong shape all mean "nothing
|
|
81
|
+
* to exclude", so nothing breaks in an app without Ask Buoy.
|
|
82
|
+
*
|
|
83
|
+
* WHY IT IS NOT ENOUGH TO FILTER LATER. The interceptor reads the response
|
|
84
|
+
* body — `await processResponseBody(response.clone())` — BEFORE handing the
|
|
85
|
+
* response back. On a streamed reply that resolves only at EOF, so an
|
|
86
|
+
* intercepted model endpoint delivers its whole answer at once and Ask Buoy
|
|
87
|
+
* cannot stream a word. Ignoring the URL is what keeps the socket the
|
|
88
|
+
* caller's. It also keeps override rules off model traffic, which a broad
|
|
89
|
+
* user-authored rule would otherwise rewrite.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Content types whose body arrives over time rather than all at once.
|
|
93
|
+
*
|
|
94
|
+
* These are the ones where reading the clone before returning the response
|
|
95
|
+
* changes the CALLER's behaviour instead of merely observing it.
|
|
96
|
+
*/
|
|
97
|
+
const STREAMED_CONTENT_TYPES = ["text/event-stream", "application/x-ndjson", "application/stream+json"];
|
|
98
|
+
const STREAMING_PLACEHOLDER = "[streaming\u2026]";
|
|
99
|
+
function isStreamedContentType(contentType) {
|
|
100
|
+
if (!contentType) return false;
|
|
101
|
+
const lower = contentType.toLowerCase();
|
|
102
|
+
return STREAMED_CONTENT_TYPES.some(t => lower.includes(t));
|
|
103
|
+
}
|
|
104
|
+
const INTERNAL_HOSTS_KEY = "__BUOY_INTERNAL_HOSTS__";
|
|
105
|
+
function isInternalBuoyHost(url) {
|
|
106
|
+
const hosts = globalThis[INTERNAL_HOSTS_KEY];
|
|
107
|
+
if (!(hosts instanceof Set) || hosts.size === 0) return false;
|
|
108
|
+
const lower = url.toLowerCase();
|
|
109
|
+
for (const host of hosts) {
|
|
110
|
+
if (typeof host === "string" && host && lower.includes(host)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
74
114
|
class NetworkListener {
|
|
75
115
|
listeners = [];
|
|
76
116
|
isListening = false;
|
|
@@ -121,7 +161,8 @@ class NetworkListener {
|
|
|
121
161
|
* @returns True if the URL should be ignored
|
|
122
162
|
*/
|
|
123
163
|
shouldIgnoreUrl(url) {
|
|
124
|
-
|
|
164
|
+
if (this.ignoredUrls.some(pattern => pattern.test(url))) return true;
|
|
165
|
+
return isInternalBuoyHost(url);
|
|
125
166
|
}
|
|
126
167
|
|
|
127
168
|
// Emit event to all listeners
|
|
@@ -167,6 +208,70 @@ class NetworkListener {
|
|
|
167
208
|
* @param maxSize - Maximum body size in bytes (default: 1MB)
|
|
168
209
|
* @returns Object containing body, size, and truncation status
|
|
169
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Read a streamed body in the background, with a hard ceiling.
|
|
213
|
+
*
|
|
214
|
+
* Deliberately not `processResponseBody`: that one calls `.text()`, which
|
|
215
|
+
* accumulates the WHOLE body before it checks the size, so a long-lived SSE
|
|
216
|
+
* stream would grow unbounded in memory with nothing to stop it. This stops
|
|
217
|
+
* at the cap and cancels the reader, and a runtime with no `body` (React
|
|
218
|
+
* Native's own fetch) falls back to `.text()` — that response was never
|
|
219
|
+
* streaming there in the first place.
|
|
220
|
+
*/
|
|
221
|
+
async readStreamedBody(response, maxSize = 256 * 1024) {
|
|
222
|
+
const body = response.body;
|
|
223
|
+
if (!body || typeof body.getReader !== "function") {
|
|
224
|
+
const text = await response.text();
|
|
225
|
+
return text.length > maxSize ? {
|
|
226
|
+
body: `${text.slice(0, maxSize)}\n\n... [truncated]`,
|
|
227
|
+
size: text.length
|
|
228
|
+
} : {
|
|
229
|
+
body: text,
|
|
230
|
+
size: text.length
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const reader = body.getReader();
|
|
234
|
+
const decoder = new TextDecoder();
|
|
235
|
+
let out = "";
|
|
236
|
+
let size = 0;
|
|
237
|
+
let capped = false;
|
|
238
|
+
try {
|
|
239
|
+
for (;;) {
|
|
240
|
+
const {
|
|
241
|
+
done,
|
|
242
|
+
value
|
|
243
|
+
} = await reader.read();
|
|
244
|
+
if (done) break;
|
|
245
|
+
size += value?.byteLength ?? 0;
|
|
246
|
+
if (!capped) {
|
|
247
|
+
out += decoder.decode(value, {
|
|
248
|
+
stream: true
|
|
249
|
+
});
|
|
250
|
+
if (out.length >= maxSize) {
|
|
251
|
+
out = `${out.slice(0, maxSize)}\n\n... [truncated]`;
|
|
252
|
+
capped = true;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!capped) out += decoder.decode();
|
|
258
|
+
} finally {
|
|
259
|
+
try {
|
|
260
|
+
void Promise.resolve(reader.cancel()).catch(() => {});
|
|
261
|
+
} catch {
|
|
262
|
+
/* already gone */
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
reader.releaseLock();
|
|
266
|
+
} catch {
|
|
267
|
+
/* already released */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
body: out,
|
|
272
|
+
size
|
|
273
|
+
};
|
|
274
|
+
}
|
|
170
275
|
async processResponseBody(response, maxSize = 1024 * 1024 // 1MB default
|
|
171
276
|
) {
|
|
172
277
|
try {
|
|
@@ -544,6 +649,8 @@ class NetworkListener {
|
|
|
544
649
|
}
|
|
545
650
|
|
|
546
651
|
// Parse request body
|
|
652
|
+
// Explicitly typed: the late emit for a streamed reply reads this from
|
|
653
|
+
// inside a callback, which defeats the inference it used to rely on.
|
|
547
654
|
let requestData;
|
|
548
655
|
if (init?.body) {
|
|
549
656
|
if (typeof init.body === "string") {
|
|
@@ -612,12 +719,29 @@ class NetworkListener {
|
|
|
612
719
|
// look faster through fetch than through XHR.
|
|
613
720
|
const ttfb = Date.now() - startTime;
|
|
614
721
|
|
|
722
|
+
/**
|
|
723
|
+
* A STREAMED reply is never awaited here.
|
|
724
|
+
*
|
|
725
|
+
* `processResponseBody` reads the clone to the end, and for
|
|
726
|
+
* `text/event-stream` the end is when the server finishes — seconds
|
|
727
|
+
* later, for an LLM. Awaiting it made the interceptor hold the
|
|
728
|
+
* response back until then, so every consumer of a streaming endpoint
|
|
729
|
+
* inside an app with capture on received the whole thing at once and
|
|
730
|
+
* could not stream a word of it. The row is emitted now with what is
|
|
731
|
+
* known, and the body is filled in when the stream closes: the store
|
|
732
|
+
* merges a second `response` event onto the same request id.
|
|
733
|
+
*/
|
|
734
|
+
const streamed = isStreamedContentType(response.headers?.get?.("content-type"));
|
|
735
|
+
|
|
615
736
|
// Clone response to read body with size limits
|
|
616
737
|
const responseClone = response.clone();
|
|
617
738
|
const {
|
|
618
739
|
body,
|
|
619
740
|
size: responseSize
|
|
620
|
-
} =
|
|
741
|
+
} = streamed ? {
|
|
742
|
+
body: STREAMING_PLACEHOLDER,
|
|
743
|
+
size: 0
|
|
744
|
+
} : await self.processResponseBody(responseClone);
|
|
621
745
|
|
|
622
746
|
// Now the body is in — this is the comparable, total number.
|
|
623
747
|
const duration = Date.now() - startTime;
|
|
@@ -676,6 +800,36 @@ class NetworkListener {
|
|
|
676
800
|
},
|
|
677
801
|
override
|
|
678
802
|
});
|
|
803
|
+
if (streamed) {
|
|
804
|
+
// Bounded, cancelled at the cap, and never awaited by the caller.
|
|
805
|
+
void self.readStreamedBody(responseClone).then(late => {
|
|
806
|
+
self.emit({
|
|
807
|
+
type: "response",
|
|
808
|
+
timestamp: new Date(),
|
|
809
|
+
duration: Date.now() - startTime,
|
|
810
|
+
timings,
|
|
811
|
+
request: {
|
|
812
|
+
id: requestId || "unknown",
|
|
813
|
+
url: cleanUrl,
|
|
814
|
+
method,
|
|
815
|
+
headers: requestHeaders,
|
|
816
|
+
data: requestData,
|
|
817
|
+
params: params || undefined,
|
|
818
|
+
client: clientType
|
|
819
|
+
},
|
|
820
|
+
response: {
|
|
821
|
+
status: response.status,
|
|
822
|
+
statusText: response.statusText,
|
|
823
|
+
headers: responseHeaders,
|
|
824
|
+
body: late.body,
|
|
825
|
+
size: late.size
|
|
826
|
+
},
|
|
827
|
+
override
|
|
828
|
+
});
|
|
829
|
+
}).catch(() => {
|
|
830
|
+
/* the caller's response is unaffected either way */
|
|
831
|
+
});
|
|
832
|
+
}
|
|
679
833
|
return response;
|
|
680
834
|
} catch (error) {
|
|
681
835
|
const duration = Date.now() - startTime;
|
|
@@ -7,6 +7,7 @@ import { draftFromEvent } from "../overrides/presets";
|
|
|
7
7
|
import { inspectEngine, overridesCanApply, OVERRIDES_INACTIVE_REASON } from "../overrides/engine";
|
|
8
8
|
import { getNetworkListenerCount, isNetworkInterceptionLive, isNetworkListening } from "../utils/networkListener";
|
|
9
9
|
import { approxJsonSize, isOverWireBudget } from "@buoy-gg/shared-ui";
|
|
10
|
+
import { expandKeyedArrays, patchFromPath, refusalText, shapeKind, suggestedPatch, typedEditViolations } from "@buoy-gg/shared-ui";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Request/response bodies larger than this are NOT streamed in the list
|
|
@@ -308,13 +309,116 @@ base) {
|
|
|
308
309
|
headers: rule.headers && typeof rule.headers === "object" ? rule.headers : base?.headers,
|
|
309
310
|
// Objects are accepted and serialized so a caller can send JSON directly
|
|
310
311
|
// instead of pre-stringifying it, which is the mistake everyone makes once.
|
|
311
|
-
body:
|
|
312
|
+
body: patchedBody(rule, base),
|
|
312
313
|
failKind: rule.failKind === "timeout" ? "timeout" : undefined,
|
|
313
314
|
delayMs: typeof rule.delayMs === "number" ? rule.delayMs : undefined,
|
|
314
315
|
times: typeof rule.times === "number" ? rule.times : null,
|
|
315
316
|
alternate: rule.alternate === true
|
|
316
317
|
};
|
|
317
318
|
}
|
|
319
|
+
const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
|
|
320
|
+
/**
|
|
321
|
+
* Objects merge key by key; arrays and primitives are replaced. Collects two
|
|
322
|
+
* dangers instead of writing them: `shrunk` (a non-empty array shorter than
|
|
323
|
+
* the one it replaces) and `breaks` (an array/object field overwritten by null
|
|
324
|
+
* or a primitive — the shape the app renders, changed out from under it). The
|
|
325
|
+
* handler refuses either unless forced.
|
|
326
|
+
*
|
|
327
|
+
* This stays local while the typed-edit RULE is shared: it is the merge itself,
|
|
328
|
+
* not the check, and it reports network-specific dangers the store surfaces do
|
|
329
|
+
* not have. `shapeKind` comes from the shared module so both agree on what
|
|
330
|
+
* counts as an array.
|
|
331
|
+
*/
|
|
332
|
+
function deepMerge(base, patch, path = "", issues = {
|
|
333
|
+
shrunk: [],
|
|
334
|
+
breaks: []
|
|
335
|
+
}) {
|
|
336
|
+
if (Array.isArray(base) && Array.isArray(patch)) {
|
|
337
|
+
if (patch.length > 0 && patch.length < base.length) {
|
|
338
|
+
issues.shrunk.push({
|
|
339
|
+
path: path || "(root)",
|
|
340
|
+
have: base.length,
|
|
341
|
+
sent: patch.length
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return patch;
|
|
345
|
+
}
|
|
346
|
+
if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
|
|
347
|
+
const out = {
|
|
348
|
+
...base
|
|
349
|
+
};
|
|
350
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
351
|
+
const p = path ? `${path}.${k}` : k;
|
|
352
|
+
if (k in base) {
|
|
353
|
+
const from = shapeKind(base[k]);
|
|
354
|
+
const to = shapeKind(v);
|
|
355
|
+
if ((from === "array" || from === "object") && from !== to) {
|
|
356
|
+
issues.breaks.push({
|
|
357
|
+
path: p,
|
|
358
|
+
from,
|
|
359
|
+
to
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
out[k] = deepMerge(base[k], v, p, issues);
|
|
363
|
+
} else {
|
|
364
|
+
out[k] = v;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Typed-edit violations a bodyPatch would cause against the seed body, or []. */
|
|
371
|
+
function patchViolations(rule, seed) {
|
|
372
|
+
if (rule.body != null || !isPlainObject(rule.bodyPatch) || typeof seed !== "string") return [];
|
|
373
|
+
try {
|
|
374
|
+
return typedEditViolations(JSON.parse(seed), rule.bodyPatch, {
|
|
375
|
+
noun: "response body"
|
|
376
|
+
});
|
|
377
|
+
} catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The rule's body: explicit `body` wins, else `bodyPatch` deep-merged into the
|
|
384
|
+
* seeded body (from `fromRequestId`, or the rule being replaced), else the seed.
|
|
385
|
+
*
|
|
386
|
+
* `bodyPatch` exists for the same reason `setQueryData` has `merge`: a remote
|
|
387
|
+
* caller reads the real response through a wire that truncates big payloads,
|
|
388
|
+
* edits one field, and pastes the torn object back as `body`. The app then
|
|
389
|
+
* renders a response missing half its fields. With a patch the caller sends
|
|
390
|
+
* `{ stats: [...] }` and the other 30KB stay exactly as the server sent them.
|
|
391
|
+
*/
|
|
392
|
+
function patchedBody(rule, base) {
|
|
393
|
+
if (typeof rule.body === "string") return rule.body;
|
|
394
|
+
if (rule.body != null) return JSON.stringify(rule.body, null, 2);
|
|
395
|
+
const seed = base?.body ?? (typeof rule.id === "string" ? overrideRulesStore.getState().rules.find(r => r.id === rule.id)?.body : undefined);
|
|
396
|
+
if (isPlainObject(rule.bodyPatch) && typeof seed === "string") {
|
|
397
|
+
try {
|
|
398
|
+
return JSON.stringify(deepMerge(JSON.parse(seed), rule.bodyPatch), null, 2);
|
|
399
|
+
} catch {
|
|
400
|
+
return seed;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return seed;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Replacing a rule by id inherits every field the caller did not send, so a
|
|
408
|
+
* remote driver can flip one thing — `{ id, bodyPatch }`, `{ id, status: 500 }`
|
|
409
|
+
* — without re-sending the urlPattern it never had to know.
|
|
410
|
+
*/
|
|
411
|
+
function existingRule(params) {
|
|
412
|
+
if (!params || typeof params !== "object") return null;
|
|
413
|
+
const raw = params.rule ?? params;
|
|
414
|
+
if (!raw || typeof raw !== "object") return null;
|
|
415
|
+
const id = raw.id;
|
|
416
|
+
if (typeof id !== "string") return null;
|
|
417
|
+
const existing = overrideRulesStore.getState().rules.find(r => r.id === id);
|
|
418
|
+
return existing ? {
|
|
419
|
+
...existing
|
|
420
|
+
} : null;
|
|
421
|
+
}
|
|
318
422
|
|
|
319
423
|
/**
|
|
320
424
|
* The body a remote surface never received, when it says so.
|
|
@@ -508,7 +612,89 @@ export const networkSyncAdapter = {
|
|
|
508
612
|
},
|
|
509
613
|
/** Create a rule, or replace one by id. */
|
|
510
614
|
upsertOverrideRule: params => {
|
|
511
|
-
const base = baseFromRequest(params) ?? preservedBody(params);
|
|
615
|
+
const base = baseFromRequest(params) ?? preservedBody(params) ?? existingRule(params);
|
|
616
|
+
const rawRule = params?.rule ?? params;
|
|
617
|
+
// `bodyPath` + `bodyValue` are sugar over `bodyPatch`, resolved against the
|
|
618
|
+
// real response before anything else looks at the rule — so the keyed
|
|
619
|
+
// expansion, the typed-edit guard and the merge below all run on an
|
|
620
|
+
// ordinary patch and gain no special cases. This is the same form the
|
|
621
|
+
// state tools take; it exists here because a response body is the surface
|
|
622
|
+
// where the caller is LEAST likely to have seen the shape (it read the
|
|
623
|
+
// body over a wire that truncates at 24,000 characters), and therefore
|
|
624
|
+
// most likely to get the nesting wrong.
|
|
625
|
+
if (rawRule && typeof rawRule.bodyPath === "string" && rawRule.body == null) {
|
|
626
|
+
const seed = base?.body ?? (typeof rawRule.id === "string" ? overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
|
|
627
|
+
if (typeof seed !== "string") {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
error: "bodyPath needs something to patch: send fromRequestId (the captured request whose real response it addresses) or the id of an existing rule."
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
if (rawRule.bodyValue === undefined) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: `Refused: sent \`bodyPath\` without \`bodyValue\`. Say what to set "${rawRule.bodyPath}" to.`
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
let parsed;
|
|
640
|
+
try {
|
|
641
|
+
parsed = JSON.parse(seed);
|
|
642
|
+
} catch {
|
|
643
|
+
return {
|
|
644
|
+
ok: false,
|
|
645
|
+
error: "The captured response body is not JSON, so it cannot be addressed by path. Send a whole `body` instead."
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const built = patchFromPath(parsed, rawRule.bodyPath, rawRule.bodyValue);
|
|
649
|
+
if (!built.ok) return {
|
|
650
|
+
ok: false,
|
|
651
|
+
error: `Refused: ${built.error}`
|
|
652
|
+
};
|
|
653
|
+
rawRule.bodyPatch = built.patch;
|
|
654
|
+
delete rawRule.bodyPath;
|
|
655
|
+
delete rawRule.bodyValue;
|
|
656
|
+
}
|
|
657
|
+
if (rawRule && isPlainObject(rawRule.bodyPatch) && rawRule.body == null) {
|
|
658
|
+
const seed = base?.body ?? (typeof rawRule.id === "string" ? overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
|
|
659
|
+
if (typeof seed !== "string") {
|
|
660
|
+
return {
|
|
661
|
+
ok: false,
|
|
662
|
+
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."
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (rawRule.force !== true) {
|
|
666
|
+
// Address list items in the real response by their own id rather than
|
|
667
|
+
// resending the array: {results:{pikachu:{name:"test123"}}}. Expanded
|
|
668
|
+
// against the seed BEFORE the guard and before the merge, so the
|
|
669
|
+
// typed-edit rule still holds over the result. This is the only form
|
|
670
|
+
// that is correct when the caller read the response through a wire
|
|
671
|
+
// that truncated it — see packages/shared/src/sync/keyedArrays.ts.
|
|
672
|
+
let keyedError;
|
|
673
|
+
try {
|
|
674
|
+
const expanded = expandKeyedArrays(JSON.parse(seed), rawRule.bodyPatch);
|
|
675
|
+
keyedError = expanded.error;
|
|
676
|
+
if (expanded.notes.length) rawRule.bodyPatch = expanded.patch;
|
|
677
|
+
} catch {
|
|
678
|
+
/* a non-JSON seed has no lists to address; leave the patch alone */
|
|
679
|
+
}
|
|
680
|
+
// See keyedArrays.ts: an added item whose id disagrees with the key
|
|
681
|
+
// it was sent under is ambiguous, and only this layer can say so.
|
|
682
|
+
if (keyedError) {
|
|
683
|
+
return {
|
|
684
|
+
ok: false,
|
|
685
|
+
error: `Refused: ${keyedError}`
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const violations = patchViolations(rawRule, seed);
|
|
689
|
+
if (violations.length > 0) {
|
|
690
|
+
return {
|
|
691
|
+
ok: false,
|
|
692
|
+
error: refusalText(violations, "response body", suggestedPatch(rawRule.bodyPatch, violations)),
|
|
693
|
+
violations
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
512
698
|
const draft = readRuleDraft(params, base);
|
|
513
699
|
if (!draft) {
|
|
514
700
|
const wanted = params?.fromRequestId;
|
|
@@ -538,8 +724,19 @@ export const networkSyncAdapter = {
|
|
|
538
724
|
enabled: rule.enabled,
|
|
539
725
|
kind: rule.kind,
|
|
540
726
|
status: rule.status,
|
|
727
|
+
times: rule.times,
|
|
541
728
|
bodySize: rule.body ? rule.body.length : 0
|
|
542
729
|
},
|
|
730
|
+
// A rule with no budget overrides EVERY matching request until someone
|
|
731
|
+
// deletes it, and that is rarely what a one-off ask meant. Measured on
|
|
732
|
+
// `api-sim/next-call-fails-once` ("just the next one, then let it work
|
|
733
|
+
// again"): ten of fourteen attempts left the budget off and got a rule
|
|
734
|
+
// that never expires. Saying so is not a nag — it is the difference
|
|
735
|
+
// between a caller that adds `times` on its next turn and one that walks
|
|
736
|
+
// away leaving the app permanently broken.
|
|
737
|
+
...(rule.times === undefined || rule.times === null ? {
|
|
738
|
+
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.`
|
|
739
|
+
} : {}),
|
|
543
740
|
...inactiveStamp()
|
|
544
741
|
};
|
|
545
742
|
},
|
|
@@ -68,6 +68,46 @@ export const BUOY_XHR_INTERCEPTOR_FLAG = "__buoyNetworkXhrIntercepted";
|
|
|
68
68
|
* @performance Uses lazy singleton pattern to minimize memory footprint
|
|
69
69
|
* @performance Includes URL filtering to ignore development traffic
|
|
70
70
|
*/
|
|
71
|
+
/**
|
|
72
|
+
* Hosts another Buoy package has claimed as its own traffic.
|
|
73
|
+
*
|
|
74
|
+
* A GLOBAL, not an import: `@buoy-gg/network` cannot depend on `agent-core`
|
|
75
|
+
* (Ask Buoy is optional and this package is older), and `agent-core` ships
|
|
76
|
+
* with no dependencies at all. `registerSelfEndpoint` in agent-core writes the
|
|
77
|
+
* set; this reads it. Absent, undefined or the wrong shape all mean "nothing
|
|
78
|
+
* to exclude", so nothing breaks in an app without Ask Buoy.
|
|
79
|
+
*
|
|
80
|
+
* WHY IT IS NOT ENOUGH TO FILTER LATER. The interceptor reads the response
|
|
81
|
+
* body — `await processResponseBody(response.clone())` — BEFORE handing the
|
|
82
|
+
* response back. On a streamed reply that resolves only at EOF, so an
|
|
83
|
+
* intercepted model endpoint delivers its whole answer at once and Ask Buoy
|
|
84
|
+
* cannot stream a word. Ignoring the URL is what keeps the socket the
|
|
85
|
+
* caller's. It also keeps override rules off model traffic, which a broad
|
|
86
|
+
* user-authored rule would otherwise rewrite.
|
|
87
|
+
*/
|
|
88
|
+
/**
|
|
89
|
+
* Content types whose body arrives over time rather than all at once.
|
|
90
|
+
*
|
|
91
|
+
* These are the ones where reading the clone before returning the response
|
|
92
|
+
* changes the CALLER's behaviour instead of merely observing it.
|
|
93
|
+
*/
|
|
94
|
+
const STREAMED_CONTENT_TYPES = ["text/event-stream", "application/x-ndjson", "application/stream+json"];
|
|
95
|
+
const STREAMING_PLACEHOLDER = "[streaming\u2026]";
|
|
96
|
+
function isStreamedContentType(contentType) {
|
|
97
|
+
if (!contentType) return false;
|
|
98
|
+
const lower = contentType.toLowerCase();
|
|
99
|
+
return STREAMED_CONTENT_TYPES.some(t => lower.includes(t));
|
|
100
|
+
}
|
|
101
|
+
const INTERNAL_HOSTS_KEY = "__BUOY_INTERNAL_HOSTS__";
|
|
102
|
+
function isInternalBuoyHost(url) {
|
|
103
|
+
const hosts = globalThis[INTERNAL_HOSTS_KEY];
|
|
104
|
+
if (!(hosts instanceof Set) || hosts.size === 0) return false;
|
|
105
|
+
const lower = url.toLowerCase();
|
|
106
|
+
for (const host of hosts) {
|
|
107
|
+
if (typeof host === "string" && host && lower.includes(host)) return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
71
111
|
class NetworkListener {
|
|
72
112
|
listeners = [];
|
|
73
113
|
isListening = false;
|
|
@@ -118,7 +158,8 @@ class NetworkListener {
|
|
|
118
158
|
* @returns True if the URL should be ignored
|
|
119
159
|
*/
|
|
120
160
|
shouldIgnoreUrl(url) {
|
|
121
|
-
|
|
161
|
+
if (this.ignoredUrls.some(pattern => pattern.test(url))) return true;
|
|
162
|
+
return isInternalBuoyHost(url);
|
|
122
163
|
}
|
|
123
164
|
|
|
124
165
|
// Emit event to all listeners
|
|
@@ -164,6 +205,70 @@ class NetworkListener {
|
|
|
164
205
|
* @param maxSize - Maximum body size in bytes (default: 1MB)
|
|
165
206
|
* @returns Object containing body, size, and truncation status
|
|
166
207
|
*/
|
|
208
|
+
/**
|
|
209
|
+
* Read a streamed body in the background, with a hard ceiling.
|
|
210
|
+
*
|
|
211
|
+
* Deliberately not `processResponseBody`: that one calls `.text()`, which
|
|
212
|
+
* accumulates the WHOLE body before it checks the size, so a long-lived SSE
|
|
213
|
+
* stream would grow unbounded in memory with nothing to stop it. This stops
|
|
214
|
+
* at the cap and cancels the reader, and a runtime with no `body` (React
|
|
215
|
+
* Native's own fetch) falls back to `.text()` — that response was never
|
|
216
|
+
* streaming there in the first place.
|
|
217
|
+
*/
|
|
218
|
+
async readStreamedBody(response, maxSize = 256 * 1024) {
|
|
219
|
+
const body = response.body;
|
|
220
|
+
if (!body || typeof body.getReader !== "function") {
|
|
221
|
+
const text = await response.text();
|
|
222
|
+
return text.length > maxSize ? {
|
|
223
|
+
body: `${text.slice(0, maxSize)}\n\n... [truncated]`,
|
|
224
|
+
size: text.length
|
|
225
|
+
} : {
|
|
226
|
+
body: text,
|
|
227
|
+
size: text.length
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const reader = body.getReader();
|
|
231
|
+
const decoder = new TextDecoder();
|
|
232
|
+
let out = "";
|
|
233
|
+
let size = 0;
|
|
234
|
+
let capped = false;
|
|
235
|
+
try {
|
|
236
|
+
for (;;) {
|
|
237
|
+
const {
|
|
238
|
+
done,
|
|
239
|
+
value
|
|
240
|
+
} = await reader.read();
|
|
241
|
+
if (done) break;
|
|
242
|
+
size += value?.byteLength ?? 0;
|
|
243
|
+
if (!capped) {
|
|
244
|
+
out += decoder.decode(value, {
|
|
245
|
+
stream: true
|
|
246
|
+
});
|
|
247
|
+
if (out.length >= maxSize) {
|
|
248
|
+
out = `${out.slice(0, maxSize)}\n\n... [truncated]`;
|
|
249
|
+
capped = true;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!capped) out += decoder.decode();
|
|
255
|
+
} finally {
|
|
256
|
+
try {
|
|
257
|
+
void Promise.resolve(reader.cancel()).catch(() => {});
|
|
258
|
+
} catch {
|
|
259
|
+
/* already gone */
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
reader.releaseLock();
|
|
263
|
+
} catch {
|
|
264
|
+
/* already released */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
body: out,
|
|
269
|
+
size
|
|
270
|
+
};
|
|
271
|
+
}
|
|
167
272
|
async processResponseBody(response, maxSize = 1024 * 1024 // 1MB default
|
|
168
273
|
) {
|
|
169
274
|
try {
|
|
@@ -541,6 +646,8 @@ class NetworkListener {
|
|
|
541
646
|
}
|
|
542
647
|
|
|
543
648
|
// Parse request body
|
|
649
|
+
// Explicitly typed: the late emit for a streamed reply reads this from
|
|
650
|
+
// inside a callback, which defeats the inference it used to rely on.
|
|
544
651
|
let requestData;
|
|
545
652
|
if (init?.body) {
|
|
546
653
|
if (typeof init.body === "string") {
|
|
@@ -609,12 +716,29 @@ class NetworkListener {
|
|
|
609
716
|
// look faster through fetch than through XHR.
|
|
610
717
|
const ttfb = Date.now() - startTime;
|
|
611
718
|
|
|
719
|
+
/**
|
|
720
|
+
* A STREAMED reply is never awaited here.
|
|
721
|
+
*
|
|
722
|
+
* `processResponseBody` reads the clone to the end, and for
|
|
723
|
+
* `text/event-stream` the end is when the server finishes — seconds
|
|
724
|
+
* later, for an LLM. Awaiting it made the interceptor hold the
|
|
725
|
+
* response back until then, so every consumer of a streaming endpoint
|
|
726
|
+
* inside an app with capture on received the whole thing at once and
|
|
727
|
+
* could not stream a word of it. The row is emitted now with what is
|
|
728
|
+
* known, and the body is filled in when the stream closes: the store
|
|
729
|
+
* merges a second `response` event onto the same request id.
|
|
730
|
+
*/
|
|
731
|
+
const streamed = isStreamedContentType(response.headers?.get?.("content-type"));
|
|
732
|
+
|
|
612
733
|
// Clone response to read body with size limits
|
|
613
734
|
const responseClone = response.clone();
|
|
614
735
|
const {
|
|
615
736
|
body,
|
|
616
737
|
size: responseSize
|
|
617
|
-
} =
|
|
738
|
+
} = streamed ? {
|
|
739
|
+
body: STREAMING_PLACEHOLDER,
|
|
740
|
+
size: 0
|
|
741
|
+
} : await self.processResponseBody(responseClone);
|
|
618
742
|
|
|
619
743
|
// Now the body is in — this is the comparable, total number.
|
|
620
744
|
const duration = Date.now() - startTime;
|
|
@@ -673,6 +797,36 @@ class NetworkListener {
|
|
|
673
797
|
},
|
|
674
798
|
override
|
|
675
799
|
});
|
|
800
|
+
if (streamed) {
|
|
801
|
+
// Bounded, cancelled at the cap, and never awaited by the caller.
|
|
802
|
+
void self.readStreamedBody(responseClone).then(late => {
|
|
803
|
+
self.emit({
|
|
804
|
+
type: "response",
|
|
805
|
+
timestamp: new Date(),
|
|
806
|
+
duration: Date.now() - startTime,
|
|
807
|
+
timings,
|
|
808
|
+
request: {
|
|
809
|
+
id: requestId || "unknown",
|
|
810
|
+
url: cleanUrl,
|
|
811
|
+
method,
|
|
812
|
+
headers: requestHeaders,
|
|
813
|
+
data: requestData,
|
|
814
|
+
params: params || undefined,
|
|
815
|
+
client: clientType
|
|
816
|
+
},
|
|
817
|
+
response: {
|
|
818
|
+
status: response.status,
|
|
819
|
+
statusText: response.statusText,
|
|
820
|
+
headers: responseHeaders,
|
|
821
|
+
body: late.body,
|
|
822
|
+
size: late.size
|
|
823
|
+
},
|
|
824
|
+
override
|
|
825
|
+
});
|
|
826
|
+
}).catch(() => {
|
|
827
|
+
/* the caller's response is unaffected either way */
|
|
828
|
+
});
|
|
829
|
+
}
|
|
676
830
|
return response;
|
|
677
831
|
} catch (error) {
|
|
678
832
|
const duration = Date.now() - startTime;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OverrideRulesState } from "../overrides/overrideRulesStore";
|
|
2
2
|
import type { OverrideRuleKind } from "../overrides/types";
|
|
3
3
|
import type { NetworkEvent } from "../types";
|
|
4
|
+
import { type EditViolation } from "@buoy-gg/shared-ui";
|
|
4
5
|
/**
|
|
5
6
|
* Request/response bodies larger than this are NOT streamed in the list
|
|
6
7
|
* snapshot. Prefer the cached size proxy already on the event (free); if that
|
|
@@ -132,9 +133,15 @@ export declare const networkSyncAdapter: {
|
|
|
132
133
|
upsertOverrideRule: (params?: unknown) => {
|
|
133
134
|
ok: boolean;
|
|
134
135
|
error: string;
|
|
136
|
+
violations?: undefined;
|
|
137
|
+
} | {
|
|
138
|
+
ok: boolean;
|
|
139
|
+
error: string;
|
|
140
|
+
violations: EditViolation[];
|
|
135
141
|
} | {
|
|
136
142
|
active: false;
|
|
137
143
|
inactiveReason: string;
|
|
144
|
+
lingers?: string | undefined;
|
|
138
145
|
ok: boolean;
|
|
139
146
|
rule: {
|
|
140
147
|
id: string;
|
|
@@ -142,10 +149,13 @@ export declare const networkSyncAdapter: {
|
|
|
142
149
|
enabled: boolean;
|
|
143
150
|
kind: OverrideRuleKind;
|
|
144
151
|
status: number | undefined;
|
|
152
|
+
times: number | null | undefined;
|
|
145
153
|
bodySize: number;
|
|
146
154
|
};
|
|
147
155
|
error?: undefined;
|
|
156
|
+
violations?: undefined;
|
|
148
157
|
} | {
|
|
158
|
+
lingers?: string | undefined;
|
|
149
159
|
ok: boolean;
|
|
150
160
|
rule: {
|
|
151
161
|
id: string;
|
|
@@ -153,9 +163,11 @@ export declare const networkSyncAdapter: {
|
|
|
153
163
|
enabled: boolean;
|
|
154
164
|
kind: OverrideRuleKind;
|
|
155
165
|
status: number | undefined;
|
|
166
|
+
times: number | null | undefined;
|
|
156
167
|
bodySize: number;
|
|
157
168
|
};
|
|
158
169
|
error?: undefined;
|
|
170
|
+
violations?: undefined;
|
|
159
171
|
};
|
|
160
172
|
setOverrideRuleEnabled: (params?: unknown) => {
|
|
161
173
|
ok: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"networkSyncAdapter.d.ts","sourceRoot":"","sources":["../../../../src/network/sync/networkSyncAdapter.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"networkSyncAdapter.d.ts","sourceRoot":"","sources":["../../../../src/network/sync/networkSyncAdapter.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAE7C,OAAO,EAAiG,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEvJ;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,QAAY,CAAC;AAEpD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,QAAqB,CAAC;AA2LzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AA0R9C;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB;;;;QAczB;;;;WAIG;;;;;;;;;;;;;0BAWe,MAAM,IAAI;;;QAkB9B;;;;;WAKG;gCACqB,OAAO;;;;;;;;;;QAoB/B,8CAA8C;6BACzB,OAAO;;;;;;;;;QAY5B,gDAAgD;4BAC5B,OAAO;;;;;;;;;QAU3B,sDAAsD;qCACzB,OAAO;;;;;;;;;QAsBpC,uEAAuE;uCACxC,OAAO;oBAnW5B,KAAK;4BAAkB,MAAM;;;;;;;QA8WvC,2CAA2C;sCACb,OAAO;;;;;;;;;oBA/W3B,KAAK;4BAAkB,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0CAgfL,OAAO;;;;oBAhf/B,KAAK;4BAAkB,MAAM;;;;;;;sCAufT,OAAO;;;;;;;;;;QAYrC,+EAA+E;;QAG/E;;;;WAIG;uCAC4B,OAAO;;;QAYtC,4EAA4E;QAC5E;;;;;;;WAOG;;;;;;;;;kCA2CuB,OAAO;;;;;;;;;;;;;;;;CAiBpC,CAAC"}
|
|
@@ -47,33 +47,6 @@ export type NetworkingEventListener = (event: NetworkingEvent) => void;
|
|
|
47
47
|
*/
|
|
48
48
|
export declare const BUOY_FETCH_INTERCEPTOR_FLAG = "__buoyNetworkFetchIntercepted";
|
|
49
49
|
export declare const BUOY_XHR_INTERCEPTOR_FLAG = "__buoyNetworkXhrIntercepted";
|
|
50
|
-
/**
|
|
51
|
-
* Network traffic interceptor for React Native applications
|
|
52
|
-
*
|
|
53
|
-
* This class intercepts both fetch and XMLHttpRequest operations to provide
|
|
54
|
-
* comprehensive network monitoring capabilities. It uses method swizzling to
|
|
55
|
-
* wrap native networking APIs while preserving their original functionality.
|
|
56
|
-
*
|
|
57
|
-
* @example
|
|
58
|
-
* ```typescript
|
|
59
|
-
* // Start monitoring network traffic
|
|
60
|
-
* startNetworkListener();
|
|
61
|
-
*
|
|
62
|
-
* // Add a listener for network events
|
|
63
|
-
* const unsubscribe = addNetworkListener((event) => {
|
|
64
|
-
* if (event.type === 'response') {
|
|
65
|
-
* console.log(`${event.request.method} ${event.request.url}: ${event.response?.status}`);
|
|
66
|
-
* }
|
|
67
|
-
* });
|
|
68
|
-
*
|
|
69
|
-
* // Stop monitoring and cleanup
|
|
70
|
-
* unsubscribe();
|
|
71
|
-
* stopNetworkListener();
|
|
72
|
-
* ```
|
|
73
|
-
*
|
|
74
|
-
* @performance Uses lazy singleton pattern to minimize memory footprint
|
|
75
|
-
* @performance Includes URL filtering to ignore development traffic
|
|
76
|
-
*/
|
|
77
50
|
declare class NetworkListener {
|
|
78
51
|
private listeners;
|
|
79
52
|
private isListening;
|
|
@@ -128,6 +101,17 @@ declare class NetworkListener {
|
|
|
128
101
|
* @param maxSize - Maximum body size in bytes (default: 1MB)
|
|
129
102
|
* @returns Object containing body, size, and truncation status
|
|
130
103
|
*/
|
|
104
|
+
/**
|
|
105
|
+
* Read a streamed body in the background, with a hard ceiling.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately not `processResponseBody`: that one calls `.text()`, which
|
|
108
|
+
* accumulates the WHOLE body before it checks the size, so a long-lived SSE
|
|
109
|
+
* stream would grow unbounded in memory with nothing to stop it. This stops
|
|
110
|
+
* at the cap and cancels the reader, and a runtime with no `body` (React
|
|
111
|
+
* Native's own fetch) falls back to `.text()` — that response was never
|
|
112
|
+
* streaming there in the first place.
|
|
113
|
+
*/
|
|
114
|
+
private readStreamedBody;
|
|
131
115
|
private processResponseBody;
|
|
132
116
|
/**
|
|
133
117
|
* Format bytes into human-readable format
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"networkListener.d.ts","sourceRoot":"","sources":["../../../../src/network/utils/networkListener.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAS/C,OAAO,EAAkB,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AA+B9E,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC;IACvC,SAAS,EAAE,IAAI,CAAC;IAChB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,OAAO,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAChC,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,GAAG,UAAU,CAAC;KAC7D,CAAC;IACF,QAAQ,CAAC,EAAE;QACT,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC;IACF,KAAK,CAAC,EAAE;QACN,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,mBAAmB,CAAC;CAChC;AAED,MAAM,MAAM,uBAAuB,GAAG,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;AAEvE;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,kCAAkC,CAAC;AAC3E,eAAO,MAAM,yBAAyB,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"networkListener.d.ts","sourceRoot":"","sources":["../../../../src/network/utils/networkListener.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buoy-gg/network",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.36",
|
|
4
4
|
"description": "In-app network inspector for React Native — requests, responses, timing & errors live, with GraphQL operation parsing. Part of Buoy devtools.",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"sideEffects": false,
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@buoy-gg/shared-ui": "^7.0.
|
|
29
|
+
"@buoy-gg/shared-ui": "^7.0.36"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"react": "*",
|