/** * FetchRequestLogState: holds the fetch request log, subscribes to the protocol * "fetchRequest" event. Protocol emits per-entry; this manager owns the list and * emits both `fetchRequest` (single entry) and `fetchRequestsChange` (full list) * on append, and `fetchRequestsChange` on clear. Does not clear on connect or * disconnect. * * When `sessionStorage` and `sessionId` are provided, restores fetch requests * from storage on creation and listens for the client's `saveSession` event to * persist before OAuth redirect. * * Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the * concrete `InspectorClient` since the runtime class is not yet ported. */ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { FetchRequestEntry } from "../types.js"; import type { InspectorClientStorage, InspectorClientSessionState, } from "../sessionStorage.js"; import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js"; import { TypedEventTarget, type TypedEventGeneric, } from "../typedEventTarget.js"; export interface FetchRequestLogStateEventMap { fetchRequest: FetchRequestEntry; fetchRequestsChange: FetchRequestEntry[]; } export interface FetchRequestLogStateOptions { /** * Maximum number of fetch requests to store (0 = unlimited, not recommended). * When exceeded, oldest entries are dropped. Default 1000. */ maxFetchRequests?: number; /** * When provided with sessionId, fetch requests are restored from storage on * creation and saved when the client dispatches `saveSession` (e.g. before * OAuth redirect). */ sessionStorage?: InspectorClientStorage; /** Session ID for load/save; required for sessionStorage to have effect. */ sessionId?: string; } export class FetchRequestLogState extends TypedEventTarget { private fetchRequests: FetchRequestEntry[] = []; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; private readonly maxFetchRequests: number; constructor( client: InspectorClientProtocol, options: FetchRequestLogStateOptions = {}, ) { super(); this.maxFetchRequests = options.maxFetchRequests ?? 1000; this.client = client; const onFetchRequest = ( event: TypedEventGeneric, ): void => { const entry = event.detail; if ( this.maxFetchRequests > 0 && this.fetchRequests.length >= this.maxFetchRequests ) { this.fetchRequests.shift(); } this.fetchRequests.push(entry); this.dispatchTypedEvent("fetchRequest", entry); this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); }; this.client.addEventListener("fetchRequest", onFetchRequest); // Body is read asynchronously by fetchTracking and arrives via a // separate event; patch the matching entry in place and re-emit so // React subscribers re-render with the body filled in. const onFetchRequestBodyUpdate = ( event: TypedEventGeneric< InspectorClientEventMap, "fetchRequestBodyUpdate" >, ): void => { const { id, responseBody } = event.detail; const idx = this.fetchRequests.findIndex((e) => e.id === id); if (idx === -1) return; this.fetchRequests[idx] = { ...this.fetchRequests[idx]!, responseBody, }; // Body fill-in re-emits the list event only, not the per-entry // `fetchRequest` event (that one fires once, on append). Consumers read // the full list (`useFetchRequests`), so they pick up the body on the // next list re-render. A future per-entry subscriber wanting incremental // body updates would need its own event here. this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); }; this.client.addEventListener( "fetchRequestBodyUpdate", onFetchRequestBodyUpdate, ); const sessionStorage = options.sessionStorage; const sessionId = options.sessionId; if (sessionStorage) { // Backstop persistence on the client's `saveSession` event. For the // OAuth full-page-redirect case the web client's `BrowserNavigation` // `beforeNavigate` hook is the *primary* flush (it runs synchronously // before navigation, so a keepalive request survives the unload); this // listener fires from the same event but can lose the race with an // already-scheduled navigation. It remains the save path for any // non-redirect `saveSession` caller (e.g. a future token-refresh save // point) and is harmless when it duplicates the primary flush — // last-writer-wins on an identical payload under the same id. // // SECURITY TRIPWIRE: captured `auth`-category response bodies in this log // are stored UNMASKED (masking is a Network-UI display concern only). // Today the only `saveSession` trigger is the pre-redirect flush, which // runs before the `/token` exchange — so no bearer token is ever // persisted. Any NEW `saveSession` trigger added after token exchange // (e.g. a periodic snapshot) would write `access_token` / // `refresh_token` to disk. Redact response bodies here before persisting // if that ever changes. const onSaveSession = ( event: TypedEventGeneric, ): void => { const { sessionId: id } = event.detail; const state: InspectorClientSessionState = { fetchRequests: this.getFetchRequests(), createdAt: Date.now(), updatedAt: Date.now(), }; sessionStorage.saveSession(id, state).catch(() => { // fire-and-forget; storage may log internally }); }; this.client.addEventListener("saveSession", onSaveSession); this.unsubscribe = () => { if (this.client) { this.client.removeEventListener("fetchRequest", onFetchRequest); this.client.removeEventListener( "fetchRequestBodyUpdate", onFetchRequestBodyUpdate, ); this.client.removeEventListener("saveSession", onSaveSession); } this.client = null; }; if (sessionId) { sessionStorage .loadSession(sessionId) .then((state) => { if (this.client && state?.fetchRequests?.length) { this.hydrateFetchRequests(state.fetchRequests); } }) .catch(() => { // fire-and-forget; storage may log internally. Matches the // saveSession swallow above so a corrupt or unreadable session // doesn't surface as an unhandled rejection. }); } } else { this.unsubscribe = () => { if (this.client) { this.client.removeEventListener("fetchRequest", onFetchRequest); this.client.removeEventListener( "fetchRequestBodyUpdate", onFetchRequestBodyUpdate, ); } this.client = null; }; } } // Restore persisted entries (e.g. the pre-redirect OAuth Network log loaded // on the `/oauth/callback` page). Merges rather than replaces: the async // load races against entries appended live by the resuming connect // (`completeOAuthFlow` + transport handshake), so we must not clobber // whichever arrived first. Restored entries are older, so they go in front; // duplicates (by id) already present from a live append are skipped. private hydrateFetchRequests(entries: FetchRequestEntry[]): void { if (entries.length === 0) return; const existingIds = new Set(this.fetchRequests.map((e) => e.id)); const restored = entries.filter((e) => !existingIds.has(e.id)); if (restored.length === 0) return; const merged = [...restored, ...this.fetchRequests]; this.fetchRequests = this.maxFetchRequests > 0 ? merged.slice(-this.maxFetchRequests) : merged; this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); } getFetchRequests(): FetchRequestEntry[] { return [...this.fetchRequests]; } /** * Clear all fetch requests. Dispatches fetchRequestsChange only if the list * was non-empty. */ clearFetchRequests(): void { if (this.fetchRequests.length === 0) return; this.fetchRequests = []; this.dispatchTypedEvent("fetchRequestsChange", []); } destroy(): void { this.unsubscribe?.(); this.unsubscribe = null; this.fetchRequests = []; } }