@buoy-gg/network 7.0.4 → 7.0.6
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 +2 -1
- package/lib/commonjs/index.js +85 -0
- package/lib/commonjs/network/components/NetworkEventDetailView.js +201 -40
- package/lib/commonjs/network/components/NetworkEventItemCompact.js +52 -5
- package/lib/commonjs/network/components/NetworkHeaderLive.js +132 -0
- package/lib/commonjs/network/components/NetworkListScreen.js +134 -56
- package/lib/commonjs/network/components/NetworkModal.js +154 -36
- package/lib/commonjs/network/components/NetworkSavedScreen.js +245 -0
- package/lib/commonjs/network/components/NetworkUpgradeBanner.js +76 -0
- package/lib/commonjs/network/hooks/useLiveNetworkEvent.js +62 -0
- package/lib/commonjs/network/hooks/useNetworkSaved.js +38 -0
- package/lib/commonjs/network/index.js +84 -0
- package/lib/commonjs/network/sync/networkSyncAdapter.js +85 -7
- package/lib/commonjs/network/utils/generateCurl.js +71 -0
- package/lib/commonjs/network/utils/networkEventStore.js +10 -1
- package/lib/commonjs/network/utils/networkListener.js +54 -1
- package/lib/commonjs/network/utils/networkSavedStore.js +575 -0
- package/lib/commonjs/network/utils/withPinnedFirst.js +32 -0
- package/lib/module/index.js +17 -1
- package/lib/module/network/components/NetworkEventDetailView.js +204 -41
- package/lib/module/network/components/NetworkEventItemCompact.js +53 -6
- package/lib/module/network/components/NetworkHeaderLive.js +132 -2
- package/lib/module/network/components/NetworkListScreen.js +136 -58
- package/lib/module/network/components/NetworkModal.js +156 -39
- package/lib/module/network/components/NetworkSavedScreen.js +241 -0
- package/lib/module/network/components/NetworkUpgradeBanner.js +72 -0
- package/lib/module/network/hooks/useLiveNetworkEvent.js +58 -0
- package/lib/module/network/hooks/useNetworkSaved.js +34 -0
- package/lib/module/network/index.js +7 -1
- package/lib/module/network/sync/networkSyncAdapter.js +85 -7
- package/lib/module/network/utils/generateCurl.js +67 -0
- package/lib/module/network/utils/networkEventStore.js +10 -1
- package/lib/module/network/utils/networkListener.js +54 -1
- package/lib/module/network/utils/networkSavedStore.js +569 -0
- package/lib/module/network/utils/withPinnedFirst.js +28 -0
- package/lib/typescript/index.d.ts +13 -1
- 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 +12 -0
- package/lib/typescript/network/components/NetworkEventItemCompact.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkHeaderLive.d.ts +28 -0
- package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkModal.d.ts +34 -0
- package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
- package/lib/typescript/network/components/NetworkSavedScreen.d.ts +29 -0
- package/lib/typescript/network/components/NetworkSavedScreen.d.ts.map +1 -0
- package/lib/typescript/network/components/NetworkUpgradeBanner.d.ts +17 -0
- package/lib/typescript/network/components/NetworkUpgradeBanner.d.ts.map +1 -0
- package/lib/typescript/network/hooks/useLiveNetworkEvent.d.ts +19 -0
- package/lib/typescript/network/hooks/useLiveNetworkEvent.d.ts.map +1 -0
- package/lib/typescript/network/hooks/useNetworkSaved.d.ts +21 -0
- package/lib/typescript/network/hooks/useNetworkSaved.d.ts.map +1 -0
- package/lib/typescript/network/index.d.ts +8 -1
- package/lib/typescript/network/index.d.ts.map +1 -1
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts +52 -3
- package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/network/types/index.d.ts +43 -0
- package/lib/typescript/network/types/index.d.ts.map +1 -1
- package/lib/typescript/network/utils/generateCurl.d.ts +3 -0
- package/lib/typescript/network/utils/generateCurl.d.ts.map +1 -0
- package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkListener.d.ts +4 -0
- package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
- package/lib/typescript/network/utils/networkSavedStore.d.ts +186 -0
- package/lib/typescript/network/utils/networkSavedStore.d.ts.map +1 -0
- package/lib/typescript/network/utils/withPinnedFirst.d.ts +24 -0
- package/lib/typescript/network/utils/withPinnedFirst.d.ts.map +1 -0
- package/package.json +2 -2
|
@@ -201,6 +201,16 @@ class NetworkListener {
|
|
|
201
201
|
*/
|
|
202
202
|
handleXHRResponse(xhr, requestId, cleanUrl, method, requestHeaders, requestData, params, startTime, isError = false, clientType = "xhr") {
|
|
203
203
|
const duration = startTime ? Date.now() - startTime : 0;
|
|
204
|
+
|
|
205
|
+
// XHR's duration was ALREADY total (measured at readyState 4 / load), so
|
|
206
|
+
// only the split is new. `_ttfb` is stamped at HEADERS_RECEIVED; it is
|
|
207
|
+
// absent for a request that failed before any headers arrived, and the
|
|
208
|
+
// breakdown is then correctly omitted rather than faked as 0.
|
|
209
|
+
const ttfb = xhr._ttfb;
|
|
210
|
+
const timings = ttfb === undefined ? undefined : {
|
|
211
|
+
ttfb,
|
|
212
|
+
download: Math.max(0, duration - ttfb)
|
|
213
|
+
};
|
|
204
214
|
if (isError || xhr.status === 0) {
|
|
205
215
|
// Network error or aborted request
|
|
206
216
|
this.emit({
|
|
@@ -341,6 +351,7 @@ class NetworkListener {
|
|
|
341
351
|
type: "response",
|
|
342
352
|
timestamp: new Date(),
|
|
343
353
|
duration,
|
|
354
|
+
timings,
|
|
344
355
|
request: {
|
|
345
356
|
id: requestId || "unknown",
|
|
346
357
|
url: cleanUrl,
|
|
@@ -468,7 +479,11 @@ class NetworkListener {
|
|
|
468
479
|
});
|
|
469
480
|
try {
|
|
470
481
|
const response = await self.originalFetch(input, init);
|
|
471
|
-
|
|
482
|
+
// A fetch promise resolves when the HEADERS arrive, not the body — so
|
|
483
|
+
// this is time-to-first-byte, and it is where `duration` used to stop.
|
|
484
|
+
// That silently excluded download time, making an identical request
|
|
485
|
+
// look faster through fetch than through XHR.
|
|
486
|
+
const ttfb = Date.now() - startTime;
|
|
472
487
|
|
|
473
488
|
// Clone response to read body with size limits
|
|
474
489
|
const responseClone = response.clone();
|
|
@@ -478,6 +493,33 @@ class NetworkListener {
|
|
|
478
493
|
truncated
|
|
479
494
|
} = await self.processResponseBody(responseClone);
|
|
480
495
|
|
|
496
|
+
// Now the body is in — this is the comparable, total number.
|
|
497
|
+
const duration = Date.now() - startTime;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The split is only real on platforms whose fetch resolves at HEADERS.
|
|
501
|
+
*
|
|
502
|
+
* React Native's does NOT: `fetch` there is the whatwg-fetch polyfill
|
|
503
|
+
* over XMLHttpRequest, and it resolves inside `xhr.onload` — readyState
|
|
504
|
+
* 4, i.e. after the entire body. On device this `ttfb` is therefore the
|
|
505
|
+
* total and the download phase collapses to ~0, so claiming a "0ms
|
|
506
|
+
* download" would be asserting a measurement we never made.
|
|
507
|
+
*
|
|
508
|
+
* Omitting is the right answer rather than a problem: RN's fetch runs
|
|
509
|
+
* through XMLHttpRequest, so the XHR interceptor below sees readyState 2
|
|
510
|
+
* and supplies a genuine TTFB for those requests anyway. Where fetch is
|
|
511
|
+
* native (web, Node), the gap is real and we keep it.
|
|
512
|
+
*
|
|
513
|
+
* Verified both ways against a server that flushes headers then holds
|
|
514
|
+
* the body 250ms: native fetch → 16ms / 251ms; resolve-at-end → the
|
|
515
|
+
* download phase vanishes, which is what this guard catches.
|
|
516
|
+
*/
|
|
517
|
+
const download = Math.max(0, duration - ttfb);
|
|
518
|
+
const timings = download > 0 ? {
|
|
519
|
+
ttfb,
|
|
520
|
+
download
|
|
521
|
+
} : undefined;
|
|
522
|
+
|
|
481
523
|
// Parse response headers
|
|
482
524
|
const responseHeaders = {};
|
|
483
525
|
response.headers.forEach((value, key) => {
|
|
@@ -489,6 +531,7 @@ class NetworkListener {
|
|
|
489
531
|
type: "response",
|
|
490
532
|
timestamp: new Date(),
|
|
491
533
|
duration,
|
|
534
|
+
timings,
|
|
492
535
|
request: {
|
|
493
536
|
id: requestId || "unknown",
|
|
494
537
|
url: cleanUrl,
|
|
@@ -641,6 +684,16 @@ class NetworkListener {
|
|
|
641
684
|
processResponse(true);
|
|
642
685
|
};
|
|
643
686
|
const readyStateListener = () => {
|
|
687
|
+
// HEADERS_RECEIVED — the XHR equivalent of a fetch promise resolving.
|
|
688
|
+
// This listener is already attached for the readyState-4 fallback, and
|
|
689
|
+
// readystatechange fires whether or not we look at it, so capturing
|
|
690
|
+
// TTFB here costs one comparison and one clock read.
|
|
691
|
+
if (this.readyState === 2) {
|
|
692
|
+
const xhr = this;
|
|
693
|
+
if (xhr._ttfb === undefined && xhr._startTime) {
|
|
694
|
+
xhr._ttfb = Date.now() - xhr._startTime;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
644
697
|
if (this.readyState === 4 && !processed) {
|
|
645
698
|
processResponse(false);
|
|
646
699
|
}
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pinned + saved network requests.
|
|
5
|
+
*
|
|
6
|
+
* The live `networkEventStore` is volatile by design: it caps at 500 events
|
|
7
|
+
* and `clearEvents()` wipes it. So "pin this failure so I don't lose it"
|
|
8
|
+
* cannot be an ID pointing into that store — it has to be a SNAPSHOT that
|
|
9
|
+
* outlives the stream. This store owns those snapshots and persists them.
|
|
10
|
+
*
|
|
11
|
+
* Two independent flags per record:
|
|
12
|
+
* - `pinned` — hoisted into the PINNED section at the top of the list,
|
|
13
|
+
* unaffected by search/filters.
|
|
14
|
+
* - `saved` — kept in the Saved (favorites) screen, out of the live stream.
|
|
15
|
+
* A record exists while either flag is true and is dropped when both go false,
|
|
16
|
+
* so all four states fall out without special cases.
|
|
17
|
+
*
|
|
18
|
+
* IDENTITY WARNING: `networkListener` mints request IDs from a per-runtime
|
|
19
|
+
* counter (`fetch_1`, `xhr_2`), so after an app reload `fetch_1` is a
|
|
20
|
+
* DIFFERENT request. Records are therefore keyed on a generated `key`, carry
|
|
21
|
+
* the `sessionId` that captured them, and only trust `liveId` while that
|
|
22
|
+
* session is the current one. Snapshots restored from an earlier session get
|
|
23
|
+
* their event `id` rewritten to `saved:<key>` so a restored pin can never
|
|
24
|
+
* resolve to (or dedupe against) an unrelated live request.
|
|
25
|
+
*
|
|
26
|
+
* Shape/persistence follows `ignoredPatternsStore` in @buoy-gg/shared-ui:
|
|
27
|
+
* lazy hydrate on first subscribe, a `dirty` flag so a slow read can't clobber
|
|
28
|
+
* a mutation, and commit = set + emit + persist.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { devToolsStorageKeys, persistentStorage } from "@buoy-gg/shared-ui";
|
|
32
|
+
/** Identifies THIS runtime. Records from other sessions can't trust `liveId`. */
|
|
33
|
+
export const SESSION_ID = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
34
|
+
|
|
35
|
+
/** Pins render un-virtualized in the list header, so the count stays bounded. */
|
|
36
|
+
export const MAX_PINNED = 25;
|
|
37
|
+
/** Upper bound on the persisted favorites list (oldest unpinned evicted). */
|
|
38
|
+
export const MAX_SAVED = 50;
|
|
39
|
+
/** Free tier keeps a handful of favorites; Pro is unlimited (up to MAX_SAVED). */
|
|
40
|
+
export const FREE_TIER_SAVED_LIMIT = 5;
|
|
41
|
+
/** Per-body cap on a snapshot. */
|
|
42
|
+
const BODY_LIMIT = 16 * 1024;
|
|
43
|
+
/**
|
|
44
|
+
* Total body bytes kept across ALL records. `persistentStorage` falls back to
|
|
45
|
+
* AsyncStorage on RN CLI (no expo-file-system), which has real per-key size
|
|
46
|
+
* limits, so the blob is budgeted rather than left to grow. Bodies are dropped
|
|
47
|
+
* oldest-first, pins last.
|
|
48
|
+
*/
|
|
49
|
+
const TOTAL_BODY_BUDGET = 512 * 1024;
|
|
50
|
+
|
|
51
|
+
/** Why a toggle didn't take effect, so the UI can explain itself. */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Is this actually a network event we can store and re-render later?
|
|
55
|
+
*
|
|
56
|
+
* This store WRITES TO DISK, which turns "accept whatever you're handed" into a
|
|
57
|
+
* permanent failure: a bad value becomes a record, the record is persisted, and
|
|
58
|
+
* every subsequent render of the pinned strip throws on it — a crash loop that
|
|
59
|
+
* survives restarts. Found exactly that way, by an automation harness invoking
|
|
60
|
+
* a row's `onLongPress` with a gesture object instead of the row's event
|
|
61
|
+
* (`TypeError: Cannot read property 'replace' of undefined` in the row, from
|
|
62
|
+
* `event.url.replace(...)`).
|
|
63
|
+
*
|
|
64
|
+
* `id` and `url` are the two fields the row and detail views dereference
|
|
65
|
+
* without guarding, so they are the bar for admission — both here, where
|
|
66
|
+
* records are created, and in `parseRecords`, so anything already on disk is
|
|
67
|
+
* dropped at hydration instead of crashing forever.
|
|
68
|
+
*/
|
|
69
|
+
function isStorableEvent(value) {
|
|
70
|
+
if (!value || typeof value !== "object") return false;
|
|
71
|
+
const event = value;
|
|
72
|
+
return typeof event.id === "string" && typeof event.url === "string";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handlers the desktop dashboard installs to forward mutations to the device.
|
|
77
|
+
*
|
|
78
|
+
* The dashboard must not keep its own copy of the device's pins: the device is
|
|
79
|
+
* the only place the snapshots can stay in sync with the traffic that produced
|
|
80
|
+
* them (and the only place a >16KB body still exists). So in remote mode this
|
|
81
|
+
* store becomes a MIRROR — same seam `networkEventStore.disableCapture()` +
|
|
82
|
+
* `replaceEvents()` uses for the event list.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How long a local optimistic write suppresses incoming mirrors.
|
|
87
|
+
*
|
|
88
|
+
* A toggle is applied locally AND forwarded; the device echoes the new state
|
|
89
|
+
* back on its next snapshot. Without this window the in-flight snapshot — the
|
|
90
|
+
* one already on the wire when the user tapped — would land first and visibly
|
|
91
|
+
* revert the toggle for a frame.
|
|
92
|
+
*/
|
|
93
|
+
const REMOTE_ECHO_GRACE_MS = 800;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read an event's flags out of a state snapshot.
|
|
97
|
+
*
|
|
98
|
+
* Two lookups because an event ID means two different things: a live request
|
|
99
|
+
* matches by `liveId` (the fast Set path), while a snapshot restored from an
|
|
100
|
+
* earlier session carries the namespaced `saved:<key>` id and has to be found
|
|
101
|
+
* among the records.
|
|
102
|
+
*/
|
|
103
|
+
export function flagsForEventId(state, eventId) {
|
|
104
|
+
if (!eventId) return {
|
|
105
|
+
pinned: false,
|
|
106
|
+
saved: false
|
|
107
|
+
};
|
|
108
|
+
const pinned = state.pinnedLiveIds.has(eventId);
|
|
109
|
+
const saved = state.savedLiveIds.has(eventId);
|
|
110
|
+
if (pinned || saved) return {
|
|
111
|
+
pinned,
|
|
112
|
+
saved
|
|
113
|
+
};
|
|
114
|
+
const record = state.records.find(entry => entry.event.id === eventId);
|
|
115
|
+
return record ? {
|
|
116
|
+
pinned: record.pinned,
|
|
117
|
+
saved: record.saved
|
|
118
|
+
} : {
|
|
119
|
+
pinned: false,
|
|
120
|
+
saved: false
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const EMPTY_STATE = {
|
|
124
|
+
records: [],
|
|
125
|
+
pinnedEvents: [],
|
|
126
|
+
savedRecords: [],
|
|
127
|
+
pinnedLiveIds: new Set(),
|
|
128
|
+
savedLiveIds: new Set()
|
|
129
|
+
};
|
|
130
|
+
function truncateBody(data) {
|
|
131
|
+
if (data === undefined || data === null) return {
|
|
132
|
+
value: data,
|
|
133
|
+
truncated: false
|
|
134
|
+
};
|
|
135
|
+
if (typeof data === "string") {
|
|
136
|
+
if (data.length <= BODY_LIMIT) return {
|
|
137
|
+
value: data,
|
|
138
|
+
truncated: false
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
value: `${data.slice(0, BODY_LIMIT)}\n\n… truncated by Buoy (body exceeded ${BODY_LIMIT} bytes)`,
|
|
142
|
+
truncated: true
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const serialized = JSON.stringify(data);
|
|
147
|
+
if (serialized === undefined) return {
|
|
148
|
+
value: undefined,
|
|
149
|
+
truncated: false
|
|
150
|
+
};
|
|
151
|
+
if (serialized.length <= BODY_LIMIT) return {
|
|
152
|
+
value: data,
|
|
153
|
+
truncated: false
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
value: `${serialized.slice(0, BODY_LIMIT)}\n\n… truncated by Buoy (body exceeded ${BODY_LIMIT} bytes)`,
|
|
157
|
+
truncated: true
|
|
158
|
+
};
|
|
159
|
+
} catch {
|
|
160
|
+
// Circular or otherwise unserializable — it can't be persisted anyway.
|
|
161
|
+
return {
|
|
162
|
+
value: "[unserializable body]",
|
|
163
|
+
truncated: true
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Freeze an event into a persistable snapshot with capped bodies. */
|
|
169
|
+
function snapshot(event) {
|
|
170
|
+
const request = truncateBody(event.requestData);
|
|
171
|
+
const response = truncateBody(event.responseData);
|
|
172
|
+
if (!request.truncated && !response.truncated) {
|
|
173
|
+
return {
|
|
174
|
+
event: {
|
|
175
|
+
...event
|
|
176
|
+
},
|
|
177
|
+
bodyTruncated: false
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
event: {
|
|
182
|
+
...event,
|
|
183
|
+
requestData: request.value,
|
|
184
|
+
responseData: response.value
|
|
185
|
+
},
|
|
186
|
+
bodyTruncated: true
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Bytes a snapshot's bodies actually occupy. Sizes are clamped to BODY_LIMIT
|
|
192
|
+
* because `snapshot()` already truncated anything larger, and a stripped body
|
|
193
|
+
* counts as zero — otherwise the budget would never converge.
|
|
194
|
+
*/
|
|
195
|
+
function bodyBytes(event) {
|
|
196
|
+
let total = 0;
|
|
197
|
+
if (event.requestData != null) {
|
|
198
|
+
total += Math.min(event.requestSize ?? 0, BODY_LIMIT);
|
|
199
|
+
}
|
|
200
|
+
if (event.responseData != null) {
|
|
201
|
+
total += Math.min(event.responseSize ?? 0, BODY_LIMIT);
|
|
202
|
+
}
|
|
203
|
+
return total;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Enforce TOTAL_BODY_BUDGET by stripping bodies from the least important
|
|
208
|
+
* records first (oldest, unpinned). The record itself — URL, status, headers,
|
|
209
|
+
* timings, the error — always survives; only the payload goes.
|
|
210
|
+
*/
|
|
211
|
+
function applyBodyBudget(records) {
|
|
212
|
+
let total = 0;
|
|
213
|
+
for (const record of records) total += bodyBytes(record.event);
|
|
214
|
+
if (total <= TOTAL_BODY_BUDGET) return records;
|
|
215
|
+
|
|
216
|
+
// Keep bodies for pins first, then newest saves.
|
|
217
|
+
const priority = [...records].sort((a, b) => {
|
|
218
|
+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
219
|
+
return b.savedAt - a.savedAt;
|
|
220
|
+
});
|
|
221
|
+
const stripped = new Set();
|
|
222
|
+
let kept = 0;
|
|
223
|
+
for (const record of priority) {
|
|
224
|
+
const size = bodyBytes(record.event);
|
|
225
|
+
if (kept + size <= TOTAL_BODY_BUDGET) {
|
|
226
|
+
kept += size;
|
|
227
|
+
} else {
|
|
228
|
+
stripped.add(record.key);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (stripped.size === 0) return records;
|
|
232
|
+
return records.map(record => stripped.has(record.key) ? {
|
|
233
|
+
...record,
|
|
234
|
+
event: {
|
|
235
|
+
...record.event,
|
|
236
|
+
requestData: undefined,
|
|
237
|
+
responseData: undefined
|
|
238
|
+
},
|
|
239
|
+
bodyTruncated: true
|
|
240
|
+
} : record);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Tolerate junk/older shapes in storage rather than throwing away the list. */
|
|
244
|
+
function parseRecords(raw) {
|
|
245
|
+
if (!Array.isArray(raw)) return [];
|
|
246
|
+
const parsed = [];
|
|
247
|
+
for (const entry of raw) {
|
|
248
|
+
if (!entry || typeof entry !== "object") continue;
|
|
249
|
+
const record = entry;
|
|
250
|
+
if (typeof record.key !== "string") continue;
|
|
251
|
+
// Not just "is an object" — a record whose event can't be rendered would
|
|
252
|
+
// crash the list on every launch, and storage is forever. Drop it instead.
|
|
253
|
+
if (!isStorableEvent(record.event)) continue;
|
|
254
|
+
if (!record.pinned && !record.saved) continue;
|
|
255
|
+
parsed.push({
|
|
256
|
+
key: record.key,
|
|
257
|
+
// A restored snapshot's `event.id` is a stale per-runtime counter, so it
|
|
258
|
+
// is rewritten to a namespaced id that can never collide with a live one.
|
|
259
|
+
event: {
|
|
260
|
+
...record.event,
|
|
261
|
+
id: `saved:${record.key}`
|
|
262
|
+
},
|
|
263
|
+
pinned: !!record.pinned,
|
|
264
|
+
saved: !!record.saved,
|
|
265
|
+
savedAt: typeof record.savedAt === "number" ? record.savedAt : 0,
|
|
266
|
+
sessionId: typeof record.sessionId === "string" ? record.sessionId : "unknown",
|
|
267
|
+
liveId: typeof record.liveId === "string" ? record.liveId : "",
|
|
268
|
+
bodyTruncated: !!record.bodyTruncated
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
class NetworkSavedStore {
|
|
274
|
+
records = [];
|
|
275
|
+
listeners = new Set();
|
|
276
|
+
loaded = false;
|
|
277
|
+
loadPromise = null;
|
|
278
|
+
/** Set once a mutation happens so a slow initial load can't clobber it. */
|
|
279
|
+
dirty = false;
|
|
280
|
+
keyCounter = 0;
|
|
281
|
+
/**
|
|
282
|
+
* Live IDs tracked in THIS session. `syncFromLive` runs on every captured
|
|
283
|
+
* response, so the hot path has to be a Set membership test.
|
|
284
|
+
*/
|
|
285
|
+
trackedLiveIds = new Set();
|
|
286
|
+
/** Derived view, rebuilt on commit so consumers get stable references. */
|
|
287
|
+
state = EMPTY_STATE;
|
|
288
|
+
/** Set on the desktop dashboard — see NetworkSavedRemote. */
|
|
289
|
+
remote = null;
|
|
290
|
+
remoteEchoUntil = 0;
|
|
291
|
+
getState() {
|
|
292
|
+
return this.state;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ===========================================================================
|
|
296
|
+
// REMOTE MIRROR (desktop dashboard)
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Turn this store into a mirror of a connected device's records. Mutations
|
|
301
|
+
* are applied locally for immediate feedback AND forwarded to the device,
|
|
302
|
+
* which is the source of truth; nothing is persisted to this machine.
|
|
303
|
+
*/
|
|
304
|
+
enableRemoteMode(remote) {
|
|
305
|
+
this.remote = remote;
|
|
306
|
+
}
|
|
307
|
+
disableRemoteMode() {
|
|
308
|
+
this.remote = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Feed the device's records in (mirror mode only). `force` bypasses the
|
|
313
|
+
* echo guard — used when tearing the mirror down, where there is no later
|
|
314
|
+
* snapshot to converge on.
|
|
315
|
+
*/
|
|
316
|
+
replaceRecords(records, force = false) {
|
|
317
|
+
if (!this.remote) return;
|
|
318
|
+
// A local toggle is still in flight — the snapshot on the wire predates it
|
|
319
|
+
// and would flicker the button back. Drop this one; the next carries the
|
|
320
|
+
// device's real answer.
|
|
321
|
+
if (!force && Date.now() < this.remoteEchoUntil) return;
|
|
322
|
+
this.records = records;
|
|
323
|
+
this.rebuild();
|
|
324
|
+
this.emit();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Whether a record's `liveId` can be trusted against the current event
|
|
329
|
+
* stream. Locally that means "captured in this runtime"; mirrored, the ids
|
|
330
|
+
* belong to the device whose events we are also mirroring, so they line up
|
|
331
|
+
* by construction.
|
|
332
|
+
*/
|
|
333
|
+
sessionMatches(record) {
|
|
334
|
+
return this.remote !== null || record.sessionId === SESSION_ID;
|
|
335
|
+
}
|
|
336
|
+
getRecords() {
|
|
337
|
+
return this.records;
|
|
338
|
+
}
|
|
339
|
+
subscribe(listener) {
|
|
340
|
+
this.listeners.add(listener);
|
|
341
|
+
void this.ensureLoaded();
|
|
342
|
+
return () => {
|
|
343
|
+
this.listeners.delete(listener);
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolve an event the live store no longer has — a pin whose request was
|
|
349
|
+
* cleared or aged out, or a snapshot restored from an earlier session.
|
|
350
|
+
*/
|
|
351
|
+
getEventById(id) {
|
|
352
|
+
for (const record of this.records) {
|
|
353
|
+
if (record.event.id === id) return record.event;
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
isPinned(liveId) {
|
|
358
|
+
return this.state.pinnedLiveIds.has(liveId);
|
|
359
|
+
}
|
|
360
|
+
isSaved(liveId) {
|
|
361
|
+
return this.state.savedLiveIds.has(liveId);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Keep a tracked snapshot current while its request is still in flight —
|
|
366
|
+
* pinning a pending request would otherwise freeze it as "Pending" forever.
|
|
367
|
+
* Called from `networkEventStore` for every response/error, so the no-op
|
|
368
|
+
* path (nothing pinned) is a single empty-Set check.
|
|
369
|
+
*/
|
|
370
|
+
syncFromLive(event) {
|
|
371
|
+
if (this.trackedLiveIds.size === 0) return;
|
|
372
|
+
if (!this.trackedLiveIds.has(event.id)) return;
|
|
373
|
+
const index = this.records.findIndex(record => this.sessionMatches(record) && record.liveId === event.id);
|
|
374
|
+
if (index === -1) return;
|
|
375
|
+
const next = snapshot(event);
|
|
376
|
+
this.commit(this.records.map((record, i) => i === index ? {
|
|
377
|
+
...record,
|
|
378
|
+
event: next.event,
|
|
379
|
+
bodyTruncated: next.bodyTruncated
|
|
380
|
+
} : record));
|
|
381
|
+
}
|
|
382
|
+
togglePin(event) {
|
|
383
|
+
return this.setFlag(event, "pinned");
|
|
384
|
+
}
|
|
385
|
+
toggleSave(event) {
|
|
386
|
+
return this.setFlag(event, "saved");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Drop a record outright, whatever its flags. */
|
|
390
|
+
remove(key) {
|
|
391
|
+
if (!this.records.some(record => record.key === key)) return;
|
|
392
|
+
this.remote?.removeRecord(key);
|
|
393
|
+
this.commit(this.records.filter(record => record.key !== key));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Empty the favorites list; pinned-only records stay pinned. */
|
|
397
|
+
clearSaved() {
|
|
398
|
+
if (!this.records.some(record => record.saved)) return;
|
|
399
|
+
this.remote?.clearSaved();
|
|
400
|
+
this.commit(this.records.filter(record => record.pinned).map(record => ({
|
|
401
|
+
...record,
|
|
402
|
+
saved: false
|
|
403
|
+
})));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Unpin everything; saved-only records stay saved. */
|
|
407
|
+
clearPinned() {
|
|
408
|
+
if (!this.records.some(record => record.pinned)) return;
|
|
409
|
+
this.remote?.clearPinned();
|
|
410
|
+
this.commit(this.records.filter(record => record.saved).map(record => ({
|
|
411
|
+
...record,
|
|
412
|
+
pinned: false
|
|
413
|
+
})));
|
|
414
|
+
}
|
|
415
|
+
setFlag(event, flag) {
|
|
416
|
+
// Gate every write on a real event — see isStorableEvent. Rejecting here is
|
|
417
|
+
// what keeps a bad caller from turning into persisted, crash-looping state.
|
|
418
|
+
if (!isStorableEvent(event)) return {
|
|
419
|
+
ok: false,
|
|
420
|
+
reason: "invalid-event"
|
|
421
|
+
};
|
|
422
|
+
const existing = this.findRecordFor(event);
|
|
423
|
+
|
|
424
|
+
// Mirror mode: tell the device first, then apply the same change locally so
|
|
425
|
+
// the button responds now instead of a snapshot later.
|
|
426
|
+
if (this.remote) {
|
|
427
|
+
const next = existing ? !existing[flag] : true;
|
|
428
|
+
if (flag === "pinned") this.remote.setPinned(event.id, next);else this.remote.setSaved(event.id, next);
|
|
429
|
+
this.remoteEchoUntil = Date.now() + REMOTE_ECHO_GRACE_MS;
|
|
430
|
+
}
|
|
431
|
+
if (existing) {
|
|
432
|
+
const next = !existing[flag];
|
|
433
|
+
if (next && !this.hasRoomFor(flag, existing.key)) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
reason: flag === "pinned" ? "pin-cap" : "saved-cap"
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const updated = {
|
|
440
|
+
...existing,
|
|
441
|
+
[flag]: next
|
|
442
|
+
};
|
|
443
|
+
this.commit(updated.pinned || updated.saved ? this.records.map(record => record.key === existing.key ? updated : record) : this.records.filter(record => record.key !== existing.key));
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
active: next
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (!this.hasRoomFor(flag, null)) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
reason: flag === "pinned" ? "pin-cap" : "saved-cap"
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const savedAt = Date.now();
|
|
456
|
+
const frozen = snapshot(event);
|
|
457
|
+
const record = {
|
|
458
|
+
key: `${SESSION_ID}_${++this.keyCounter}`,
|
|
459
|
+
event: frozen.event,
|
|
460
|
+
pinned: flag === "pinned",
|
|
461
|
+
saved: flag === "saved",
|
|
462
|
+
savedAt,
|
|
463
|
+
sessionId: SESSION_ID,
|
|
464
|
+
liveId: event.id,
|
|
465
|
+
bodyTruncated: frozen.bodyTruncated
|
|
466
|
+
};
|
|
467
|
+
this.commit(this.evictIfNeeded([record, ...this.records]));
|
|
468
|
+
return {
|
|
469
|
+
ok: true,
|
|
470
|
+
active: true
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Match an event to its record: by live ID within this session, or by the
|
|
476
|
+
* namespaced snapshot ID when the row being toggled IS a restored snapshot.
|
|
477
|
+
*/
|
|
478
|
+
findRecordFor(event) {
|
|
479
|
+
return this.records.find(record => record.event.id === event.id || this.sessionMatches(record) && record.liveId === event.id);
|
|
480
|
+
}
|
|
481
|
+
hasRoomFor(flag, exceptKey) {
|
|
482
|
+
const limit = flag === "pinned" ? MAX_PINNED : MAX_SAVED;
|
|
483
|
+
const count = this.records.filter(record => record[flag] && record.key !== exceptKey).length;
|
|
484
|
+
return count < limit;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Trim the oldest UNPINNED saves once the list outgrows MAX_SAVED. */
|
|
488
|
+
evictIfNeeded(records) {
|
|
489
|
+
const saved = records.filter(record => record.saved);
|
|
490
|
+
if (saved.length <= MAX_SAVED) return records;
|
|
491
|
+
const evictable = saved.filter(record => !record.pinned).sort((a, b) => a.savedAt - b.savedAt).slice(0, saved.length - MAX_SAVED);
|
|
492
|
+
if (evictable.length === 0) return records;
|
|
493
|
+
const evicted = new Set(evictable.map(record => record.key));
|
|
494
|
+
return records.filter(record => !evicted.has(record.key));
|
|
495
|
+
}
|
|
496
|
+
ensureLoaded() {
|
|
497
|
+
// Mirror mode reads the device's records, never this machine's storage.
|
|
498
|
+
if (this.remote) return Promise.resolve();
|
|
499
|
+
if (this.loaded) return Promise.resolve();
|
|
500
|
+
if (this.loadPromise) return this.loadPromise;
|
|
501
|
+
this.loadPromise = (async () => {
|
|
502
|
+
try {
|
|
503
|
+
const stored = await persistentStorage.getItem(devToolsStorageKeys.network.saved());
|
|
504
|
+
// A pin may have raced this read — never overwrite user intent.
|
|
505
|
+
if (stored && !this.dirty) {
|
|
506
|
+
this.records = parseRecords(JSON.parse(stored));
|
|
507
|
+
this.rebuild();
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// Corrupt or unreadable — start empty rather than crashing the tool.
|
|
511
|
+
} finally {
|
|
512
|
+
this.loaded = true;
|
|
513
|
+
this.emit();
|
|
514
|
+
}
|
|
515
|
+
})();
|
|
516
|
+
return this.loadPromise;
|
|
517
|
+
}
|
|
518
|
+
persist() {
|
|
519
|
+
// The dashboard must not write the device's pins into its own storage —
|
|
520
|
+
// they'd outlive the connection and shadow the next device.
|
|
521
|
+
if (this.remote) return;
|
|
522
|
+
persistentStorage.setItem(devToolsStorageKeys.network.saved(), JSON.stringify(this.records)).catch(() => {
|
|
523
|
+
// Silently fail — records remain in memory for this session.
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
commit(next) {
|
|
527
|
+
this.dirty = true;
|
|
528
|
+
// The budget is applied at COMMIT, not at persist, so what the UI shows is
|
|
529
|
+
// exactly what survives a reload — no silently-vanishing bodies.
|
|
530
|
+
this.records = applyBodyBudget(next);
|
|
531
|
+
this.rebuild();
|
|
532
|
+
this.emit();
|
|
533
|
+
this.persist();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Rebuild the derived view once per mutation so renders get stable refs. */
|
|
537
|
+
rebuild() {
|
|
538
|
+
const pinnedRecords = [];
|
|
539
|
+
const savedRecords = [];
|
|
540
|
+
const pinnedLiveIds = new Set();
|
|
541
|
+
const savedLiveIds = new Set();
|
|
542
|
+
const tracked = new Set();
|
|
543
|
+
for (const record of this.records) {
|
|
544
|
+
if (record.pinned) pinnedRecords.push(record);
|
|
545
|
+
if (record.saved) savedRecords.push(record);
|
|
546
|
+
if (!this.sessionMatches(record) || !record.liveId) continue;
|
|
547
|
+
tracked.add(record.liveId);
|
|
548
|
+
if (record.pinned) pinnedLiveIds.add(record.liveId);
|
|
549
|
+
if (record.saved) savedLiveIds.add(record.liveId);
|
|
550
|
+
}
|
|
551
|
+
this.trackedLiveIds = tracked;
|
|
552
|
+
this.state = {
|
|
553
|
+
records: this.records,
|
|
554
|
+
pinnedEvents: pinnedRecords.map(record => record.event),
|
|
555
|
+
savedRecords,
|
|
556
|
+
pinnedLiveIds,
|
|
557
|
+
savedLiveIds
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
emit() {
|
|
561
|
+
this.listeners.forEach(listener => listener());
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Singleton — one instance per JS runtime, shared by the mobile modal, the
|
|
567
|
+
* desktop inline port and the sync adapter.
|
|
568
|
+
*/
|
|
569
|
+
export const networkSavedStore = new NetworkSavedStore();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split a filtered event list into its PINNED section and the rest.
|
|
5
|
+
*
|
|
6
|
+
* Lives as a pure function because there are TWO list implementations — the
|
|
7
|
+
* mobile modal's `NetworkListScreen` and the desktop dashboard's hand-mirrored
|
|
8
|
+
* `NetworkInline` — and pin ordering silently diverging between them is
|
|
9
|
+
* exactly the drift `buoy.tools.json` exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* Pins are deliberately NOT filtered: they come straight from the saved store,
|
|
12
|
+
* so a pinned request stays visible no matter what the search box or the
|
|
13
|
+
* status chips say. The only thing the filtered list contributes is the
|
|
14
|
+
* dedupe — a pinned request that is still live must not render twice.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const EMPTY = [];
|
|
18
|
+
export function splitPinned(filtered, pinnedEvents, /** Live IDs pinned in THIS session — from `networkSavedStore.getState()`. */
|
|
19
|
+
pinnedLiveIds) {
|
|
20
|
+
if (pinnedEvents.length === 0) return {
|
|
21
|
+
pinned: EMPTY,
|
|
22
|
+
rest: filtered
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
pinned: pinnedEvents,
|
|
26
|
+
rest: pinnedLiveIds.size === 0 ? filtered : filtered.filter(event => !pinnedLiveIds.has(event.id))
|
|
27
|
+
};
|
|
28
|
+
}
|