From 6671e279721cc376f51c5f43025f7e31ed97682c Mon Sep 17 00:00:00 2001 From: nafees87n Date: Fri, 29 May 2026 00:02:58 +0530 Subject: [PATCH 01/23] feat(extension): add network recording for BrowserStack LT integration Add network interception recording via chrome.webRequest.onCompleted + onErrorOccurred. Captures all resource types (XHR, fetch, script, stylesheet, image, font, document) with metadata (URL, method, status, type, size). - NetworkRecordingService in service worker with start/stop lifecycle, tab-scoped event buffering, and max duration auto-stop - External messaging API (startNetworkRecording/stopNetworkRecording) via existing externally_connectable for BrowserStack domains - Chrome sidepanel UI with live request list, summary counters, text + method filters, recording timer - URL validation, error propagation, tab close cleanup with event recovery to sender tab - Sidepanel scoped to recorded tab only (disabled globally by default) - Guards for chrome.sidePanel API (Firefox/Safari compatibility) - Build setup: Rollup entry for sidepanel, manifest sidePanel permission - Test harness on localhost:3099 (dev builds only) Jira: RQ-2895 Co-Authored-By: Claude Opus 4.6 (1M context) --- browser-extension/common/rollup.config.js | 30 ++ browser-extension/common/src/constants.ts | 5 + .../NetworkRecordingPanel.tsx | 180 ++++++++++ .../components/FilterBar.tsx | 54 +++ .../components/NetworkEventRow.tsx | 61 ++++ .../src/sidepanel/network-recording/index.css | 317 ++++++++++++++++++ .../sidepanel/network-recording/index.html | 12 + .../src/sidepanel/network-recording/index.tsx | 7 + .../src/sidepanel/network-recording/types.ts | 14 + browser-extension/mv3/rollup.config.js | 5 + .../mv3/src/manifest.chrome.json | 4 + browser-extension/mv3/src/manifest.edge.json | 4 + .../services/messageHandler/listener.ts | 23 ++ .../services/networkRecording.ts | 257 ++++++++++++++ .../src/service-worker/services/tabService.ts | 1 + .../mv3/test/network-recording-test.html | 189 +++++++++++ browser-extension/mv3/test/serve.js | 21 ++ 17 files changed, 1184 insertions(+) create mode 100644 browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx create mode 100644 browser-extension/common/src/sidepanel/network-recording/components/FilterBar.tsx create mode 100644 browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx create mode 100644 browser-extension/common/src/sidepanel/network-recording/index.css create mode 100644 browser-extension/common/src/sidepanel/network-recording/index.html create mode 100644 browser-extension/common/src/sidepanel/network-recording/index.tsx create mode 100644 browser-extension/common/src/sidepanel/network-recording/types.ts create mode 100644 browser-extension/mv3/src/service-worker/services/networkRecording.ts create mode 100644 browser-extension/mv3/test/network-recording-test.html create mode 100644 browser-extension/mv3/test/serve.js diff --git a/browser-extension/common/rollup.config.js b/browser-extension/common/rollup.config.js index bd07063ee0..e06983f28b 100644 --- a/browser-extension/common/rollup.config.js +++ b/browser-extension/common/rollup.config.js @@ -127,6 +127,36 @@ export default [ }, plugins: [...commonPlugins, nodeResolve()], }, + { + ...commonConfig, + input: "src/sidepanel/network-recording/index.tsx", + output: { + file: `${OUTPUT_DIR}/sidepanel/network-recording/index.js`, + format: "iife", + }, + context: "window", + plugins: [ + copy({ + targets: [ + { + src: "src/sidepanel/network-recording/index.html", + dest: `${OUTPUT_DIR}/sidepanel/network-recording`, + }, + ], + }), + nodeResolve(), + replace({ + preventAssignment: true, + "process.env.NODE_ENV": JSON.stringify("production"), + }), + ...commonPlugins, + commonjs(), + postcss({ + extract: true, + }), + svgr(), + ], + }, { ...commonConfig, input: "src/custom-elements/index.ts", diff --git a/browser-extension/common/src/constants.ts b/browser-extension/common/src/constants.ts index 66536f8653..193528bfd9 100644 --- a/browser-extension/common/src/constants.ts +++ b/browser-extension/common/src/constants.ts @@ -43,10 +43,14 @@ export const EXTENSION_MESSAGES = { DESKTOP_APP_CONNECTION_STATUS_UPDATED: "desktopAppConnectionStatusUpdated", IS_SESSION_REPLAY_ENABLED: "isSessionReplayEnabled", TRIGGER_OPEN_CURL_MODAL: "triggerOpenCurlModal", + STOP_NETWORK_RECORDING: "stopNetworkRecording", + GET_NETWORK_RECORDING_STATE: "getNetworkRecordingState", }; export const EXTENSION_EXTERNAL_MESSAGES = { GET_EXTENSION_METADATA: "getExtensionMetadata", + START_NETWORK_RECORDING: "startNetworkRecording", + STOP_NETWORK_RECORDING: "stopNetworkRecording", }; export const CLIENT_MESSAGES = { @@ -72,6 +76,7 @@ export const CLIENT_MESSAGES = { NOTIFY_RECORD_UPDATED: "notifyRecordUpdated", NOTIFY_EXTENSION_STATUS_UPDATED: "notifyExtensionStatusUpdated", OPEN_CURL_IMPORT_MODAL: "openCurlImportModal", + NETWORK_EVENT_CAPTURED: "networkEventCaptured", }; export const STORAGE_TYPE = "local"; diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx new file mode 100644 index 0000000000..9b65545d70 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -0,0 +1,180 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { NetworkRecordingEvent } from "./types"; +import NetworkEventRow from "./components/NetworkEventRow"; +import FilterBar from "./components/FilterBar"; + +const RESOURCE_TYPE_DISPLAY: Record = { + xmlhttprequest: "xhr", + main_frame: "document", + sub_frame: "document", + stylesheet: "css", + script: "js", + image: "img", + font: "font", + media: "media", + websocket: "ws", + other: "other", + fetch: "fetch", +}; + +const formatTime = (ms: number): string => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +}; + +const formatSize = (bytes: number | undefined): string => { + if (bytes === undefined) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const NetworkRecordingPanel: React.FC = () => { + const [events, setEvents] = useState([]); + const [filter, setFilter] = useState({ text: "", method: "ALL" }); + const [recordingStartTime, setRecordingStartTime] = useState(Date.now()); + const [isRecording, setIsRecording] = useState(true); + const [elapsedTime, setElapsedTime] = useState(0); + const [targetUrl, setTargetUrl] = useState(""); + const currentTabIdRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + const init = async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) return; + currentTabIdRef.current = tab.id; + try { + setTargetUrl(new URL(tab.url).hostname); + } catch { + setTargetUrl(tab.url || ""); + } + + chrome.runtime.sendMessage({ action: "getNetworkRecordingState", tabId: tab.id }, (response) => { + if (response?.active) { + setEvents(response.events || []); + setRecordingStartTime(response.startTime); + setIsRecording(true); + } + }); + }; + + init(); + + const listener = (message: any) => { + if (message.action === "networkEventCaptured" && message.tabId === currentTabIdRef.current) { + setEvents((prev) => [...prev, message.event]); + } + }; + + chrome.runtime.onMessage.addListener(listener); + return () => chrome.runtime.onMessage.removeListener(listener); + }, []); + + useEffect(() => { + if (!isRecording) return undefined; + + const interval = setInterval(() => { + setElapsedTime(Date.now() - recordingStartTime); + }, 1000); + + return () => clearInterval(interval); + }, [isRecording, recordingStartTime]); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [events.length]); + + const handleStop = useCallback(() => { + chrome.runtime.sendMessage({ + action: "stopNetworkRecording", + targetTabId: currentTabIdRef.current, + }); + setIsRecording(false); + }, []); + + const filteredEvents = useMemo(() => { + return events.filter((event) => { + if (filter.method !== "ALL" && event.method !== filter.method) return false; + if (filter.text && !event.url.toLowerCase().includes(filter.text.toLowerCase())) return false; + return true; + }); + }, [events, filter]); + + const counts = useMemo(() => { + const total = filteredEvents.length; + const xhr = filteredEvents.filter((e) => e.type === "xmlhttprequest" || e.type === "fetch").length; + const docs = filteredEvents.filter((e) => e.type === "main_frame" || e.type === "sub_frame").length; + const staticCount = filteredEvents.filter((e) => ["script", "stylesheet", "image", "font"].includes(e.type)).length; + return { total, xhr, docs, static: staticCount }; + }, [filteredEvents]); + + return ( +
+
+
+
+ {isRecording && } + {isRecording ? "Recording" : "Stopped"} + {formatTime(elapsedTime)} +
+ {isRecording && ( + + )} +
+ {targetUrl &&
{targetUrl}
} +
+ +
+
+ {counts.total} + Total +
+
+ {counts.xhr} + XHR +
+
+ {counts.docs} + Docs +
+
+ {counts.static} + Static +
+
+ + + +
+ {filteredEvents.map((event) => ( + + ))} + {filteredEvents.length === 0 && ( +
+ {events.length === 0 ? "Waiting for network requests..." : "No requests match the current filter"} +
+ )} +
+ +
+ Sending live updates to BrowserStack Load Testing + v{chrome.runtime.getManifest().version} +
+
+ ); +}; + +export default NetworkRecordingPanel; diff --git a/browser-extension/common/src/sidepanel/network-recording/components/FilterBar.tsx b/browser-extension/common/src/sidepanel/network-recording/components/FilterBar.tsx new file mode 100644 index 0000000000..bf9f6464f8 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/components/FilterBar.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +const METHODS = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] as const; + +interface FilterBarProps { + filter: { text: string; method: string }; + onFilterChange: (filter: { text: string; method: string }) => void; +} + +const FilterBar: React.FC = ({ filter, onFilterChange }) => { + return ( +
+
+ + + + + onFilterChange({ ...filter, text: e.target.value })} + /> + {filter.text && ( + + )} +
+
+ {METHODS.map((method) => ( + + ))} +
+
+ ); +}; + +export default FilterBar; diff --git a/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx new file mode 100644 index 0000000000..27b20142eb --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { NetworkRecordingEvent } from "../types"; + +const METHOD_COLORS: Record = { + GET: "#4CAF50", + POST: "#2196F3", + PUT: "#FF9800", + PATCH: "#FF9800", + DELETE: "#F44336", + OPTIONS: "#9E9E9E", + HEAD: "#9E9E9E", +}; + +const getStatusColor = (statusCode: number): string => { + if (statusCode === 0) return "#F44336"; + if (statusCode < 300) return "#4CAF50"; + if (statusCode < 400) return "#2196F3"; + if (statusCode < 500) return "#FF9800"; + return "#F44336"; +}; + +const getUrlPath = (url: string): string => { + try { + const parsed = new URL(url); + return parsed.pathname + parsed.search; + } catch { + return url; + } +}; + +interface NetworkEventRowProps { + event: NetworkRecordingEvent; + typeDisplay: string; + formatSize: (bytes: number | undefined) => string; +} + +const NetworkEventRow: React.FC = ({ event, typeDisplay, formatSize }) => { + return ( +
+
+ + {event.method} + + + {getUrlPath(event.url)} + +
+
+ + {event.state === "error" ? event.error || "Error" : event.statusCode} + + · + {typeDisplay} + · + {formatSize(event.contentLength)} +
+
+ ); +}; + +export default NetworkEventRow; diff --git a/browser-extension/common/src/sidepanel/network-recording/index.css b/browser-extension/common/src/sidepanel/network-recording/index.css new file mode 100644 index 0000000000..4f9f07a60a --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/index.css @@ -0,0 +1,317 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #1a1a1a; + color: #ffffff; + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + overflow: hidden; +} + +#root { + height: 100vh; + display: flex; + flex-direction: column; +} + +.network-panel { + display: flex; + flex-direction: column; + height: 100%; +} + +.panel-header { + padding: 12px 16px; + background: #212121; + border-bottom: 1px solid #333; +} + +.header-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.recording-status { + display: flex; + align-items: center; + gap: 8px; +} + +.recording-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #e43434; + animation: blink 1s cubic-bezier(0.5, 0, 1, 1) infinite alternate; +} + +@keyframes blink { + from { + opacity: 1; + } + to { + opacity: 0.3; + } +} + +.recording-label { + font-weight: 600; + font-size: 14px; +} + +.recording-time { + color: #9e9e9e; + font-variant-numeric: tabular-nums; +} + +.stop-btn { + display: flex; + align-items: center; + gap: 4px; + background: #e43434; + color: #fff; + border: none; + border-radius: 4px; + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.stop-btn:hover { + background: #c62828; +} + +.stop-icon { + width: 10px; + height: 10px; + background: #fff; + border-radius: 2px; +} + +.target-url { + color: #9e9e9e; + font-size: 12px; + margin-top: 4px; + font-family: monospace; +} + +.summary-counters { + display: flex; + padding: 8px 16px; + gap: 8px; + border-bottom: 1px solid #333; +} + +.counter { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px; + background: #2a2a2a; + border-radius: 6px; + border: 1px solid #333; +} + +.counter-value { + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.counter-label { + font-size: 11px; + color: #9e9e9e; + margin-top: 2px; +} + +.filter-bar { + padding: 8px 16px; + border-bottom: 1px solid #333; +} + +.filter-input-wrapper { + position: relative; + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.search-icon { + position: absolute; + left: 10px; + pointer-events: none; +} + +.filter-input { + width: 100%; + background: #2a2a2a; + border: 1px solid #333; + border-radius: 6px; + color: #fff; + padding: 8px 30px 8px 32px; + font-size: 13px; + outline: none; +} + +.filter-input:focus { + border-color: #4caf50; +} + +.filter-input::placeholder { + color: #666; +} + +.filter-clear { + position: absolute; + right: 8px; + background: none; + border: none; + color: #9e9e9e; + cursor: pointer; + font-size: 16px; + padding: 2px 4px; +} + +.method-chips { + display: flex; + gap: 6px; +} + +.method-chip { + background: #2a2a2a; + border: 1px solid #333; + border-radius: 16px; + color: #9e9e9e; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.method-chip:hover { + border-color: #555; + color: #fff; +} + +.method-chip--active { + border-color: #4caf50; + color: #4caf50; + background: rgba(76, 175, 80, 0.1); +} + +.request-list { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.request-list::-webkit-scrollbar { + width: 6px; +} + +.request-list::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.request-list::-webkit-scrollbar-thumb { + background: #444; + border-radius: 3px; +} + +.network-row { + padding: 8px 16px; + border-bottom: 1px solid #2a2a2a; + cursor: default; +} + +.network-row:hover { + background: #2a2a2a; +} + +.network-row--error { + opacity: 0.7; +} + +.row-main { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; +} + +.method-badge { + display: inline-block; + min-width: 48px; + text-align: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + color: #fff; + text-transform: uppercase; +} + +.row-url { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; + font-size: 13px; +} + +.row-details { + display: flex; + align-items: center; + gap: 6px; + padding-left: 56px; + font-size: 12px; + color: #9e9e9e; +} + +.row-status { + font-weight: 600; +} + +.row-separator { + color: #555; +} + +.row-type { + color: #9e9e9e; +} + +.row-size { + color: #9e9e9e; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 16px; + color: #666; + font-size: 13px; +} + +.panel-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-top: 1px solid #333; + color: #9e9e9e; + font-size: 11px; + background: #212121; +} + +.version { + color: #666; +} diff --git a/browser-extension/common/src/sidepanel/network-recording/index.html b/browser-extension/common/src/sidepanel/network-recording/index.html new file mode 100644 index 0000000000..6e26a4b358 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/index.html @@ -0,0 +1,12 @@ + + + + + Network Recording + + + +
+ + + diff --git a/browser-extension/common/src/sidepanel/network-recording/index.tsx b/browser-extension/common/src/sidepanel/network-recording/index.tsx new file mode 100644 index 0000000000..3010d7994b --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/index.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import NetworkRecordingPanel from "./NetworkRecordingPanel"; +import "./index.css"; + +const root = createRoot(document.getElementById("root")); +root.render(); diff --git a/browser-extension/common/src/sidepanel/network-recording/types.ts b/browser-extension/common/src/sidepanel/network-recording/types.ts new file mode 100644 index 0000000000..e375ea9aeb --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/types.ts @@ -0,0 +1,14 @@ +export interface NetworkRecordingEvent { + requestId: string; + url: string; + method: string; + type: string; + statusCode: number; + timeStamp: number; + fromCache: boolean; + ip?: string; + contentLength?: number; + contentType?: string; + state: "complete" | "error"; + error?: string; +} diff --git a/browser-extension/mv3/rollup.config.js b/browser-extension/mv3/rollup.config.js index b92612b2ce..3dcd28aa0f 100644 --- a/browser-extension/mv3/rollup.config.js +++ b/browser-extension/mv3/rollup.config.js @@ -50,6 +50,10 @@ const processManifest = (content) => { }, }, }; + + if (manifestJson.externally_connectable?.matches) { + manifestJson.externally_connectable.matches.push("http://localhost:3099/*"); + } } return JSON.stringify(manifestJson, null, 2); @@ -99,6 +103,7 @@ export default [ }, { src: "../common/dist/devtools", dest: OUTPUT_DIR }, { src: "../common/dist/popup", dest: OUTPUT_DIR }, + { src: "../common/dist/sidepanel", dest: OUTPUT_DIR }, { src: "../common/dist/lib/customElements.js", dest: `${OUTPUT_DIR}/libs` }, ], }), diff --git a/browser-extension/mv3/src/manifest.chrome.json b/browser-extension/mv3/src/manifest.chrome.json index 0f2e7c5823..a03e6a332f 100644 --- a/browser-extension/mv3/src/manifest.chrome.json +++ b/browser-extension/mv3/src/manifest.chrome.json @@ -57,11 +57,15 @@ } ] }, + "side_panel": { + "default_path": "sidepanel/network-recording/index.html" + }, "permissions": [ "contextMenus", "declarativeNetRequest", "proxy", "scripting", + "sidePanel", "storage", "tabs", "unlimitedStorage", diff --git a/browser-extension/mv3/src/manifest.edge.json b/browser-extension/mv3/src/manifest.edge.json index 0ba21f9a19..9c16b42f73 100644 --- a/browser-extension/mv3/src/manifest.edge.json +++ b/browser-extension/mv3/src/manifest.edge.json @@ -57,11 +57,15 @@ } ] }, + "side_panel": { + "default_path": "sidepanel/network-recording/index.html" + }, "permissions": [ "contextMenus", "declarativeNetRequest", "proxy", "scripting", + "sidePanel", "storage", "tabs", "unlimitedStorage", diff --git a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts index e7a711ab42..67820f852e 100644 --- a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts +++ b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts @@ -33,6 +33,12 @@ import { import { sendMessageToApp } from "./sender"; import { triggerOpenCurlModalMessage, updateExtensionStatus } from "../utils"; import extensionIconManager from "../extensionIconManager"; +import { + startNetworkRecording, + stopNetworkRecording, + getNetworkRecordingState, + handleNetworkRecordingOnClientPageLoad, +} from "../networkRecording"; export const initExternalMessageListener = () => { chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { @@ -43,6 +49,14 @@ export const initExternalMessageListener = () => { version: chrome.runtime.getManifest().version, }); break; + + case EXTENSION_EXTERNAL_MESSAGES.START_NETWORK_RECORDING: + startNetworkRecording(sender.tab?.id, message.url, message.config || {}).then(sendResponse); + return true; + + case EXTENSION_EXTERNAL_MESSAGES.STOP_NETWORK_RECORDING: + sendResponse(stopNetworkRecording(message.targetTabId)); + break; } }); }; @@ -63,6 +77,7 @@ export const initMessageHandler = () => { ruleExecutionHandler.processTabCachedRulesExecutions(sender.tab.id); handleTestRuleOnClientPageLoad(sender.tab); handleSessionRecordingOnClientPageLoad(sender.tab, sender.frameId); + handleNetworkRecordingOnClientPageLoad(sender.tab); break; case EXTENSION_MESSAGES.INIT_SESSION_RECORDER: @@ -227,6 +242,14 @@ export const initMessageHandler = () => { case EXTENSION_MESSAGES.TRIGGER_OPEN_CURL_MODAL: triggerOpenCurlModalMessage({}, message.source); break; + + case EXTENSION_MESSAGES.STOP_NETWORK_RECORDING: + stopNetworkRecording(message.targetTabId || sender.tab?.id); + break; + + case EXTENSION_MESSAGES.GET_NETWORK_RECORDING_STATE: + sendResponse(getNetworkRecordingState(message.tabId || sender.tab?.id)); + return true; } return false; diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording.ts b/browser-extension/mv3/src/service-worker/services/networkRecording.ts new file mode 100644 index 0000000000..63b1df68f3 --- /dev/null +++ b/browser-extension/mv3/src/service-worker/services/networkRecording.ts @@ -0,0 +1,257 @@ +import { tabService, TAB_SERVICE_DATA } from "./tabService"; +import { CLIENT_MESSAGES } from "common/constants"; + +interface NetworkRecordingEvent { + requestId: string; + url: string; + method: string; + type: chrome.webRequest.ResourceType; + statusCode: number; + timeStamp: number; + fromCache: boolean; + ip?: string; + contentLength?: number; + contentType?: string; + state: "complete" | "error"; + error?: string; +} + +interface NetworkRecordingState { + senderTabId: number | undefined; + targetTabId: number; + startTime: number; + config: { showWidget?: boolean; maxDuration?: number }; +} + +const activeRecordings = new Map(); +const recordingEvents = new Map(); + +const hasSidePanelAPI = typeof chrome.sidePanel !== "undefined"; + +if (hasSidePanelAPI) { + chrome.sidePanel.setOptions({ enabled: false }).catch(() => {}); +} + +const DEFAULT_MAX_DURATION = 15 * 60 * 1000; + +const parseContentLength = (headers: chrome.webRequest.HttpHeader[] | undefined): number | undefined => { + if (!headers) return undefined; + const header = headers.find((h) => h.name.toLowerCase() === "content-length"); + if (!header?.value) return undefined; + const parsed = parseInt(header.value, 10); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const parseHeaderValue = (headers: chrome.webRequest.HttpHeader[] | undefined, name: string): string | undefined => { + if (!headers) return undefined; + const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase()); + return header?.value; +}; + +const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => { + const recording = activeRecordings.get(details.tabId); + if (!recording) return; + + const maxDuration = recording.config.maxDuration || DEFAULT_MAX_DURATION; + if (Date.now() - recording.startTime > maxDuration) { + stopNetworkRecording(details.tabId); + return; + } + + const event: NetworkRecordingEvent = { + requestId: details.requestId, + url: details.url, + method: details.method, + type: details.type, + statusCode: details.statusCode, + timeStamp: details.timeStamp, + fromCache: details.fromCache, + ip: details.ip, + contentLength: parseContentLength(details.responseHeaders), + contentType: parseHeaderValue(details.responseHeaders, "content-type"), + state: "complete", + }; + + recordingEvents.get(details.tabId)?.push(event); + + chrome.runtime + .sendMessage({ + action: CLIENT_MESSAGES.NETWORK_EVENT_CAPTURED, + event, + tabId: details.tabId, + }) + .catch(() => {}); +}; + +const IGNORED_ERRORS = new Set(["net::ERR_CACHE_MISS", "net::ERR_ABORTED", "net::ERR_BLOCKED_BY_CLIENT"]); + +const onRequestError = (details: chrome.webRequest.WebResponseErrorDetails) => { + const recording = activeRecordings.get(details.tabId); + if (!recording) return; + + if (IGNORED_ERRORS.has(details.error)) return; + + const event: NetworkRecordingEvent = { + requestId: details.requestId, + url: details.url, + method: details.method, + type: details.type, + statusCode: 0, + timeStamp: details.timeStamp, + fromCache: false, + state: "error", + error: details.error, + }; + + recordingEvents.get(details.tabId)?.push(event); + + chrome.runtime + .sendMessage({ + action: CLIENT_MESSAGES.NETWORK_EVENT_CAPTURED, + event, + tabId: details.tabId, + }) + .catch(() => {}); +}; + +const addWebRequestListeners = () => { + if (!chrome.webRequest.onCompleted.hasListener(onRequestCompleted)) { + chrome.webRequest.onCompleted.addListener(onRequestCompleted, { urls: [""] }, ["responseHeaders"]); + } + if (!chrome.webRequest.onErrorOccurred.hasListener(onRequestError)) { + chrome.webRequest.onErrorOccurred.addListener(onRequestError, { urls: [""] }); + } +}; + +const removeWebRequestListeners = () => { + chrome.webRequest.onCompleted.removeListener(onRequestCompleted); + chrome.webRequest.onErrorOccurred.removeListener(onRequestError); +}; + +const isValidUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +}; + +export const startNetworkRecording = ( + senderTabId: number | undefined, + url: string, + config: Record = {} +): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { + if (!url || !isValidUrl(url)) { + return Promise.resolve({ success: false, error: "Invalid URL. Must be a valid http or https URL." }); + } + + return new Promise((resolve) => { + chrome.tabs.create({ url }, (tab) => { + if (chrome.runtime.lastError || !tab?.id) { + resolve({ success: false, error: chrome.runtime.lastError?.message || "Failed to create tab" }); + return; + } + + const state: NetworkRecordingState = { + senderTabId, + targetTabId: tab.id, + startTime: Date.now(), + config, + }; + + activeRecordings.set(tab.id, state); + recordingEvents.set(tab.id, []); + tabService.setData(tab.id, TAB_SERVICE_DATA.NETWORK_RECORDING, { active: true, senderTabId }); + + addWebRequestListeners(); + + if (hasSidePanelAPI && config.showWidget !== false) { + chrome.sidePanel.setOptions({ + tabId: tab.id, + path: "sidepanel/network-recording/index.html", + enabled: true, + }); + chrome.sidePanel.open({ tabId: tab.id }).catch(() => {}); + } + + resolve({ success: true, targetTabId: tab.id }); + }); + }); +}; + +export const stopNetworkRecording = ( + targetTabId: number +): { success: boolean; events?: NetworkRecordingEvent[]; error?: string } => { + if (!activeRecordings.has(targetTabId)) { + return { success: false, error: `No active recording for tab ${targetTabId}` }; + } + + const events = recordingEvents.get(targetTabId) || []; + + activeRecordings.delete(targetTabId); + recordingEvents.delete(targetTabId); + tabService.removeData(targetTabId, TAB_SERVICE_DATA.NETWORK_RECORDING); + + if (activeRecordings.size === 0) { + removeWebRequestListeners(); + } + + if (hasSidePanelAPI) { + chrome.sidePanel.setOptions({ tabId: targetTabId, enabled: false }).catch(() => {}); + } + + return { success: true, events }; +}; + +export const getNetworkRecordingState = ( + tabId: number +): { active: boolean; events: NetworkRecordingEvent[]; startTime: number } | null => { + const recording = activeRecordings.get(tabId); + if (!recording) return null; + + return { + active: true, + events: recordingEvents.get(tabId) || [], + startTime: recording.startTime, + }; +}; + +export const handleNetworkRecordingOnClientPageLoad = (tab: chrome.tabs.Tab) => { + const recordingData = tabService.getData(tab.id, TAB_SERVICE_DATA.NETWORK_RECORDING); + if (!recordingData?.active) return; + + if (hasSidePanelAPI) { + chrome.sidePanel + .setOptions({ + tabId: tab.id, + path: "sidepanel/network-recording/index.html", + enabled: true, + }) + .catch(() => {}); + } +}; + +chrome.tabs.onRemoved.addListener((tabId) => { + if (!activeRecordings.has(tabId)) return; + + const recording = activeRecordings.get(tabId); + const events = recordingEvents.get(tabId) || []; + + activeRecordings.delete(tabId); + recordingEvents.delete(tabId); + + if (activeRecordings.size === 0) { + removeWebRequestListeners(); + } + + if (recording?.senderTabId != null) { + chrome.tabs + .sendMessage(recording.senderTabId, { + action: "networkRecordingTerminated", + targetTabId: tabId, + events, + }) + .catch(() => {}); + } +}); diff --git a/browser-extension/mv3/src/service-worker/services/tabService.ts b/browser-extension/mv3/src/service-worker/services/tabService.ts index 6b98bed708..1b42baf669 100644 --- a/browser-extension/mv3/src/service-worker/services/tabService.ts +++ b/browser-extension/mv3/src/service-worker/services/tabService.ts @@ -301,4 +301,5 @@ export const TAB_SERVICE_DATA = { APPLIED_RULE_DETAILS: "appliedRuleDetails", RULES_EXECUTION_LOGS: "rulesExecutionLogs", SHARED_STATE: "sharedState", + NETWORK_RECORDING: "networkRecording", }; diff --git a/browser-extension/mv3/test/network-recording-test.html b/browser-extension/mv3/test/network-recording-test.html new file mode 100644 index 0000000000..121d31b33d --- /dev/null +++ b/browser-extension/mv3/test/network-recording-test.html @@ -0,0 +1,189 @@ + + + + + Network Recording Test + + + +

Network Recording Test Harness

+

Simulates BrowserStack LTS calling the Requestly extension's external messaging API

+ +
+

1. Extension Setup

+ + +
+ +
+

2. Start Recording

+
+
+ + +
+ +
+

Status: Idle

+

Target Tab ID:

+
+ +
+

3. Stop Recording

+ +
+ +
+

Log

+
+
+ + + + diff --git a/browser-extension/mv3/test/serve.js b/browser-extension/mv3/test/serve.js new file mode 100644 index 0000000000..fd8c29a4b8 --- /dev/null +++ b/browser-extension/mv3/test/serve.js @@ -0,0 +1,21 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = 3099; +const HTML_PATH = path.join(__dirname, "network-recording-test.html"); + +const server = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(fs.readFileSync(HTML_PATH, "utf8")); +}); + +server.listen(PORT, () => { + console.log(`Test harness running at http://localhost:${PORT}`); + console.log("Steps:"); + console.log(" 1. Build the extension: cd ../ && npm run build"); + console.log(" 2. Load unpacked from browser-extension/mv3/dist/ in chrome://extensions"); + console.log(" 3. Copy the extension ID from chrome://extensions"); + console.log(` 4. Open http://localhost:${PORT} and paste the extension ID`); + console.log(" 5. Enter a URL and click Start Recording"); +}); From 182280b3816103a6a21d5023c6bcc42e295dcbe7 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Fri, 29 May 2026 21:51:31 +0530 Subject: [PATCH 02/23] refactor(extension): emit network recording events as HAR 1.2 entries Replace the custom NetworkRecordingEvent schema with HAR Entry objects (@types/har-format, already in repo). Network recording now produces the same format Chrome DevTools exports, so LTS can parse it directly. - New networkRecording/harBuilder.ts: webRequest details -> HAR Entry mapper with mapResourceType (webRequest -> DevTools _resourceType enum). Emits spec-complete entries (required request/response/content/timings/cache fields) plus _resourceType / _request_id / _fromCache extension fields. - Add a separate onBeforeSendHeaders listener + requestId correlation map to populate request headers; consumed and cleared on completed/error. Cache hits (no onBeforeSendHeaders) still produce an entry with empty headers. - _request_id is an extension-assigned id, not the browser webRequest id. - Move networkRecording.ts -> networkRecording/index.ts (directory module). - Migrate sidepanel to read the HAR Entry shape, key off _request_id, and count by _resourceType. Stop still returns { success, events } in this PR (shape unchanged) so the test harness keeps working; the summary-only contract lands in a later PR. Jira: RQ-2895 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NetworkRecordingPanel.tsx | 54 +++--- .../components/NetworkEventRow.tsx | 27 +-- .../src/sidepanel/network-recording/types.ts | 25 ++- .../services/networkRecording/harBuilder.ts | 173 +++++++++++++++++ .../index.ts} | 175 +++++++----------- 5 files changed, 294 insertions(+), 160 deletions(-) create mode 100644 browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts rename browser-extension/mv3/src/service-worker/services/{networkRecording.ts => networkRecording/index.ts} (55%) diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx index 9b65545d70..36f71c7b6c 100644 --- a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -1,20 +1,20 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; -import { NetworkRecordingEvent } from "./types"; +import { NetworkEntry } from "./types"; import NetworkEventRow from "./components/NetworkEventRow"; import FilterBar from "./components/FilterBar"; +// Maps the HAR _resourceType (DevTools enum) to the short label shown in the list. const RESOURCE_TYPE_DISPLAY: Record = { - xmlhttprequest: "xhr", - main_frame: "document", - sub_frame: "document", + document: "document", stylesheet: "css", script: "js", image: "img", font: "font", media: "media", websocket: "ws", - other: "other", + xhr: "xhr", fetch: "fetch", + other: "other", }; const formatTime = (ms: number): string => { @@ -32,7 +32,7 @@ const formatSize = (bytes: number | undefined): string => { }; const NetworkRecordingPanel: React.FC = () => { - const [events, setEvents] = useState([]); + const [entries, setEntries] = useState([]); const [filter, setFilter] = useState({ text: "", method: "ALL" }); const [recordingStartTime, setRecordingStartTime] = useState(Date.now()); const [isRecording, setIsRecording] = useState(true); @@ -54,7 +54,7 @@ const NetworkRecordingPanel: React.FC = () => { chrome.runtime.sendMessage({ action: "getNetworkRecordingState", tabId: tab.id }, (response) => { if (response?.active) { - setEvents(response.events || []); + setEntries(response.entries || []); setRecordingStartTime(response.startTime); setIsRecording(true); } @@ -65,7 +65,7 @@ const NetworkRecordingPanel: React.FC = () => { const listener = (message: any) => { if (message.action === "networkEventCaptured" && message.tabId === currentTabIdRef.current) { - setEvents((prev) => [...prev, message.event]); + setEntries((prev) => [...prev, message.entry]); } }; @@ -87,7 +87,7 @@ const NetworkRecordingPanel: React.FC = () => { if (listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } - }, [events.length]); + }, [entries.length]); const handleStop = useCallback(() => { chrome.runtime.sendMessage({ @@ -97,21 +97,23 @@ const NetworkRecordingPanel: React.FC = () => { setIsRecording(false); }, []); - const filteredEvents = useMemo(() => { - return events.filter((event) => { - if (filter.method !== "ALL" && event.method !== filter.method) return false; - if (filter.text && !event.url.toLowerCase().includes(filter.text.toLowerCase())) return false; + const filteredEntries = useMemo(() => { + return entries.filter((entry) => { + if (filter.method !== "ALL" && entry.request.method !== filter.method) return false; + if (filter.text && !entry.request.url.toLowerCase().includes(filter.text.toLowerCase())) return false; return true; }); - }, [events, filter]); + }, [entries, filter]); const counts = useMemo(() => { - const total = filteredEvents.length; - const xhr = filteredEvents.filter((e) => e.type === "xmlhttprequest" || e.type === "fetch").length; - const docs = filteredEvents.filter((e) => e.type === "main_frame" || e.type === "sub_frame").length; - const staticCount = filteredEvents.filter((e) => ["script", "stylesheet", "image", "font"].includes(e.type)).length; + const total = filteredEntries.length; + const xhr = filteredEntries.filter((e) => e._resourceType === "xhr" || e._resourceType === "fetch").length; + const docs = filteredEntries.filter((e) => e._resourceType === "document").length; + const staticCount = filteredEntries.filter((e) => + ["script", "stylesheet", "image", "font"].includes(e._resourceType as string) + ).length; return { total, xhr, docs, static: staticCount }; - }, [filteredEvents]); + }, [filteredEntries]); return (
@@ -154,17 +156,19 @@ const NetworkRecordingPanel: React.FC = () => {
- {filteredEvents.map((event) => ( + {filteredEntries.map((entry) => ( ))} - {filteredEvents.length === 0 && ( + {filteredEntries.length === 0 && (
- {events.length === 0 ? "Waiting for network requests..." : "No requests match the current filter"} + {entries.length === 0 ? "Waiting for network requests..." : "No requests match the current filter"}
)}
diff --git a/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx index 27b20142eb..654214f337 100644 --- a/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { NetworkRecordingEvent } from "../types"; +import { NetworkEntry } from "../types"; const METHOD_COLORS: Record = { GET: "#4CAF50", @@ -29,30 +29,35 @@ const getUrlPath = (url: string): string => { }; interface NetworkEventRowProps { - event: NetworkRecordingEvent; + entry: NetworkEntry; typeDisplay: string; formatSize: (bytes: number | undefined) => string; } -const NetworkEventRow: React.FC = ({ event, typeDisplay, formatSize }) => { +const NetworkEventRow: React.FC = ({ entry, typeDisplay, formatSize }) => { + const { method, url } = entry.request; + const { status } = entry.response; + const error = (entry as { _error?: string })._error; + const isError = !!error; + return ( -
+
- - {event.method} + + {method} - - {getUrlPath(event.url)} + + {getUrlPath(url)}
- - {event.state === "error" ? event.error || "Error" : event.statusCode} + + {isError ? error : status} · {typeDisplay} · - {formatSize(event.contentLength)} + {formatSize(entry.response.content.size)}
); diff --git a/browser-extension/common/src/sidepanel/network-recording/types.ts b/browser-extension/common/src/sidepanel/network-recording/types.ts index e375ea9aeb..66ee80af33 100644 --- a/browser-extension/common/src/sidepanel/network-recording/types.ts +++ b/browser-extension/common/src/sidepanel/network-recording/types.ts @@ -1,14 +1,11 @@ -export interface NetworkRecordingEvent { - requestId: string; - url: string; - method: string; - type: string; - statusCode: number; - timeStamp: number; - fromCache: boolean; - ip?: string; - contentLength?: number; - contentType?: string; - state: "complete" | "error"; - error?: string; -} +import { Entry } from "har-format"; + +/** + * Network entries are HAR 1.2 Entry objects carrying these `_`-prefixed extension fields + * (the same DevTools convention typed by @types/har-format): + * _resourceType — DevTools resource category (document | script | xhr | image | ...) + * _request_id — extension-assigned unique id (stable key / dedup) + * _fromCache — served from cache + * _error — present on failed/aborted requests + */ +export type NetworkEntry = Entry; diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts new file mode 100644 index 0000000000..52e04a4fcd --- /dev/null +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts @@ -0,0 +1,173 @@ +import { Entry, Header, QueryString } from "har-format"; + +/** HAR Entry plus our `_error` extension (set on failed/aborted requests). */ +export type NetworkHarEntry = Entry & { _error?: string }; + +/** + * The HAR _resourceType enum (Chrome DevTools convention) differs from + * chrome.webRequest.ResourceType. This maps webRequest types onto the HAR enum + * so the entries match what DevTools' own HAR export emits. + */ +export const mapResourceType = (type: chrome.webRequest.ResourceType): NonNullable => { + switch (type) { + case "xmlhttprequest": + return "xhr"; + case "main_frame": + case "sub_frame": + return "document"; + case "stylesheet": + return "stylesheet"; + case "script": + return "script"; + case "image": + return "image"; + case "font": + return "font"; + case "media": + return "media"; + case "websocket": + return "websocket"; + case "ping": + return "ping"; + case "csp_report": + return "csp-violation-report"; + default: + return "other"; + } +}; + +const toHarHeaders = (headers: chrome.webRequest.HttpHeader[] | undefined): Header[] => + (headers || []).map((h) => ({ name: h.name, value: h.value ?? "" })); + +const parseHeaderValue = (headers: chrome.webRequest.HttpHeader[] | undefined, name: string): string | undefined => { + if (!headers) return undefined; + const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase()); + return header?.value; +}; + +const parseContentLength = (headers: chrome.webRequest.HttpHeader[] | undefined): number => { + const value = parseHeaderValue(headers, "content-length"); + if (!value) return 0; + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const parseQueryString = (url: string): QueryString[] => { + try { + const params = new URL(url).searchParams; + const result: QueryString[] = []; + params.forEach((value, name) => result.push({ name, value })); + return result; + } catch { + return []; + } +}; + +/** Parse "HTTP/1.1 200 OK" → { httpVersion: "HTTP/1.1", statusText: "OK" }. Both fall back to "". */ +const parseStatusLine = (statusLine: string | undefined): { httpVersion: string; statusText: string } => { + if (!statusLine) return { httpVersion: "", statusText: "" }; + const match = statusLine.match(/^(\S+)\s+\d+\s*(.*)$/); + if (!match) return { httpVersion: "", statusText: "" }; + return { httpVersion: match[1] || "", statusText: (match[2] || "").trim() }; +}; + +export interface CorrelationData { + startTime: number; // epoch ms, from onBeforeSendHeaders + requestHeaders: chrome.webRequest.HttpHeader[] | undefined; +} + +/** + * Build a spec-complete HAR 1.2 Entry from a completed webRequest. + * `correlation` is the matched onBeforeSendHeaders data (may be absent for cache hits). + * `requestId` is the extension-assigned unique id (NOT chrome.webRequest.requestId). + */ +export const buildCompletedEntry = ( + details: chrome.webRequest.WebResponseCacheDetails, + correlation: CorrelationData | undefined, + requestId: string +): NetworkHarEntry => { + const startTime = correlation?.startTime ?? details.timeStamp; + const wait = Math.max(0, Math.round(details.timeStamp - startTime)); + const { httpVersion, statusText } = parseStatusLine((details as { statusLine?: string }).statusLine); + + const entry: NetworkHarEntry = { + startedDateTime: new Date(startTime).toISOString(), + time: wait, + request: { + method: details.method, + url: details.url, + httpVersion: "", + cookies: [], + headers: toHarHeaders(correlation?.requestHeaders), + queryString: parseQueryString(details.url), + headersSize: -1, + bodySize: -1, + }, + response: { + status: details.statusCode, + statusText, + httpVersion, + cookies: [], + headers: toHarHeaders(details.responseHeaders), + content: { + size: parseContentLength(details.responseHeaders), + mimeType: parseHeaderValue(details.responseHeaders, "content-type") || "", + }, + redirectURL: parseHeaderValue(details.responseHeaders, "location") || "", + headersSize: -1, + bodySize: -1, + }, + cache: {}, + timings: { send: 0, wait, receive: 0 }, + _resourceType: mapResourceType(details.type), + _request_id: requestId, + _fromCache: details.fromCache ? "disk" : null, + }; + + if (details.ip) { + entry.serverIPAddress = details.ip; + } + + return entry; +}; + +/** Build a HAR Entry for a failed/aborted request (no response). */ +export const buildErrorEntry = ( + details: chrome.webRequest.WebResponseErrorDetails, + correlation: CorrelationData | undefined, + requestId: string, + error: string +): NetworkHarEntry => { + const startTime = correlation?.startTime ?? details.timeStamp; + + return { + startedDateTime: new Date(startTime).toISOString(), + time: 0, + request: { + method: details.method, + url: details.url, + httpVersion: "", + cookies: [], + headers: toHarHeaders(correlation?.requestHeaders), + queryString: parseQueryString(details.url), + headersSize: -1, + bodySize: -1, + }, + response: { + status: 0, + statusText: "", + httpVersion: "", + cookies: [], + headers: [], + content: { size: 0, mimeType: "" }, + redirectURL: "", + headersSize: -1, + bodySize: -1, + }, + cache: {}, + timings: { send: 0, wait: 0, receive: 0 }, + _resourceType: mapResourceType(details.type), + _request_id: requestId, + _error: error, + }; +}; diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts similarity index 55% rename from browser-extension/mv3/src/service-worker/services/networkRecording.ts rename to browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index 63b1df68f3..b7b9a090a3 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -1,30 +1,22 @@ -import { tabService, TAB_SERVICE_DATA } from "./tabService"; +import { tabService, TAB_SERVICE_DATA } from "../tabService"; import { CLIENT_MESSAGES } from "common/constants"; - -interface NetworkRecordingEvent { - requestId: string; - url: string; - method: string; - type: chrome.webRequest.ResourceType; - statusCode: number; - timeStamp: number; - fromCache: boolean; - ip?: string; - contentLength?: number; - contentType?: string; - state: "complete" | "error"; - error?: string; -} +import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry } from "./harBuilder"; interface NetworkRecordingState { senderTabId: number | undefined; targetTabId: number; startTime: number; - config: { showWidget?: boolean; maxDuration?: number }; + config: { maxDuration?: number }; } const activeRecordings = new Map(); -const recordingEvents = new Map(); +const recordingEntries = new Map(); + +// webRequest requestId -> request-start correlation data (internal only, never surfaced). +const correlationMap = new Map(); + +let entryCounter = 0; +const nextRequestId = (tabId: number): string => `${tabId}-${++entryCounter}`; const hasSidePanelAPI = typeof chrome.sidePanel !== "undefined"; @@ -34,53 +26,32 @@ if (hasSidePanelAPI) { const DEFAULT_MAX_DURATION = 15 * 60 * 1000; -const parseContentLength = (headers: chrome.webRequest.HttpHeader[] | undefined): number | undefined => { - if (!headers) return undefined; - const header = headers.find((h) => h.name.toLowerCase() === "content-length"); - if (!header?.value) return undefined; - const parsed = parseInt(header.value, 10); - return Number.isNaN(parsed) ? undefined : parsed; -}; - -const parseHeaderValue = (headers: chrome.webRequest.HttpHeader[] | undefined, name: string): string | undefined => { - if (!headers) return undefined; - const header = headers.find((h) => h.name.toLowerCase() === name.toLowerCase()); - return header?.value; +const onBeforeSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => { + if (!activeRecordings.has(details.tabId)) return; + correlationMap.set(details.requestId, { + startTime: details.timeStamp, + requestHeaders: details.requestHeaders, + }); }; const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => { const recording = activeRecordings.get(details.tabId); if (!recording) return; + // PR1 stopgap: enforce maxDuration inline. PR5 moves this into the keepalive tick so a + // quiet page (no further requests) also auto-stops. const maxDuration = recording.config.maxDuration || DEFAULT_MAX_DURATION; if (Date.now() - recording.startTime > maxDuration) { stopNetworkRecording(details.tabId); return; } - const event: NetworkRecordingEvent = { - requestId: details.requestId, - url: details.url, - method: details.method, - type: details.type, - statusCode: details.statusCode, - timeStamp: details.timeStamp, - fromCache: details.fromCache, - ip: details.ip, - contentLength: parseContentLength(details.responseHeaders), - contentType: parseHeaderValue(details.responseHeaders, "content-type"), - state: "complete", - }; + const correlation = correlationMap.get(details.requestId); + correlationMap.delete(details.requestId); - recordingEvents.get(details.tabId)?.push(event); - - chrome.runtime - .sendMessage({ - action: CLIENT_MESSAGES.NETWORK_EVENT_CAPTURED, - event, - tabId: details.tabId, - }) - .catch(() => {}); + const entry = buildCompletedEntry(details, correlation, nextRequestId(details.tabId)); + recordingEntries.get(details.tabId)?.push(entry); + deliverEntry(details.tabId, entry); }; const IGNORED_ERRORS = new Set(["net::ERR_CACHE_MISS", "net::ERR_ABORTED", "net::ERR_BLOCKED_BY_CLIENT"]); @@ -89,32 +60,33 @@ const onRequestError = (details: chrome.webRequest.WebResponseErrorDetails) => { const recording = activeRecordings.get(details.tabId); if (!recording) return; - if (IGNORED_ERRORS.has(details.error)) return; + const correlation = correlationMap.get(details.requestId); + correlationMap.delete(details.requestId); - const event: NetworkRecordingEvent = { - requestId: details.requestId, - url: details.url, - method: details.method, - type: details.type, - statusCode: 0, - timeStamp: details.timeStamp, - fromCache: false, - state: "error", - error: details.error, - }; + if (IGNORED_ERRORS.has(details.error)) return; - recordingEvents.get(details.tabId)?.push(event); + const entry = buildErrorEntry(details, correlation, nextRequestId(details.tabId), details.error); + recordingEntries.get(details.tabId)?.push(entry); + deliverEntry(details.tabId, entry); +}; +/** Deliver a captured entry to the internal sidepanel. (External port delivery added in PR2.) */ +const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { chrome.runtime .sendMessage({ action: CLIENT_MESSAGES.NETWORK_EVENT_CAPTURED, - event, - tabId: details.tabId, + entry, + tabId, }) .catch(() => {}); }; const addWebRequestListeners = () => { + if (!chrome.webRequest.onBeforeSendHeaders.hasListener(onBeforeSendHeaders)) { + chrome.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: [""] }, [ + "requestHeaders", + ]); + } if (!chrome.webRequest.onCompleted.hasListener(onRequestCompleted)) { chrome.webRequest.onCompleted.addListener(onRequestCompleted, { urls: [""] }, ["responseHeaders"]); } @@ -124,6 +96,7 @@ const addWebRequestListeners = () => { }; const removeWebRequestListeners = () => { + chrome.webRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); chrome.webRequest.onCompleted.removeListener(onRequestCompleted); chrome.webRequest.onErrorOccurred.removeListener(onRequestError); }; @@ -137,10 +110,20 @@ const isValidUrl = (url: string): boolean => { } }; +const openPanel = (tabId: number) => { + if (!hasSidePanelAPI) return; + chrome.sidePanel.setOptions({ + tabId, + path: "sidepanel/network-recording/index.html", + enabled: true, + }); + chrome.sidePanel.open({ tabId }).catch(() => {}); +}; + export const startNetworkRecording = ( senderTabId: number | undefined, url: string, - config: Record = {} + config: { maxDuration?: number } = {} ): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { if (!url || !isValidUrl(url)) { return Promise.resolve({ success: false, error: "Invalid URL. Must be a valid http or https URL." }); @@ -161,19 +144,11 @@ export const startNetworkRecording = ( }; activeRecordings.set(tab.id, state); - recordingEvents.set(tab.id, []); + recordingEntries.set(tab.id, []); tabService.setData(tab.id, TAB_SERVICE_DATA.NETWORK_RECORDING, { active: true, senderTabId }); addWebRequestListeners(); - - if (hasSidePanelAPI && config.showWidget !== false) { - chrome.sidePanel.setOptions({ - tabId: tab.id, - path: "sidepanel/network-recording/index.html", - enabled: true, - }); - chrome.sidePanel.open({ tabId: tab.id }).catch(() => {}); - } + openPanel(tab.id); resolve({ success: true, targetTabId: tab.id }); }); @@ -182,15 +157,15 @@ export const startNetworkRecording = ( export const stopNetworkRecording = ( targetTabId: number -): { success: boolean; events?: NetworkRecordingEvent[]; error?: string } => { +): { success: boolean; events?: NetworkHarEntry[]; error?: string } => { if (!activeRecordings.has(targetTabId)) { return { success: false, error: `No active recording for tab ${targetTabId}` }; } - const events = recordingEvents.get(targetTabId) || []; + const events = recordingEntries.get(targetTabId) || []; activeRecordings.delete(targetTabId); - recordingEvents.delete(targetTabId); + recordingEntries.delete(targetTabId); tabService.removeData(targetTabId, TAB_SERVICE_DATA.NETWORK_RECORDING); if (activeRecordings.size === 0) { @@ -206,13 +181,13 @@ export const stopNetworkRecording = ( export const getNetworkRecordingState = ( tabId: number -): { active: boolean; events: NetworkRecordingEvent[]; startTime: number } | null => { +): { active: boolean; entries: NetworkHarEntry[]; startTime: number } | null => { const recording = activeRecordings.get(tabId); if (!recording) return null; return { active: true, - events: recordingEvents.get(tabId) || [], + entries: recordingEntries.get(tabId) || [], startTime: recording.startTime, }; }; @@ -220,38 +195,18 @@ export const getNetworkRecordingState = ( export const handleNetworkRecordingOnClientPageLoad = (tab: chrome.tabs.Tab) => { const recordingData = tabService.getData(tab.id, TAB_SERVICE_DATA.NETWORK_RECORDING); if (!recordingData?.active) return; - - if (hasSidePanelAPI) { - chrome.sidePanel - .setOptions({ - tabId: tab.id, - path: "sidepanel/network-recording/index.html", - enabled: true, - }) - .catch(() => {}); - } + openPanel(tab.id); }; -chrome.tabs.onRemoved.addListener((tabId) => { - if (!activeRecordings.has(tabId)) return; - - const recording = activeRecordings.get(tabId); - const events = recordingEvents.get(tabId) || []; - +const cleanupRecording = (tabId: number) => { activeRecordings.delete(tabId); - recordingEvents.delete(tabId); - + recordingEntries.delete(tabId); if (activeRecordings.size === 0) { removeWebRequestListeners(); } +}; - if (recording?.senderTabId != null) { - chrome.tabs - .sendMessage(recording.senderTabId, { - action: "networkRecordingTerminated", - targetTabId: tabId, - events, - }) - .catch(() => {}); - } +chrome.tabs.onRemoved.addListener((tabId) => { + if (!activeRecordings.has(tabId)) return; + cleanupRecording(tabId); }); From 77cdec99f4920298eae2aba46282dd3936fd3123 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Fri, 29 May 2026 21:55:54 +0530 Subject: [PATCH 03/23] feat(extension): stream network recording entries to LTS over a port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the streaming data channel for BrowserStack LT. LTS opens a long-lived external port ("network-recording") and subscribes to a target tab; the extension backfills the buffer from t=0, then streams each HAR entry live. - initNetworkRecordingPort() via chrome.runtime.onConnectExternal, gated by the existing externally_connectable allowlist. Registered in the SW index. - subscriptions: Map> — multiplexed (one LTS page can watch several tabs from one port). - subscribe: ack, synchronous backfill of existing entries (no await, so no live entry can interleave -> no gap/dup), then register for live entries. If the recording already ended, send complete immediately. - deliverEntry now fans out to both the internal sidepanel and subscribed ports; per-port send is try/caught so one dead port can't block others. - complete { totalCount } is emitted to ports on stop and on tab close, before the buffer is torn down. onDisconnect removes the port everywhere. Stop still also returns { success, events } in this PR (changed to summary in the next PR). LTS dedups on _request_id across reconnects. Jira: RQ-2895 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mv3/src/service-worker/index.ts | 2 + .../services/networkRecording/index.ts | 83 ++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/browser-extension/mv3/src/service-worker/index.ts b/browser-extension/mv3/src/service-worker/index.ts index 848aa642c0..eb1b7b82ba 100644 --- a/browser-extension/mv3/src/service-worker/index.ts +++ b/browser-extension/mv3/src/service-worker/index.ts @@ -6,6 +6,7 @@ import { handleInstallUninstall } from "./services/installUninstall"; import { initExternalMessageListener, initMessageHandler } from "./services/messageHandler/listener"; import { initRulesManager } from "./services/rulesManager"; import { initWebRequestInterceptor } from "./services/webRequestInterceptor"; +import { initNetworkRecordingPort } from "./services/networkRecording"; // initialize (async () => { @@ -19,4 +20,5 @@ import { initWebRequestInterceptor } from "./services/webRequestInterceptor"; initContextMenu(); initWebRequestInterceptor(); initDevtoolsListener(); + initNetworkRecordingPort(); })(); diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index b7b9a090a3..3059085eb7 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -12,9 +12,14 @@ interface NetworkRecordingState { const activeRecordings = new Map(); const recordingEntries = new Map(); +// LTS streaming subscribers, keyed by target tabId. One LTS page may subscribe to many tabs. +const subscriptions = new Map>(); + // webRequest requestId -> request-start correlation data (internal only, never surfaced). const correlationMap = new Map(); +const NETWORK_RECORDING_PORT = "network-recording"; + let entryCounter = 0; const nextRequestId = (tabId: number): string => `${tabId}-${++entryCounter}`; @@ -70,8 +75,9 @@ const onRequestError = (details: chrome.webRequest.WebResponseErrorDetails) => { deliverEntry(details.tabId, entry); }; -/** Deliver a captured entry to the internal sidepanel. (External port delivery added in PR2.) */ +/** Deliver a captured entry to the internal sidepanel and any subscribed LTS ports. */ const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { + // Internal sidepanel (fire-and-forget; panel may be closed). chrome.runtime .sendMessage({ action: CLIENT_MESSAGES.NETWORK_EVENT_CAPTURED, @@ -79,6 +85,30 @@ const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { tabId, }) .catch(() => {}); + + // External LTS subscribers. + const subs = subscriptions.get(tabId); + subs?.forEach((port) => { + try { + port.postMessage({ type: "entry", entry }); + } catch { + // Port died between events; onDisconnect will clean it up. + } + }); +}; + +/** Notify subscribed LTS ports that a recording has ended. */ +const streamCompleteToPorts = (tabId: number) => { + const subs = subscriptions.get(tabId); + if (!subs) return; + const totalCount = recordingEntries.get(tabId)?.length ?? 0; + subs.forEach((port) => { + try { + port.postMessage({ type: "complete", totalCount }); + } catch { + /* ignore */ + } + }); }; const addWebRequestListeners = () => { @@ -110,6 +140,53 @@ const isValidUrl = (url: string): boolean => { } }; +const removePortFromAllSubscriptions = (port: chrome.runtime.Port) => { + subscriptions.forEach((ports, tabId) => { + ports.delete(port); + if (ports.size === 0) subscriptions.delete(tabId); + }); +}; + +/** + * LTS connects a long-lived port (`network-recording`) and subscribes to a target tab. + * On subscribe we ack, synchronously backfill the buffer (entries from t=0), then register + * the port for live entries. Because the backfill is synchronous (no await), no live + * onCompleted can interleave, so there is no gap or duplicate. + */ +export const initNetworkRecordingPort = () => { + chrome.runtime.onConnectExternal.addListener((port) => { + if (port.name !== NETWORK_RECORDING_PORT) return; + + port.onMessage.addListener((msg: { action?: string; targetTabId?: number }) => { + const tabId = msg?.targetTabId; + if (typeof tabId !== "number") return; + + if (msg.action === "subscribe") { + port.postMessage({ type: "subscribed", targetTabId: tabId }); + + // Synchronous backfill, then register — no await in between. + const buffered = recordingEntries.get(tabId) || []; + for (const entry of buffered) { + port.postMessage({ type: "entry", entry }); + } + + if (!subscriptions.has(tabId)) subscriptions.set(tabId, new Set()); + subscriptions.get(tabId)!.add(port); + + // Recording already ended (e.g. very short) but buffer still around: tell LTS. + if (!activeRecordings.has(tabId)) { + port.postMessage({ type: "complete", totalCount: buffered.length }); + } + } else if (msg.action === "unsubscribe") { + subscriptions.get(tabId)?.delete(port); + if (subscriptions.get(tabId)?.size === 0) subscriptions.delete(tabId); + } + }); + + port.onDisconnect.addListener(() => removePortFromAllSubscriptions(port)); + }); +}; + const openPanel = (tabId: number) => { if (!hasSidePanelAPI) return; chrome.sidePanel.setOptions({ @@ -164,6 +241,9 @@ export const stopNetworkRecording = ( const events = recordingEntries.get(targetTabId) || []; + // Notify subscribed LTS ports before tearing down the buffer. + streamCompleteToPorts(targetTabId); + activeRecordings.delete(targetTabId); recordingEntries.delete(targetTabId); tabService.removeData(targetTabId, TAB_SERVICE_DATA.NETWORK_RECORDING); @@ -199,6 +279,7 @@ export const handleNetworkRecordingOnClientPageLoad = (tab: chrome.tabs.Tab) => }; const cleanupRecording = (tabId: number) => { + streamCompleteToPorts(tabId); activeRecordings.delete(tabId); recordingEntries.delete(tabId); if (activeRecordings.size === 0) { From 09bcf56efbd4dd07ef63ae4c83b2366e4f52ab78 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Fri, 29 May 2026 22:00:05 +0530 Subject: [PATCH 04/23] feat(extension): stop returns summary; { action, payload } external envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream is now the sole data channel to LTS. stopNetworkRecording is a control command that ends the recording and returns a summary only — the HAR entries are delivered over the port, not re-sent here. - stopNetworkRecording returns { success, summary } where summary is { targetTabId, url, startTime, endTime, duration, totalCount }. No entries. (Recording state now also tracks url for the summary.) - External start/stop adopt the nested { action, payload } envelope: start payload { url, config? }, stop payload { targetTabId }. (getExtensionMetadata stays flat — no args.) - Test harness updated: subscribes to the port after start, accumulates HAR entries locally (dedup on _request_id), and on stop reads summary + reconciles summary.totalCount against the streamed count. Jira: RQ-2895 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/messageHandler/listener.ts | 4 +- .../services/networkRecording/index.ts | 29 +++++- .../mv3/test/network-recording-test.html | 91 ++++++++++++++----- 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts index 67820f852e..898cf235f8 100644 --- a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts +++ b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts @@ -51,11 +51,11 @@ export const initExternalMessageListener = () => { break; case EXTENSION_EXTERNAL_MESSAGES.START_NETWORK_RECORDING: - startNetworkRecording(sender.tab?.id, message.url, message.config || {}).then(sendResponse); + startNetworkRecording(sender.tab?.id, message.payload?.url, message.payload?.config || {}).then(sendResponse); return true; case EXTENSION_EXTERNAL_MESSAGES.STOP_NETWORK_RECORDING: - sendResponse(stopNetworkRecording(message.targetTabId)); + sendResponse(stopNetworkRecording(message.payload?.targetTabId)); break; } }); diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index 3059085eb7..b4f3aba464 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -5,6 +5,7 @@ import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry interface NetworkRecordingState { senderTabId: number | undefined; targetTabId: number; + url: string; startTime: number; config: { maxDuration?: number }; } @@ -216,6 +217,7 @@ export const startNetworkRecording = ( const state: NetworkRecordingState = { senderTabId, targetTabId: tab.id, + url, startTime: Date.now(), config, }; @@ -232,14 +234,33 @@ export const startNetworkRecording = ( }); }; +export interface RecordingSummary { + targetTabId: number; + url: string; + startTime: number; + endTime: number; + duration: number; + totalCount: number; +} + export const stopNetworkRecording = ( targetTabId: number -): { success: boolean; events?: NetworkHarEntry[]; error?: string } => { - if (!activeRecordings.has(targetTabId)) { +): { success: boolean; summary?: RecordingSummary; error?: string } => { + const recording = activeRecordings.get(targetTabId); + if (!recording) { return { success: false, error: `No active recording for tab ${targetTabId}` }; } - const events = recordingEntries.get(targetTabId) || []; + const entries = recordingEntries.get(targetTabId) || []; + const endTime = Date.now(); + const summary: RecordingSummary = { + targetTabId, + url: recording.url, + startTime: recording.startTime, + endTime, + duration: endTime - recording.startTime, + totalCount: entries.length, + }; // Notify subscribed LTS ports before tearing down the buffer. streamCompleteToPorts(targetTabId); @@ -256,7 +277,7 @@ export const stopNetworkRecording = ( chrome.sidePanel.setOptions({ tabId: targetTabId, enabled: false }).catch(() => {}); } - return { success: true, events }; + return { success: true, summary }; }; export const getNetworkRecordingState = ( diff --git a/browser-extension/mv3/test/network-recording-test.html b/browser-extension/mv3/test/network-recording-test.html index 121d31b33d..0dcb67d8de 100644 --- a/browser-extension/mv3/test/network-recording-test.html +++ b/browser-extension/mv3/test/network-recording-test.html @@ -67,6 +67,9 @@

Log

From 23003d80c3be792c204cc2ea13e61e6e15fda47d Mon Sep 17 00:00:00 2001 From: nafees87n Date: Mon, 1 Jun 2026 15:55:22 +0530 Subject: [PATCH 09/23] =?UTF-8?q?feat(extension):=20network=20panel=20UX?= =?UTF-8?q?=20=E2=80=94=20host=20display,=20scroll,=20chip=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidepanel feedback fixes: - Show the request host on the detail line (host · status · type · size) and the path on the primary line. Cross-host requests (CDN, third-party, subdomains) are now distinguishable, and bare relative paths are no longer ambiguous across page navigations. Host truncates first on a narrow sidebar so status/type/size stay visible; full URL on hover (title). - Auto-scroll only while the user is pinned to the bottom of the list; once they scroll up mid-stream, hold their position until they return to the bottom. - Method filter chips wrap instead of being clipped on a narrow sidebar. - Remove the "Sending live updates to BrowserStack Load Testing" footer text (kept the version, right-aligned). Jira: RQ-2895 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NetworkRecordingPanel.tsx | 16 +++++++++--- .../components/NetworkEventRow.tsx | 17 ++++++++++--- .../src/sidepanel/network-recording/index.css | 25 ++++++++++++++++--- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx index ea9abec81c..b33c990c64 100644 --- a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -83,8 +83,19 @@ const NetworkRecordingPanel: React.FC = () => { return () => clearInterval(interval); }, [isRecording, recordingStartTime]); + // Auto-scroll to the newest entry, but only while the user is pinned to the bottom. + // Once they scroll up, stop yanking them back down until they return to the bottom. + const stickToBottomRef = useRef(true); + + const handleListScroll = useCallback(() => { + const el = listRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom <= 24; // within ~1 row of the bottom + }, []); + useEffect(() => { - if (listRef.current) { + if (listRef.current && stickToBottomRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } }, [entries.length]); @@ -155,7 +166,7 @@ const NetworkRecordingPanel: React.FC = () => { -
+
{filteredEntries.map((entry) => ( {
- Sending live updates to BrowserStack Load Testing v{chrome.runtime.getManifest().version}
diff --git a/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx index 654214f337..fd8fe7c779 100644 --- a/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx @@ -19,12 +19,12 @@ const getStatusColor = (statusCode: number): string => { return "#F44336"; }; -const getUrlPath = (url: string): string => { +const splitUrl = (url: string): { host: string; path: string } => { try { const parsed = new URL(url); - return parsed.pathname + parsed.search; + return { host: parsed.host, path: parsed.pathname + parsed.search || "/" }; } catch { - return url; + return { host: "", path: url }; } }; @@ -39,6 +39,7 @@ const NetworkEventRow: React.FC = ({ entry, typeDisplay, f const { status } = entry.response; const error = (entry as { _error?: string })._error; const isError = !!error; + const { host, path } = splitUrl(url); return (
@@ -47,10 +48,18 @@ const NetworkEventRow: React.FC = ({ entry, typeDisplay, f {method} - {getUrlPath(url)} + {path}
+ {host && ( + <> + + {host} + + · + + )} {isError ? error : status} diff --git a/browser-extension/common/src/sidepanel/network-recording/index.css b/browser-extension/common/src/sidepanel/network-recording/index.css index 4f9f07a60a..61e17260e0 100644 --- a/browser-extension/common/src/sidepanel/network-recording/index.css +++ b/browser-extension/common/src/sidepanel/network-recording/index.css @@ -181,6 +181,7 @@ body { .method-chips { display: flex; + flex-wrap: wrap; gap: 6px; } @@ -189,8 +190,9 @@ body { border: 1px solid #333; border-radius: 16px; color: #9e9e9e; - padding: 4px 12px; + padding: 4px 10px; font-size: 12px; + white-space: nowrap; cursor: pointer; transition: all 0.15s; } @@ -276,10 +278,27 @@ body { color: #9e9e9e; } +.row-host { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #b0b0b0; + font-family: monospace; +} + .row-status { + flex: none; font-weight: 600; } +.row-separator, +.row-type, +.row-size { + flex: none; +} + .row-separator { color: #555; } @@ -304,8 +323,8 @@ body { .panel-footer { display: flex; align-items: center; - justify-content: space-between; - padding: 8px 16px; + justify-content: flex-end; + padding: 6px 16px; border-top: 1px solid #333; color: #9e9e9e; font-size: 11px; From 0c02db8dc6e893d22bd4171f85fadd2106d38d27 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Mon, 1 Jun 2026 17:56:23 +0530 Subject: [PATCH 10/23] refactor(extension): finalize stop/complete/summary contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the summary retrieval consistent regardless of who stops the recording (LTS or the side panel): both end the recording, the stream fires a pure `complete` signal, and the consumer fetches the summary the same way. - stopNetworkRecording returns { success } only — no inline summary. (When the side panel stops, that response goes to the panel, not the stream consumer, so returning it there was inconsistent.) - `complete` is now a pure signal { type: "complete" } — no totalCount. - getNetworkRecordingSummary errors while the recording is still active ("...is still active") so a half-finished summary is never mistaken for the final one; returns the retained summary after stop; errors if unknown/expired. - Harness: `complete` just logs the signal and fetches the summary; the streamed-vs-totalCount reconcile moved into the summary panel. Jira: RQ-2895 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/networkRecording/index.ts | 44 ++++++++----------- .../mv3/test/network-recording-test.html | 6 +-- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index c60d2fba65..5c861482ff 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -157,14 +157,14 @@ const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { }); }; -/** Notify subscribed LTS ports that a recording has ended. */ +/** Signal subscribed LTS ports that a recording has ended. Pure signal — the consumer then + * fetches the summary via getNetworkRecordingSummary. */ const streamCompleteToPorts = (tabId: number) => { const subs = subscriptions.get(tabId); if (!subs) return; - const totalCount = recordingEntries.get(tabId)?.length ?? 0; subs.forEach((port) => { try { - port.postMessage({ type: "complete", totalCount }); + port.postMessage({ type: "complete" }); } catch { /* ignore */ } @@ -240,9 +240,9 @@ export const initNetworkRecordingPort = () => { if (!subscriptions.has(tabId)) subscriptions.set(tabId, new Set()); subscriptions.get(tabId)!.add(port); - // Recording already ended (e.g. very short) but buffer still around: tell LTS. + // Recording already ended (e.g. very short) but buffer still around: signal complete. if (!activeRecordings.has(tabId)) { - port.postMessage({ type: "complete", totalCount: buffered.length }); + port.postMessage({ type: "complete" }); } } else if (msg.action === "unsubscribe") { subscriptions.get(tabId)?.delete(port); @@ -336,24 +336,20 @@ const buildSummary = (recording: NetworkRecordingState, totalCount: number): Rec }; }; -export const stopNetworkRecording = ( - targetTabId: number -): { success: boolean; summary?: RecordingSummary; error?: string } => { +export const stopNetworkRecording = (targetTabId: number): { success: boolean; error?: string } => { const recording = activeRecordings.get(targetTabId); if (!recording) { return { success: false, error: `No active recording for tab ${targetTabId}` }; } const entries = recordingEntries.get(targetTabId) || []; - const summary = buildSummary(recording, entries.length); - // The stream is the data channel; the summary lives only here. A stream consumer (LTS) - // that didn't trigger this stop (e.g. the user clicked Stop in the side panel) learns of - // the end via the port `complete` message, then fetches this summary with - // getNetworkRecordingSummary — so we retain it briefly after teardown. - retainSummary(summary); + // Stop returns { success } only. Whoever holds the stream (LTS) learns of the end via the + // port `complete` signal and fetches the metadata with getNetworkRecordingSummary — the same + // path regardless of who triggered this stop (LTS or the side panel) — so retain it briefly. + retainSummary(buildSummary(recording, entries.length)); - // Notify subscribed LTS ports before tearing down the buffer. + // Signal subscribed LTS ports before tearing down the buffer. streamCompleteToPorts(targetTabId); activeRecordings.delete(targetTabId); @@ -367,7 +363,7 @@ export const stopNetworkRecording = ( closePanel(targetTabId); - return { success: true, summary }; + return { success: true }; }; // Summaries are retained for a short window after a recording ends so a stream consumer can @@ -384,24 +380,22 @@ const retainSummary = (summary: RecordingSummary) => { }; /** - * Fetch the summary for a recording. Works while the recording is active (live snapshot) and - * for a short window after it ends (retained). Intended to be called by a stream consumer when - * it receives the `complete` message, since whoever triggered the stop (e.g. the side panel) - * may not be the consumer holding the stream. + * Fetch the final summary for a recording. Call this AFTER the stream's `complete` signal — + * it only succeeds once the recording has stopped (the summary is retained ~5 min after end). + * While the recording is still active it returns an error, so a half-finished summary is never + * mistaken for the final one. Works regardless of who triggered the stop (LTS or the side panel). */ export const getNetworkRecordingSummary = ( targetTabId: number ): { success: boolean; summary?: RecordingSummary; error?: string } => { - const active = activeRecordings.get(targetTabId); - if (active) { - const totalCount = recordingEntries.get(targetTabId)?.length ?? 0; - return { success: true, summary: buildSummary(active, totalCount) }; + if (activeRecordings.has(targetTabId)) { + return { success: false, error: `Recording for tab ${targetTabId} is still active` }; } const retained = recentSummaries.get(targetTabId); if (retained) { return { success: true, summary: retained }; } - return { success: false, error: `No recording summary for tab ${targetTabId}` }; + return { success: false, error: `No summary for tab ${targetTabId}` }; }; export const getNetworkRecordingState = ( diff --git a/browser-extension/mv3/test/network-recording-test.html b/browser-extension/mv3/test/network-recording-test.html index 913aa38b75..a829c7c303 100644 --- a/browser-extension/mv3/test/network-recording-test.html +++ b/browser-extension/mv3/test/network-recording-test.html @@ -168,10 +168,8 @@

Log

} case "complete": // Recording ended (possibly stopped from the side panel — we did NOT trigger it). - // The stream gave us totalCount; fetch the full summary metadata on demand. - log(`Stream complete: extension captured ${msg.totalCount}, we streamed ${streamedEntries.length}` + - (msg.totalCount === streamedEntries.length ? ' ✓' : ' ✗ MISMATCH'), - msg.totalCount === streamedEntries.length ? 'info' : 'error'); + // `complete` is a pure signal; fetch the summary metadata on demand. + log(`Stream complete (signal). Streamed ${streamedEntries.length} entries. Fetching summary…`, 'info'); fetchSummary(extId, targetTabId); break; case "error": From f42ef069f2f1aa7bb4b684b0b1898d98614300e2 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Mon, 1 Jun 2026 19:47:56 +0530 Subject: [PATCH 11/23] refactor(extension): drop unreachable fetch resourceType branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chrome.webRequest reports both XHR and fetch() as "xmlhttprequest", which mapResourceType maps to "xhr" — it never emits "fetch". Remove the dead "fetch" display-map entry and the unreachable fetch check in the XHR counter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/sidepanel/network-recording/NetworkRecordingPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx index b33c990c64..34fd4ab17c 100644 --- a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -13,7 +13,6 @@ const RESOURCE_TYPE_DISPLAY: Record = { media: "media", websocket: "ws", xhr: "xhr", - fetch: "fetch", other: "other", }; @@ -118,7 +117,7 @@ const NetworkRecordingPanel: React.FC = () => { const counts = useMemo(() => { const total = filteredEntries.length; - const xhr = filteredEntries.filter((e) => e._resourceType === "xhr" || e._resourceType === "fetch").length; + const xhr = filteredEntries.filter((e) => e._resourceType === "xhr").length; const docs = filteredEntries.filter((e) => e._resourceType === "document").length; const staticCount = filteredEntries.filter((e) => ["script", "stylesheet", "image", "font"].includes(e._resourceType as string) From b1c586527a5e2efe9e5d4d79deb7b1939d2a3fea Mon Sep 17 00:00:00 2001 From: nafees87n Date: Tue, 2 Jun 2026 13:42:39 +0530 Subject: [PATCH 12/23] chore(extension): log received network entries to console in test harness Co-Authored-By: Claude Opus 4.8 (1M context) --- browser-extension/mv3/test/network-recording-test.html | 1 + 1 file changed, 1 insertion(+) diff --git a/browser-extension/mv3/test/network-recording-test.html b/browser-extension/mv3/test/network-recording-test.html index a829c7c303..11a4187b3b 100644 --- a/browser-extension/mv3/test/network-recording-test.html +++ b/browser-extension/mv3/test/network-recording-test.html @@ -162,6 +162,7 @@

Log

if (id && seenRequestIds.has(id)) break; // dedup across reconnects if (id) seenRequestIds.add(id); streamedEntries.push(msg.entry); + console.log('[network-recording] entry received:', msg.entry); document.getElementById('targetTabId').textContent = `${targetTabId} (${streamedEntries.length} entries)`; break; From e3138ecef205bccce0ff47fa16640779dfbae91b Mon Sep 17 00:00:00 2001 From: nafees87n Date: Tue, 2 Jun 2026 16:02:59 +0530 Subject: [PATCH 13/23] =?UTF-8?q?feat(extension):=20network-recording=20li?= =?UTF-8?q?fecycle=20=E2=80=94=20focus-return,=20disconnect=20auto-stop,?= =?UTF-8?q?=20optional=20maxDuration,=20stop=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return focus to the originating LTS tab on stop, with a cascade fallback: LTS tab -> its window -> a hardcoded LTS fallback URL (TODO before merge: replace with the real URL from the LTS team). Captures senderTabId + senderWindowId at start (re-adds the field removed when it was write-only). - Auto-stop a recording when its LTS port disconnects and no reconnect arrives within a 3s grace window. In v1 the port is the only data channel, so a recording is pointless once its consumer is gone; the grace tolerates the brief drop+reconnect the _request_id dedup was built for. - maxDuration is now optional with no default: when LTS omits it there is no time cap (recording runs until user stop / tab close / port disconnect). The port-disconnect auto-stop is the real leak-guard, so the 15-min default is redundant. isOverMaxDuration no-ops when no cap is set. - Side panel reflects why a recording ended via a new NETWORK_RECORDING_ENDED message + StopReason enum (user | max-duration | connection-lost | tab-closed): amber banner for max-duration, red for connection-lost; user/tab-closed show none. Panel now stays open in the stopped state (closePanel removed). Co-Authored-By: Claude Opus 4.8 (1M context) --- browser-extension/common/src/constants.ts | 1 + .../NetworkRecordingPanel.tsx | 33 ++++- .../src/sidepanel/network-recording/index.css | 30 ++++ .../services/messageHandler/listener.ts | 5 +- .../services/networkRecording/index.ts | 136 +++++++++++++++--- 5 files changed, 185 insertions(+), 20 deletions(-) diff --git a/browser-extension/common/src/constants.ts b/browser-extension/common/src/constants.ts index 334db8437c..b2aa4a6576 100644 --- a/browser-extension/common/src/constants.ts +++ b/browser-extension/common/src/constants.ts @@ -78,6 +78,7 @@ export const CLIENT_MESSAGES = { NOTIFY_EXTENSION_STATUS_UPDATED: "notifyExtensionStatusUpdated", OPEN_CURL_IMPORT_MODAL: "openCurlImportModal", NETWORK_EVENT_CAPTURED: "networkEventCaptured", + NETWORK_RECORDING_ENDED: "networkRecordingEnded", }; export const STORAGE_TYPE = "local"; diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx index 34fd4ab17c..e288ef5e92 100644 --- a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -30,11 +30,30 @@ const formatSize = (bytes: number | undefined): string => { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; +// Why a recording ended — mirrors the StopReason union in the service worker. +type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed"; + +// Banner shown for SW-initiated stops. `user` and `tab-closed` show no banner (the user knows / +// the panel is gone), so they're absent from this map. +const STOP_BANNERS: Partial> = { + "max-duration": { + icon: "⏱", + text: "Recording stopped — time limit reached", + variant: "warning", + }, + "connection-lost": { + icon: "⚠", + text: "Connection to Load Testing lost — recording stopped", + variant: "error", + }, +}; + const NetworkRecordingPanel: React.FC = () => { const [entries, setEntries] = useState([]); const [filter, setFilter] = useState({ text: "", method: "ALL" }); const [recordingStartTime, setRecordingStartTime] = useState(Date.now()); const [isRecording, setIsRecording] = useState(true); + const [stopReason, setStopReason] = useState(null); const [elapsedTime, setElapsedTime] = useState(0); const [targetUrl, setTargetUrl] = useState(""); const currentTabIdRef = useRef(null); @@ -63,8 +82,12 @@ const NetworkRecordingPanel: React.FC = () => { init(); const listener = (message: any) => { - if (message.action === "networkEventCaptured" && message.tabId === currentTabIdRef.current) { + if (message.tabId !== currentTabIdRef.current) return; + if (message.action === "networkEventCaptured") { setEntries((prev) => [...prev, message.entry]); + } else if (message.action === "networkRecordingEnded") { + setIsRecording(false); + setStopReason(message.reason ?? "user"); } }; @@ -105,6 +128,7 @@ const NetworkRecordingPanel: React.FC = () => { targetTabId: currentTabIdRef.current, }); setIsRecording(false); + setStopReason("user"); }, []); const filteredEntries = useMemo(() => { @@ -144,6 +168,13 @@ const NetworkRecordingPanel: React.FC = () => { {targetUrl &&
{targetUrl}
}
+ {!isRecording && stopReason && STOP_BANNERS[stopReason] && ( +
+ {STOP_BANNERS[stopReason].icon} + {STOP_BANNERS[stopReason].text} +
+ )} +
{counts.total} diff --git a/browser-extension/common/src/sidepanel/network-recording/index.css b/browser-extension/common/src/sidepanel/network-recording/index.css index 61e17260e0..85a52d48a5 100644 --- a/browser-extension/common/src/sidepanel/network-recording/index.css +++ b/browser-extension/common/src/sidepanel/network-recording/index.css @@ -101,6 +101,36 @@ body { font-family: monospace; } +.end-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + font-size: 12px; + font-weight: 500; + border-bottom: 1px solid #333; +} + +.end-banner-icon { + flex: none; + font-size: 14px; + line-height: 1; +} + +.end-banner-text { + flex: 1; +} + +.end-banner--error { + background: rgba(228, 52, 52, 0.12); + color: #ff6b6b; +} + +.end-banner--warning { + background: rgba(255, 152, 0, 0.12); + color: #ffb74d; +} + .summary-counters { display: flex; padding: 8px 16px; diff --git a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts index a79bbbac59..e9bdc517bb 100644 --- a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts +++ b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts @@ -52,7 +52,10 @@ export const initExternalMessageListener = () => { break; case EXTENSION_EXTERNAL_MESSAGES.START_NETWORK_RECORDING: - startNetworkRecording(message.payload?.url, message.payload?.config || {}).then(sendResponse); + startNetworkRecording(message.payload?.url, message.payload?.config || {}, { + tabId: sender.tab?.id, + windowId: sender.tab?.windowId, + }).then(sendResponse); return true; case EXTENSION_EXTERNAL_MESSAGES.STOP_NETWORK_RECORDING: diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index 5c861482ff..a9ce85d295 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -7,14 +7,31 @@ interface NetworkRecordingState { url: string; startTime: number; config: { maxDuration?: number }; + // The LTS tab/window that started the recording. On stop we return focus here. + // Both may be gone by stop time (user closed the tab/window mid-recording). + senderTabId?: number; + senderWindowId?: number; } +// TODO(before-merge): replace with the real LTS fallback URL provided by the LTS team. +// Used only when the originating LTS tab AND its window are both gone at stop time — +// we open this so the user always lands back in an LTS context. Placeholder for now. +const LTS_FALLBACK_URL = "https://www.browserstack.com"; + const activeRecordings = new Map(); const recordingEntries = new Map(); -// LTS streaming subscribers, keyed by target tabId. One LTS page may subscribe to many tabs. +// LTS streaming subscribers, keyed by target tabId. One LTS page may subscribe to many tabs, +// but a given recorded tab has exactly one port (one consumer per recording). const subscriptions = new Map>(); +// In v1 the LTS port is the only data channel, so a recording is pointless once its consumer +// is gone — every entry after that is buffered for nobody. When a tab's port disconnects we +// give LTS a short window to reconnect (it dedups on _request_id, so a brief drop+reconnect is +// expected). If nobody re-subscribes within the window, the recording is stopped. +const disconnectGraceTimers = new Map>(); +const DISCONNECT_GRACE_MS = 3_000; + // webRequest requestId -> request-start correlation data (internal only, never surfaced). const correlationMap = new Map(); @@ -38,8 +55,6 @@ if (sidePanelApi) { sidePanelApi.setOptions({ enabled: false }).catch(() => {}); } -const DEFAULT_MAX_DURATION = 15 * 60 * 1000; - // --- Service-worker keepalive ---------------------------------------------------------------- // An open port does NOT keep an MV3 SW alive — only events/API calls reset the 30s idle timer. // During idle gaps (user reading a page, no requests firing) the SW would die and lose the @@ -77,7 +92,7 @@ chrome.alarms.onAlarm.addListener((alarm) => { activeRecordings.forEach((recording, tabId) => { if (isOverMaxDuration(recording)) { - stopNetworkRecording(tabId); + stopNetworkRecording(tabId, "max-duration"); } }); @@ -98,8 +113,10 @@ const onBeforeSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails }); }; +// maxDuration is optional with no default — when LTS omits it there is no time cap, and the +// recording runs until the user stops it, the tab closes, or the LTS port disconnects (grace). const isOverMaxDuration = (recording: NetworkRecordingState): boolean => - Date.now() - recording.startTime > (recording.config.maxDuration || DEFAULT_MAX_DURATION); + recording.config.maxDuration !== undefined && Date.now() - recording.startTime > recording.config.maxDuration; const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => { const recording = activeRecordings.get(details.tabId); @@ -107,7 +124,7 @@ const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) // Prompt auto-stop on a busy page; the alarm tick is the backstop for a quiet page. if (isOverMaxDuration(recording)) { - stopNetworkRecording(details.tabId); + stopNetworkRecording(details.tabId, "max-duration"); return; } @@ -157,6 +174,25 @@ const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { }); }; +// Why a recording ended — drives the message the side panel shows. +// user – the user clicked Stop in the panel (no banner; just "Stopped") +// max-duration – config.maxDuration elapsed (amber banner) +// connection-lost – the LTS port disconnected and no reconnect within the grace window (red) +// tab-closed – the recorded tab was removed (panel is gone with it; informational only) +type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed"; + +/** Tell the side panel a recording ended and why, so it can flip to a stopped state with the + * right banner. Fire-and-forget — the panel may already be closed. */ +const notifyPanelEnded = (tabId: number, reason: StopReason) => { + chrome.runtime + .sendMessage({ + action: CLIENT_MESSAGES.NETWORK_RECORDING_ENDED, + tabId, + reason, + }) + .catch(() => {}); +}; + /** Signal subscribed LTS ports that a recording has ended. Pure signal — the consumer then * fetches the summary via getNetworkRecordingSummary. */ const streamCompleteToPorts = (tabId: number) => { @@ -200,10 +236,29 @@ const isValidUrl = (url: string): boolean => { } }; +const cancelDisconnectGrace = (tabId: number) => { + const timer = disconnectGraceTimers.get(tabId); + if (timer !== undefined) { + clearTimeout(timer); + disconnectGraceTimers.delete(tabId); + } +}; + const removePortFromAllSubscriptions = (port: chrome.runtime.Port) => { subscriptions.forEach((ports, tabId) => { - ports.delete(port); - if (ports.size === 0) subscriptions.delete(tabId); + if (!ports.delete(port)) return; + if (ports.size > 0) return; + subscriptions.delete(tabId); + + // The consumer for an active recording just vanished. Hold a short grace window for a + // reconnect; if none arrives, stop the recording (its data channel is gone). + if (!activeRecordings.has(tabId) || disconnectGraceTimers.has(tabId)) return; + const timer = setTimeout(() => { + disconnectGraceTimers.delete(tabId); + if (subscriptions.get(tabId)?.size) return; // reconnected in the meantime + if (activeRecordings.has(tabId)) stopNetworkRecording(tabId, "connection-lost"); + }, DISCONNECT_GRACE_MS); + disconnectGraceTimers.set(tabId, timer); }); }; @@ -239,6 +294,7 @@ export const initNetworkRecordingPort = () => { if (!subscriptions.has(tabId)) subscriptions.set(tabId, new Set()); subscriptions.get(tabId)!.add(port); + cancelDisconnectGrace(tabId); // a reconnect within the grace window keeps the recording alive // Recording already ended (e.g. very short) but buffer still around: signal complete. if (!activeRecordings.has(tabId)) { @@ -273,16 +329,10 @@ const openPanel = (tabId: number) => { // Safari / other: no panel API → no-op (capture + streaming still work). }; -const closePanel = (tabId: number) => { - if (sidePanelApi) { - sidePanelApi.setOptions({ tabId, enabled: false }).catch(() => {}); - } - // Firefox sidebar is global; leave it for the user to close. -}; - export const startNetworkRecording = ( url: string, - config: { maxDuration?: number } = {} + config: { maxDuration?: number } = {}, + sender?: { tabId?: number; windowId?: number } ): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { if (!url || !isValidUrl(url)) { return Promise.resolve({ success: false, error: "Invalid URL. Must be a valid http or https URL." }); @@ -300,6 +350,8 @@ export const startNetworkRecording = ( url, startTime: Date.now(), config, + senderTabId: sender?.tabId, + senderWindowId: sender?.windowId, }; activeRecordings.set(tab.id, state); @@ -336,7 +388,47 @@ const buildSummary = (recording: NetworkRecordingState, totalCount: number): Rec }; }; -export const stopNetworkRecording = (targetTabId: number): { success: boolean; error?: string } => { +// Return the user to where they came from after a recording ends. Cascade: +// 1. the originating LTS tab, if it still exists +// 2. else its window (LTS tab closed but window alive), focusing it +// 3. else open the LTS fallback URL in a new tab (tab + window both gone) +// Each step is guarded; failures fall through to the next. +const returnFocusToSender = (recording: NetworkRecordingState) => { + const { senderTabId, senderWindowId } = recording; + + const openFallback = () => { + chrome.tabs.create({ url: LTS_FALLBACK_URL }).catch(() => {}); + }; + + const tryWindowThenFallback = () => { + if (senderWindowId === undefined) { + openFallback(); + return; + } + chrome.windows.update(senderWindowId, { focused: true }).then( + () => {}, + () => openFallback() + ); + }; + + if (senderTabId === undefined) { + tryWindowThenFallback(); + return; + } + + // tabs.get rejects if the tab is gone -> fall through to window, then fallback. + chrome.tabs + .get(senderTabId) + .then( + () => chrome.tabs.update(senderTabId, { active: true }).then(() => {}, tryWindowThenFallback), + tryWindowThenFallback + ); +}; + +export const stopNetworkRecording = ( + targetTabId: number, + reason: StopReason = "user" +): { success: boolean; error?: string } => { const recording = activeRecordings.get(targetTabId); if (!recording) { return { success: false, error: `No active recording for tab ${targetTabId}` }; @@ -344,6 +436,8 @@ export const stopNetworkRecording = (targetTabId: number): { success: boolean; e const entries = recordingEntries.get(targetTabId) || []; + cancelDisconnectGrace(targetTabId); + // Stop returns { success } only. Whoever holds the stream (LTS) learns of the end via the // port `complete` signal and fetches the metadata with getNetworkRecordingSummary — the same // path regardless of who triggered this stop (LTS or the side panel) — so retain it briefly. @@ -351,6 +445,8 @@ export const stopNetworkRecording = (targetTabId: number): { success: boolean; e // Signal subscribed LTS ports before tearing down the buffer. streamCompleteToPorts(targetTabId); + // Tell the side panel why it ended so it can show the right stopped state / banner. + notifyPanelEnded(targetTabId, reason); activeRecordings.delete(targetTabId); recordingEntries.delete(targetTabId); @@ -361,7 +457,8 @@ export const stopNetworkRecording = (targetTabId: number): { success: boolean; e } stopKeepaliveIfIdle(); - closePanel(targetTabId); + // Leave the panel open showing the stopped state + reason banner; the user closes it. + returnFocusToSender(recording); return { success: true }; }; @@ -418,11 +515,14 @@ export const handleNetworkRecordingOnClientPageLoad = (tab: chrome.tabs.Tab) => }; const cleanupRecording = (tabId: number) => { + cancelDisconnectGrace(tabId); const recording = activeRecordings.get(tabId); if (recording) { retainSummary(buildSummary(recording, recordingEntries.get(tabId)?.length ?? 0)); } streamCompleteToPorts(tabId); + // The recorded tab closed — its panel is gone with it, but send for contract completeness. + notifyPanelEnded(tabId, "tab-closed"); activeRecordings.delete(tabId); recordingEntries.delete(tabId); if (activeRecordings.size === 0) { From 0197339e62a7eae34bca2d807fad5ecf6fdedfe0 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 12:59:16 +0530 Subject: [PATCH 14/23] feat(extension): guard network recording on extension-enabled state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block startNetworkRecording when the extension is disabled and return a structured error so LTS can surface "enable the Requestly extension" instead of opening a tab and streaming an empty HAR. Also auto-stop any active recording if the extension is toggled off mid-recording (the recorder's webRequest listeners are independent of the enabled flag), via a new "extension-disabled" StopReason that runs the normal teardown — LTS gets the complete signal + fetchable summary, the panel gets a banner reason. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mv3/src/service-worker/index.ts | 3 +- .../services/networkRecording/index.ts | 38 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/index.ts b/browser-extension/mv3/src/service-worker/index.ts index eb1b7b82ba..c6136efec4 100644 --- a/browser-extension/mv3/src/service-worker/index.ts +++ b/browser-extension/mv3/src/service-worker/index.ts @@ -6,7 +6,7 @@ import { handleInstallUninstall } from "./services/installUninstall"; import { initExternalMessageListener, initMessageHandler } from "./services/messageHandler/listener"; import { initRulesManager } from "./services/rulesManager"; import { initWebRequestInterceptor } from "./services/webRequestInterceptor"; -import { initNetworkRecordingPort } from "./services/networkRecording"; +import { initNetworkRecordingPort, initNetworkRecordingExtensionToggleListener } from "./services/networkRecording"; // initialize (async () => { @@ -21,4 +21,5 @@ import { initNetworkRecordingPort } from "./services/networkRecording"; initWebRequestInterceptor(); initDevtoolsListener(); initNetworkRecordingPort(); + initNetworkRecordingExtensionToggleListener(); })(); diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index a9ce85d295..d14247a20d 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -1,5 +1,7 @@ import { tabService, TAB_SERVICE_DATA } from "../tabService"; import { CLIENT_MESSAGES } from "common/constants"; +import { isExtensionEnabled } from "../../../utils"; +import { onVariableChange, Variable } from "../../variable"; import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry } from "./harBuilder"; interface NetworkRecordingState { @@ -175,11 +177,12 @@ const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { }; // Why a recording ended — drives the message the side panel shows. -// user – the user clicked Stop in the panel (no banner; just "Stopped") -// max-duration – config.maxDuration elapsed (amber banner) -// connection-lost – the LTS port disconnected and no reconnect within the grace window (red) -// tab-closed – the recorded tab was removed (panel is gone with it; informational only) -type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed"; +// user – the user clicked Stop in the panel (no banner; just "Stopped") +// max-duration – config.maxDuration elapsed (amber banner) +// connection-lost – the LTS port disconnected and no reconnect within the grace window (red) +// tab-closed – the recorded tab was removed (panel is gone with it; informational only) +// extension-disabled – the Requestly extension was toggled off mid-recording (red banner) +type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed" | "extension-disabled"; /** Tell the side panel a recording ended and why, so it can flip to a stopped state with the * right banner. Fire-and-forget — the panel may already be closed. */ @@ -310,6 +313,20 @@ export const initNetworkRecordingPort = () => { }); }; +/** + * Stop every active recording if the extension is turned off mid-recording. The recorder's + * webRequest listeners are independent of the extension-enabled flag, so without this a recording + * would keep capturing while the UI says "disabled". Each stop runs the normal teardown — LTS gets + * `complete` + a fetchable summary, the panel shows the disabled banner. + */ +export const initNetworkRecordingExtensionToggleListener = () => { + onVariableChange(Variable.IS_EXTENSION_ENABLED, (enabled) => { + if (enabled) return; + // Snapshot keys first — stopNetworkRecording mutates activeRecordings while we iterate. + Array.from(activeRecordings.keys()).forEach((tabId) => stopNetworkRecording(tabId, "extension-disabled")); + }); +}; + // Firefox exposes sidebarAction only on the `browser.*` namespace, not the `chrome` alias. const firefoxSidebar = (globalThis as any).browser?.sidebarAction as { open?: () => Promise } | undefined; @@ -329,13 +346,20 @@ const openPanel = (tabId: number) => { // Safari / other: no panel API → no-op (capture + streaming still work). }; -export const startNetworkRecording = ( +export const startNetworkRecording = async ( url: string, config: { maxDuration?: number } = {}, sender?: { tabId?: number; windowId?: number } ): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { + // Don't start a recording while the extension is off — the user-facing state would be + // inconsistent (UI says "disabled" yet a recording + panel are live). Return a structured + // error so LTS can surface "enable the Requestly extension" instead of getting an empty HAR. + if (!(await isExtensionEnabled())) { + return { success: false, error: "Requestly extension is disabled. Enable it to start a recording." }; + } + if (!url || !isValidUrl(url)) { - return Promise.resolve({ success: false, error: "Invalid URL. Must be a valid http or https URL." }); + return { success: false, error: "Invalid URL. Must be a valid http or https URL." }; } return new Promise((resolve) => { From 21c86feedf53314a1ab565adf5bd9919ba783154 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 13:02:08 +0530 Subject: [PATCH 15/23] refactor(extension): drop chrome.alarms from network recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 20s keepalive ping already keeps the MV3 SW warm for the whole recording (well under the 30s idle limit), so the alarm backstop is redundant — and in packed production builds its 0.5min period silently clamps to ~60s, making it a weaker max-duration guard than a timer. - max-duration: per-recording setTimeout (cleared on every teardown path), with the inline onCompleted check as the busy-page fast path. - correlation-map sweep: folded into the keepalive ping callback. - removed the "alarms" permission from chrome/edge/firefox manifests and the alarm listener. Accepted edge case documented: an OS sleep/wake SW kill can delay max-duration enforcement by a few seconds on a fully idle tab — no data loss (nothing records while asleep). Also adds a correlation-flow doc comment at onBeforeSendHeaders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mv3/src/manifest.chrome.json | 1 - browser-extension/mv3/src/manifest.edge.json | 1 - .../mv3/src/manifest.firefox.json | 1 - .../services/networkRecording/index.ts | 79 +++++++++++-------- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/browser-extension/mv3/src/manifest.chrome.json b/browser-extension/mv3/src/manifest.chrome.json index b4dc57fa77..a03e6a332f 100644 --- a/browser-extension/mv3/src/manifest.chrome.json +++ b/browser-extension/mv3/src/manifest.chrome.json @@ -61,7 +61,6 @@ "default_path": "sidepanel/network-recording/index.html" }, "permissions": [ - "alarms", "contextMenus", "declarativeNetRequest", "proxy", diff --git a/browser-extension/mv3/src/manifest.edge.json b/browser-extension/mv3/src/manifest.edge.json index b03453aadb..9c16b42f73 100644 --- a/browser-extension/mv3/src/manifest.edge.json +++ b/browser-extension/mv3/src/manifest.edge.json @@ -61,7 +61,6 @@ "default_path": "sidepanel/network-recording/index.html" }, "permissions": [ - "alarms", "contextMenus", "declarativeNetRequest", "proxy", diff --git a/browser-extension/mv3/src/manifest.firefox.json b/browser-extension/mv3/src/manifest.firefox.json index e1d5ae51aa..56fc0141e3 100644 --- a/browser-extension/mv3/src/manifest.firefox.json +++ b/browser-extension/mv3/src/manifest.firefox.json @@ -71,7 +71,6 @@ ] }, "permissions": [ - "alarms", "contextMenus", "declarativeNetRequest", "proxy", diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index d14247a20d..15865a9e4e 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -13,6 +13,8 @@ interface NetworkRecordingState { // Both may be gone by stop time (user closed the tab/window mid-recording). senderTabId?: number; senderWindowId?: number; + // Per-recording max-duration auto-stop timer (only set when config.maxDuration is given). + maxDurationTimer?: ReturnType; } // TODO(before-merge): replace with the real LTS fallback URL provided by the LTS team. @@ -60,23 +62,36 @@ if (sidePanelApi) { // --- Service-worker keepalive ---------------------------------------------------------------- // An open port does NOT keep an MV3 SW alive — only events/API calls reset the 30s idle timer. // During idle gaps (user reading a page, no requests firing) the SW would die and lose the -// in-memory buffer. Prevention: a ~20s API-ping interval keeps the SW warm while a recording is -// active. Backstop: a chrome.alarms tick (0.5min floor; sub-0.5 is clamped in packed builds) -// survives SW death, re-wakes it, and runs the max-duration auto-stop + correlation-map sweep so -// a quiet page still stops and stale correlation entries don't leak. -const KEEPALIVE_ALARM = "nr-keepalive"; +// in-memory buffer. A ~20s API-ping interval (well under the 30s limit) keeps the SW warm for the +// whole recording, so we don't need chrome.alarms: max-duration runs off a per-recording setTimeout +// (see startNetworkRecording) and the correlation-map sweep piggybacks on the ping below. +// +// Accepted edge case: the SW can still be killed abruptly on OS sleep/wake regardless of the ping. +// While asleep nothing is being recorded, so a max-duration "overrun" is meaningless; on wake the +// next network event (onCompleted's inline isOverMaxDuration check) or the next ping stops it — a +// few seconds' delay on a fully idle tab, never lost data. Not worth an alarms permission to cover. const KEEPALIVE_PING_MS = 20_000; const CORRELATION_TTL_MS = 60_000; let keepalivePingId: ReturnType | undefined; +const sweepStaleCorrelations = () => { + const now = Date.now(); + correlationMap.forEach((data, requestId) => { + if (now - data.startTime > CORRELATION_TTL_MS) { + correlationMap.delete(requestId); + } + }); +}; + const startKeepalive = () => { - if (keepalivePingId === undefined) { - keepalivePingId = setInterval(() => { - // Any extension API call resets the SW idle timer. - chrome.runtime.getPlatformInfo().catch(() => {}); - }, KEEPALIVE_PING_MS); - } - chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 }); + if (keepalivePingId !== undefined) return; + keepalivePingId = setInterval(() => { + // Any extension API call resets the SW idle timer. + chrome.runtime.getPlatformInfo().catch(() => {}); + // Sweep orphaned correlation entries (request started, never completed/errored) so they + // don't leak. Normal entries are deleted on completion; this is only the un-correlated tail. + sweepStaleCorrelations(); + }, KEEPALIVE_PING_MS); }; const stopKeepaliveIfIdle = () => { @@ -85,28 +100,21 @@ const stopKeepaliveIfIdle = () => { clearInterval(keepalivePingId); keepalivePingId = undefined; } - chrome.alarms.clear(KEEPALIVE_ALARM); }; - -// Alarm tick: enforce max-duration even on quiet pages, and sweep stale correlation entries. -chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== KEEPALIVE_ALARM) return; - - activeRecordings.forEach((recording, tabId) => { - if (isOverMaxDuration(recording)) { - stopNetworkRecording(tabId, "max-duration"); - } - }); - - const now = Date.now(); - correlationMap.forEach((data, requestId) => { - if (now - data.startTime > CORRELATION_TTL_MS) { - correlationMap.delete(requestId); - } - }); -}); // ------------------------------------------------------------------------------------------- +// --- Request/response correlation ----------------------------------------------------------- +// A HAR entry needs request-side data (start time, request headers) AND response-side data +// (status, response headers, timing), but those arrive on two different webRequest events. We +// stitch them via correlationMap, keyed by the browser's details.requestId (NOT the LTS-facing +// _request_id — that's a separate per-entry UUID): +// 1. onBeforeSendHeaders → store { startTime, requestHeaders } keyed by requestId. +// 2. onCompleted/onError → look up + delete that entry (one-shot), merge with response data +// into one HAR entry via buildCompletedEntry/buildErrorEntry. +// 3. Cache hits have no onBeforeSendHeaders → correlation is undefined; the builder falls back +// to details.timeStamp + empty request headers. Expected, not an error. +// 4. Orphans (started, never completed/errored — cancelled, navigated away) are swept by the +// CORRELATION_TTL_MS pass in the keepalive ping. const onBeforeSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => { if (!activeRecordings.has(details.tabId)) return; correlationMap.set(details.requestId, { @@ -382,6 +390,13 @@ export const startNetworkRecording = async ( recordingEntries.set(tab.id, []); tabService.setData(tab.id, TAB_SERVICE_DATA.NETWORK_RECORDING, { active: true }); + // Max-duration auto-stop. The keepalive ping keeps the SW alive so this timer fires; the + // inline isOverMaxDuration check in onCompleted is the fast path on a busy page. (See the + // sleep/wake caveat in the keepalive comment — the only case this timer can be late.) + if (config.maxDuration !== undefined) { + state.maxDurationTimer = setTimeout(() => stopNetworkRecording(tab.id!, "max-duration"), config.maxDuration); + } + addWebRequestListeners(); startKeepalive(); openPanel(tab.id); @@ -460,6 +475,7 @@ export const stopNetworkRecording = ( const entries = recordingEntries.get(targetTabId) || []; + if (recording.maxDurationTimer !== undefined) clearTimeout(recording.maxDurationTimer); cancelDisconnectGrace(targetTabId); // Stop returns { success } only. Whoever holds the stream (LTS) learns of the end via the @@ -542,6 +558,7 @@ const cleanupRecording = (tabId: number) => { cancelDisconnectGrace(tabId); const recording = activeRecordings.get(tabId); if (recording) { + if (recording.maxDurationTimer !== undefined) clearTimeout(recording.maxDurationTimer); retainSummary(buildSummary(recording, recordingEntries.get(tabId)?.length ?? 0)); } streamCompleteToPorts(tabId); From e5f2bcc1cba6e7549190d940f947a89b909ef7c1 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 14:54:45 +0530 Subject: [PATCH 16/23] fix(extension): decouple network-recording panel open from tab creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the side panel inside the chrome.tabs.create callback (off the external LTS message path) broke after the extension-enabled guard added an await before tab creation — sidePanel.open()'s user-gesture window was already gone. Stop opening the panel eagerly there. Instead lean on the already-wired CLIENT_PAGE_LOADED path: the NETWORK_RECORDING tab flag is set on start, and handleNetworkRecordingOnClientPageLoad opens the panel once the new tab's page loads — the same decoupled pattern session-recording uses. Also: - openPanel now chains setOptions().then(open) so open() targets the registered per-tab path instead of racing it, and temporarily logs open() failures to the SW console to confirm the open path. - GET_EXTENSION_METADATA now returns isExtensionEnabled so LTS can check enabled state up front via the metadata call it already makes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/messageHandler/listener.ts | 11 +++++--- .../services/networkRecording/index.ts | 25 +++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts index e9bdc517bb..997f102ce0 100644 --- a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts +++ b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts @@ -45,11 +45,14 @@ export const initExternalMessageListener = () => { chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { switch (message.action) { case EXTENSION_EXTERNAL_MESSAGES.GET_EXTENSION_METADATA: - sendResponse({ - name: chrome.runtime.getManifest().name, - version: chrome.runtime.getManifest().version, + isExtensionEnabled().then((enabled) => { + sendResponse({ + name: chrome.runtime.getManifest().name, + version: chrome.runtime.getManifest().version, + isExtensionEnabled: enabled, + }); }); - break; + return true; case EXTENSION_EXTERNAL_MESSAGES.START_NETWORK_RECORDING: startNetworkRecording(message.payload?.url, message.payload?.config || {}, { diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index 15865a9e4e..05996a92d7 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -340,13 +340,19 @@ const firefoxSidebar = (globalThis as any).browser?.sidebarAction as { open?: () const openPanel = (tabId: number) => { if (sidePanelApi) { - // Chrome / Edge: per-tab side panel. - sidePanelApi.setOptions({ - tabId, - path: "sidepanel/network-recording/index.html", - enabled: true, - }); - sidePanelApi.open({ tabId }).catch(() => {}); + // Chrome / Edge: register the per-tab panel first, then open. open() must follow setOptions + // so it targets the enabled per-tab path rather than racing the registration. + sidePanelApi + .setOptions({ + tabId, + path: "sidepanel/network-recording/index.html", + enabled: true, + }) + .then(() => sidePanelApi.open({ tabId })) + // TODO(network-recording): temporary logging to confirm whether open() succeeds from the + // CLIENT_PAGE_LOADED path (vs. being blocked by the user-gesture requirement). Remove once + // the panel-open strategy is confirmed. + .catch((e) => console.warn("[network-recording] sidePanel open failed:", e)); } else if (firefoxSidebar?.open) { // Firefox: global sidebar (auto-open validated on FF 151, no user gesture needed). firefoxSidebar.open().catch(() => {}); @@ -399,7 +405,10 @@ export const startNetworkRecording = async ( addWebRequestListeners(); startKeepalive(); - openPanel(tab.id); + // Panel opening is decoupled from tab creation: the NETWORK_RECORDING tab flag is set above, + // and handleNetworkRecordingOnClientPageLoad opens the panel on CLIENT_PAGE_LOADED once the + // new tab's page loads. This matches the session-recording pattern and avoids opening the + // side panel off this external-message path (where the user-gesture window is already gone). resolve({ success: true, targetTabId: tab.id }); }); From 53d9d7525ea4b199e3fa7d43fea7d6d920e62648 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 15:10:44 +0530 Subject: [PATCH 17/23] fix(extension): restore eager side-panel open on network recording start Reverts the panel-open changes that stopped the side panel from opening. The decoupled CLIENT_PAGE_LOADED path did not open the panel (a page-load event is not a user gesture), and the earlier extension-enabled guard's await before tabs.create had already pushed sidePanel.open() past the gesture window. Restores the pre-change synchronous path: startNetworkRecording is sync to chrome.tabs.create and calls openPanel(tab.id) eagerly in the callback, exactly as it worked before. Drops the isExtensionEnabled() start-guard entirely (LTS can still read enabled state from GET_EXTENSION_METADATA). Retained from this session: the chrome.alarms->setTimeout max-duration refactor, the mid-recording extension-disabled toggle auto-stop, and isExtensionEnabled in the metadata response. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/networkRecording/index.ts | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index 05996a92d7..ee8b9cf6b2 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -1,6 +1,5 @@ import { tabService, TAB_SERVICE_DATA } from "../tabService"; import { CLIENT_MESSAGES } from "common/constants"; -import { isExtensionEnabled } from "../../../utils"; import { onVariableChange, Variable } from "../../variable"; import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry } from "./harBuilder"; @@ -340,19 +339,13 @@ const firefoxSidebar = (globalThis as any).browser?.sidebarAction as { open?: () const openPanel = (tabId: number) => { if (sidePanelApi) { - // Chrome / Edge: register the per-tab panel first, then open. open() must follow setOptions - // so it targets the enabled per-tab path rather than racing the registration. - sidePanelApi - .setOptions({ - tabId, - path: "sidepanel/network-recording/index.html", - enabled: true, - }) - .then(() => sidePanelApi.open({ tabId })) - // TODO(network-recording): temporary logging to confirm whether open() succeeds from the - // CLIENT_PAGE_LOADED path (vs. being blocked by the user-gesture requirement). Remove once - // the panel-open strategy is confirmed. - .catch((e) => console.warn("[network-recording] sidePanel open failed:", e)); + // Chrome / Edge: per-tab side panel. + sidePanelApi.setOptions({ + tabId, + path: "sidepanel/network-recording/index.html", + enabled: true, + }); + sidePanelApi.open({ tabId }).catch(() => {}); } else if (firefoxSidebar?.open) { // Firefox: global sidebar (auto-open validated on FF 151, no user gesture needed). firefoxSidebar.open().catch(() => {}); @@ -360,20 +353,13 @@ const openPanel = (tabId: number) => { // Safari / other: no panel API → no-op (capture + streaming still work). }; -export const startNetworkRecording = async ( +export const startNetworkRecording = ( url: string, config: { maxDuration?: number } = {}, sender?: { tabId?: number; windowId?: number } ): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { - // Don't start a recording while the extension is off — the user-facing state would be - // inconsistent (UI says "disabled" yet a recording + panel are live). Return a structured - // error so LTS can surface "enable the Requestly extension" instead of getting an empty HAR. - if (!(await isExtensionEnabled())) { - return { success: false, error: "Requestly extension is disabled. Enable it to start a recording." }; - } - if (!url || !isValidUrl(url)) { - return { success: false, error: "Invalid URL. Must be a valid http or https URL." }; + return Promise.resolve({ success: false, error: "Invalid URL. Must be a valid http or https URL." }); } return new Promise((resolve) => { @@ -405,10 +391,7 @@ export const startNetworkRecording = async ( addWebRequestListeners(); startKeepalive(); - // Panel opening is decoupled from tab creation: the NETWORK_RECORDING tab flag is set above, - // and handleNetworkRecordingOnClientPageLoad opens the panel on CLIENT_PAGE_LOADED once the - // new tab's page loads. This matches the session-recording pattern and avoids opening the - // side panel off this external-message path (where the user-gesture window is already gone). + openPanel(tab.id); resolve({ success: true, targetTabId: tab.id }); }); From a1cae4ec0bbdd968399ea9e03ef0b1a04e3e7022 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 16:30:06 +0530 Subject: [PATCH 18/23] feat(extension): reject network recording start when extension is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds the disabled-guard dropped earlier, but reads the enabled state from a synchronously-readable in-memory cache instead of awaiting storage. The previous guard's `await isExtensionEnabled()` before chrome.tabs.create pushed sidePanel.open() past its user-gesture window, so the panel stopped opening. Reading the cache keeps the path to openPanel fully synchronous. The cache (isExtensionEnabledCache) is seeded at init and kept fresh via the existing IS_EXTENSION_ENABLED onVariableChange listener — the same pattern clientHandler uses. Optimistic default (true) covers the brief window before the seed resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/networkRecording/index.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index ee8b9cf6b2..df9e4e8300 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -1,5 +1,6 @@ import { tabService, TAB_SERVICE_DATA } from "../tabService"; import { CLIENT_MESSAGES } from "common/constants"; +import { isExtensionEnabled } from "../../../utils"; import { onVariableChange, Variable } from "../../variable"; import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry } from "./harBuilder"; @@ -320,14 +321,23 @@ export const initNetworkRecordingPort = () => { }); }; +// Synchronously-readable copy of IS_EXTENSION_ENABLED, so startNetworkRecording can reject a start +// while the extension is off WITHOUT an async storage read — an await there would push +// sidePanel.open() past its user-gesture window and the panel would never open. Seeded at init and +// kept fresh via onVariableChange (the same cache pattern clientHandler uses). Optimistic default +// (true) covers the tiny window before the seed resolves; the SW seeds long before any LTS call. +let isExtensionEnabledCache = true; + /** - * Stop every active recording if the extension is turned off mid-recording. The recorder's - * webRequest listeners are independent of the extension-enabled flag, so without this a recording - * would keep capturing while the UI says "disabled". Each stop runs the normal teardown — LTS gets - * `complete` + a fetchable summary, the panel shows the disabled banner. + * Seed the enabled cache and stop every active recording if the extension is turned off + * mid-recording. The recorder's webRequest listeners are independent of the extension-enabled flag, + * so without this a recording would keep capturing while the UI says "disabled". Each stop runs the + * normal teardown — LTS gets `complete` + a fetchable summary, the panel shows the disabled banner. */ -export const initNetworkRecordingExtensionToggleListener = () => { +export const initNetworkRecordingExtensionToggleListener = async () => { + isExtensionEnabledCache = await isExtensionEnabled(); onVariableChange(Variable.IS_EXTENSION_ENABLED, (enabled) => { + isExtensionEnabledCache = enabled; if (enabled) return; // Snapshot keys first — stopNetworkRecording mutates activeRecordings while we iterate. Array.from(activeRecordings.keys()).forEach((tabId) => stopNetworkRecording(tabId, "extension-disabled")); @@ -358,6 +368,16 @@ export const startNetworkRecording = ( config: { maxDuration?: number } = {}, sender?: { tabId?: number; windowId?: number } ): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { + // Reject a start while the extension is off, so the UI never says "disabled" with a live + // recording. Read from the in-memory cache (NOT an await) to keep the path to openPanel + // synchronous — see isExtensionEnabledCache. + if (!isExtensionEnabledCache) { + return Promise.resolve({ + success: false, + error: "Requestly extension is disabled. Enable it to start a recording.", + }); + } + if (!url || !isValidUrl(url)) { return Promise.resolve({ success: false, error: "Invalid URL. Must be a valid http or https URL." }); } From b87bd7be6c4a2ca24e77660947c46df56d3ce100 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 16:49:44 +0530 Subject: [PATCH 19/23] fix(extension): catch first-ever disable toggle for network recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension-enabled guard and mid-recording auto-stop both relied on onVariableChange(IS_EXTENSION_ENABLED), which filtered to ChangeType.MODIFIED only. IS_EXTENSION_ENABLED is lazily stored (getVariable defaults to true when the key is absent), so the FIRST time a user disables the extension the write is a CREATED change (no prior value) — the MODIFIED-only filter silently dropped it. Result: the cache stayed true and recordings started and continued while the extension was disabled. - onVariableChange now takes an optional changeTypes param (defaults to [MODIFIED], preserving existing callers). - The network-recording toggle listener opts into [MODIFIED, CREATED] so the first disable is caught — both the start-guard cache and the mid-recording auto-stop now react correctly. Also fixes a stale "alarm tick" comment left from the chrome.alarms removal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/networkRecording/index.ts | 21 ++++++++++++------- .../mv3/src/service-worker/variable.ts | 11 ++++++++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index df9e4e8300..ac332c103b 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -1,5 +1,6 @@ import { tabService, TAB_SERVICE_DATA } from "../tabService"; import { CLIENT_MESSAGES } from "common/constants"; +import { ChangeType } from "common/storage"; import { isExtensionEnabled } from "../../../utils"; import { onVariableChange, Variable } from "../../variable"; import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry } from "./harBuilder"; @@ -132,7 +133,7 @@ const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) const recording = activeRecordings.get(details.tabId); if (!recording) return; - // Prompt auto-stop on a busy page; the alarm tick is the backstop for a quiet page. + // Prompt auto-stop on a busy page; the per-recording setTimeout is the backstop for a quiet page. if (isOverMaxDuration(recording)) { stopNetworkRecording(details.tabId, "max-duration"); return; @@ -336,12 +337,18 @@ let isExtensionEnabledCache = true; */ export const initNetworkRecordingExtensionToggleListener = async () => { isExtensionEnabledCache = await isExtensionEnabled(); - onVariableChange(Variable.IS_EXTENSION_ENABLED, (enabled) => { - isExtensionEnabledCache = enabled; - if (enabled) return; - // Snapshot keys first — stopNetworkRecording mutates activeRecordings while we iterate. - Array.from(activeRecordings.keys()).forEach((tabId) => stopNetworkRecording(tabId, "extension-disabled")); - }); + onVariableChange( + Variable.IS_EXTENSION_ENABLED, + (enabled) => { + isExtensionEnabledCache = enabled; + if (enabled) return; + // Snapshot keys first — stopNetworkRecording mutates activeRecordings while we iterate. + Array.from(activeRecordings.keys()).forEach((tabId) => stopNetworkRecording(tabId, "extension-disabled")); + }, + // Catch CREATED too: the flag is lazily stored, so the first time the user disables it the + // write is a CREATED change (no prior value), which the default MODIFIED-only filter drops. + [ChangeType.MODIFIED, ChangeType.CREATED] + ); }; // Firefox exposes sidebarAction only on the `browser.*` namespace, not the `chrome` alias. diff --git a/browser-extension/mv3/src/service-worker/variable.ts b/browser-extension/mv3/src/service-worker/variable.ts index 324a8213f5..37ca36dcf2 100644 --- a/browser-extension/mv3/src/service-worker/variable.ts +++ b/browser-extension/mv3/src/service-worker/variable.ts @@ -14,11 +14,18 @@ export const getVariable = async (name: Variable, defaultValue?: T) return ((await getRecord(getStorageKey(name))) as T) ?? defaultValue; }; -export const onVariableChange = (name: Variable, callback: (newValue: T, oldValue: T) => void) => { +export const onVariableChange = ( + name: Variable, + callback: (newValue: T, oldValue: T) => void, + // Defaults to MODIFIED only (existing behavior). Pass CREATED too to also catch the first-ever + // write of a variable — variables are lazily created, so the first toggle of a never-set value + // is a CREATED change (oldValue undefined), which a MODIFIED-only filter would silently drop. + changeTypes: ChangeType[] = [ChangeType.MODIFIED] +) => { onRecordChange( { keyFilter: getStorageKey(name), - changeTypes: [ChangeType.MODIFIED], + changeTypes, }, (changes) => { callback(changes[changes.length - 1].newValue, changes[0].oldValue); From 8bce6fe966c1f7c83813ccdacf98049ae87b6dfd Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 17:08:00 +0530 Subject: [PATCH 20/23] fix(extension): don't route user away on extension-disabled stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit returnFocusToSender exists to return the user to their LTS context after a recording they were watching ends (manual stop / max-duration / connection lost). An extension-disabled stop is different — it's a side effect of the user toggling the extension off, not a recording finishing — so moving their focus to another tab is surprising. The stopped-state banner already explains why. Skip the focus return for reason === "extension-disabled"; other reasons are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/service-worker/services/networkRecording/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index ac332c103b..951289a4fa 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -517,7 +517,13 @@ export const stopNetworkRecording = ( stopKeepaliveIfIdle(); // Leave the panel open showing the stopped state + reason banner; the user closes it. - returnFocusToSender(recording); + // Don't yank focus on an extension-disabled stop: that's a side effect of the user toggling + // the extension off, not a recording they were watching finishing — moving them to another + // tab would be surprising. The banner already tells them why it stopped. Other reasons + // (user / max-duration / connection-lost) return the user to their LTS context. + if (reason !== "extension-disabled") { + returnFocusToSender(recording); + } return { success: true }; }; From db11d145c6d0e7fecf00851aabb339975e7fc96b Mon Sep 17 00:00:00 2001 From: nafees87n Date: Wed, 3 Jun 2026 17:17:31 +0530 Subject: [PATCH 21/23] refactor(extension): route user back only on user-initiated stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return focus to the LTS context only when the user themselves stops the recording (clicked Stop). max-duration, connection-lost, and extension-disabled are background/system events, not actions on this recording — yanking the user's focus on top of the explanatory banner is surprising. Collapses the prior extension-disabled exclusion into the simpler positive rule (reason === "user"). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service-worker/services/networkRecording/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index 951289a4fa..c16950ea50 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -517,11 +517,11 @@ export const stopNetworkRecording = ( stopKeepaliveIfIdle(); // Leave the panel open showing the stopped state + reason banner; the user closes it. - // Don't yank focus on an extension-disabled stop: that's a side effect of the user toggling - // the extension off, not a recording they were watching finishing — moving them to another - // tab would be surprising. The banner already tells them why it stopped. Other reasons - // (user / max-duration / connection-lost) return the user to their LTS context. - if (reason !== "extension-disabled") { + // Return focus to the LTS context ONLY when the user themselves ended the recording (clicked + // Stop). Every other reason — max-duration, connection-lost, extension-disabled — is a + // background/system event, not an action on this recording; yanking the user's focus on top of + // the banner that already explains what happened would be surprising. + if (reason === "user") { returnFocusToSender(recording); } From 8a95ecc0f73fd73d00944cb2decc49bedf214804 Mon Sep 17 00:00:00 2001 From: Nafees Nehar Date: Wed, 3 Jun 2026 18:40:45 +0530 Subject: [PATCH 22/23] =?UTF-8?q?feat(extension):=20Network=20Interceptor?= =?UTF-8?q?=20v2=20=E2=80=94=20request/response=20bodies=20+=20headers=20(?= =?UTF-8?q?XHR/Fetch=20via=20web-sdk)=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extension): network recording v2 — request/response bodies + headers for XHR/Fetch via web-sdk Captures full request + response bodies and headers for XHR/Fetch by reusing the web-sdk Network interceptor (the module session recording uses), filling the HAR fields v1 left empty (request.postData, response.content.text). - SDK is the SOLE source for XHR/Fetch: webRequest is hard-suppressed for "xmlhttprequest" in onBeforeSendHeaders/onRequestCompleted/onRequestError, so there's exactly one source and no correlation. The v1 correlationMap is left intact for the non-xhr/fetch resource types that still come from webRequest. - New MAIN-world page script (networkBodyRecorder) calls the global Requestly.Network.intercept; the web-sdk UMD is injected per recorded tab (re-injected on webNavigation.onCommitted, single-tab scoped). overrideResponse is false (observe only — never alters the real request/response). - Size caps re-implemented in the page script (Network.intercept has no options; maxPayloadSize/ignoreMediaResponse live only on SessionRecorder): media-skip + per-body maxPayloadSize, surfaced as a _truncated extension field on the entry. - maxPayloadSize is configurable via startNetworkRecording config (default 100KB). - Stop gates the page callback off (never Network.clearInterceptors() — that would nuke other SDK consumers like session recording). - SDK-sourced entries flow through the same deliverEntry/stream path as v1, so LTS and the side panel consume them identically. No new stream message type. Known limitation: an XHR/Fetch the SDK can't capture (pre-injection race, no-cors/opaque, CSP-blocked injection) is dropped entirely (not backfilled from webRequest) — accepted trade for the no-correlation simplicity. Builds clean; message wiring verified static end-to-end. Live-browser recording not yet exercised. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(extension): reference the web-sdk global as bare `Requestly`, not window.Requestly The web-sdk UMD declares a top-level `var Requestly` (no explicit window attach). Referencing it bare — as the shipping sessionRecorderHelper.js does with `Requestly.SessionRecorder` — resolves the global binding regardless of how the injected file's scope reflects onto window; `window.Requestly` was a fragile stronger assumption. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(extension): restore eager side-panel open + drop isExtensionEnabled start guard (v2) The decoupled CLIENT_PAGE_LOADED panel-open path didn't open the side panel (a page-load event is not a user gesture), and the isExtensionEnabled await before tabs.create pushed sidePanel.open() past the gesture window. Restore the synchronous start path with eager openPanel(tab.id), and drop the start-guard. (Converges with base's 53d9d7525; reconciled in the following merge.) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- browser-extension/common/src/constants.ts | 4 + .../NetworkRecordingPanel.tsx | 7 +- browser-extension/mv3/rollup.config.js | 11 ++ .../client/pageScriptMessageListener.ts | 22 ++++ .../src/page-scripts/networkBodyRecorder.js | 102 ++++++++++++++++ .../services/messageHandler/listener.ts | 6 + .../services/networkRecording/harBuilder.ts | 112 +++++++++++++++++- .../services/networkRecording/index.ts | 110 +++++++++++++++-- 8 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 browser-extension/mv3/src/page-scripts/networkBodyRecorder.js diff --git a/browser-extension/common/src/constants.ts b/browser-extension/common/src/constants.ts index b2aa4a6576..fa1528b34a 100644 --- a/browser-extension/common/src/constants.ts +++ b/browser-extension/common/src/constants.ts @@ -45,6 +45,9 @@ export const EXTENSION_MESSAGES = { TRIGGER_OPEN_CURL_MODAL: "triggerOpenCurlModal", STOP_NETWORK_RECORDING: "stopNetworkRecording", GET_NETWORK_RECORDING_STATE: "getNetworkRecordingState", + // v2 body capture: SW → client content script → page script (networkBodyRecorder) start/stop. + START_NETWORK_BODY_CAPTURE: "startNetworkBodyCapture", + STOP_NETWORK_BODY_CAPTURE: "stopNetworkBodyCapture", }; export const EXTENSION_EXTERNAL_MESSAGES = { @@ -79,6 +82,7 @@ export const CLIENT_MESSAGES = { OPEN_CURL_IMPORT_MODAL: "openCurlImportModal", NETWORK_EVENT_CAPTURED: "networkEventCaptured", NETWORK_RECORDING_ENDED: "networkRecordingEnded", + NETWORK_BODY_CAPTURED: "networkBodyCaptured", }; export const STORAGE_TYPE = "local"; diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx index e288ef5e92..b290704320 100644 --- a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -31,7 +31,7 @@ const formatSize = (bytes: number | undefined): string => { }; // Why a recording ended — mirrors the StopReason union in the service worker. -type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed"; +type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed" | "extension-disabled"; // Banner shown for SW-initiated stops. `user` and `tab-closed` show no banner (the user knows / // the panel is gone), so they're absent from this map. @@ -46,6 +46,11 @@ const STOP_BANNERS: Partial { diff --git a/browser-extension/mv3/rollup.config.js b/browser-extension/mv3/rollup.config.js index 3dcd28aa0f..1aa435de84 100644 --- a/browser-extension/mv3/rollup.config.js +++ b/browser-extension/mv3/rollup.config.js @@ -145,4 +145,15 @@ export default [ }, plugins: commonPlugins, }, + { + ...commonConfig, + // Network Interceptor v2 body capture. Uses the global Requestly.Network (web-sdk UMD injected + // separately), so no npm deps to resolve — commonPlugins (no nodeResolve) is sufficient. + input: "src/page-scripts/networkBodyRecorder.js", + output: { + file: `${OUTPUT_DIR}/page-scripts/networkBodyRecorder.ps.js`, + format: "iife", + }, + plugins: commonPlugins, + }, ]; diff --git a/browser-extension/mv3/src/content-scripts/client/pageScriptMessageListener.ts b/browser-extension/mv3/src/content-scripts/client/pageScriptMessageListener.ts index 6f8e5ddc08..5b311410df 100644 --- a/browser-extension/mv3/src/content-scripts/client/pageScriptMessageListener.ts +++ b/browser-extension/mv3/src/content-scripts/client/pageScriptMessageListener.ts @@ -1,6 +1,20 @@ import { CLIENT_MESSAGES, EXTENSION_MESSAGES } from "common/constants"; export const initPageScriptMessageListener = () => { + // SW → page relay for Network Interceptor v2 body capture start/stop control signals. + // The page script (networkBodyRecorder, MAIN world) listens for source "requestly:extension". + chrome.runtime.onMessage.addListener((message) => { + if ( + message?.action === EXTENSION_MESSAGES.START_NETWORK_BODY_CAPTURE || + message?.action === EXTENSION_MESSAGES.STOP_NETWORK_BODY_CAPTURE + ) { + window.postMessage( + { source: "requestly:extension", action: message.action, payload: message.payload }, + window.location.href + ); + } + }); + window.addEventListener("message", function (event) { if (event.source !== window || event.data.source !== "requestly:client") { return; @@ -41,6 +55,14 @@ export const initPageScriptMessageListener = () => { case EXTENSION_MESSAGES.CACHE_SHARED_STATE: chrome.runtime.sendMessage(event.data); break; + case CLIENT_MESSAGES.NETWORK_BODY_CAPTURED: + // Network Interceptor v2: forward a captured XHR/Fetch body+headers to the SW. + // Fire-and-forget; tabId is added in the SW from sender.tab.id. + chrome.runtime.sendMessage({ + action: CLIENT_MESSAGES.NETWORK_BODY_CAPTURED, + payload: event.data.payload, + }); + break; } }); }; diff --git a/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js b/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js new file mode 100644 index 0000000000..b281d87597 --- /dev/null +++ b/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js @@ -0,0 +1,102 @@ +import { CLIENT_MESSAGES, EXTENSION_MESSAGES } from "common/constants"; + +/** + * MAIN-world page script for Network Interceptor v2 — body + header capture for XHR/Fetch. + * + * v1 captures all resource types via chrome.webRequest in the service worker, but webRequest + * cannot read bodies. For XHR/Fetch we instead use the web-sdk Network interceptor (the same + * module session recording uses), which sees request + response headers AND bodies. The service + * worker hard-suppresses webRequest for xhr/fetch, so this is their sole source — no correlation. + * + * The web-sdk UMD (`libs/requestly-web-sdk.js`) is injected before this script and declares a + * top-level `var Requestly` (global binding), so we call `Requestly.Network.intercept(...)` + * directly — no import/bundle needed. (Same global-reference style as sessionRecorderHelper.js.) + * + * Caps: Network.intercept has no size options — those live only on SessionRecorder — so we port + * its `#filterOutLargeNetworkValues` here (media-skip + per-body maxPayloadSize, with error flags). + */ + +// Mirrors web-sdk RQNetworkEventErrorCodes. +const REQUEST_TOO_LARGE = 101; +const RESPONSE_TOO_LARGE = 102; + +const isMediaContentType = (contentType) => /^(image|audio|video)\/.+$/gi.test(contentType || ""); + +const sizeInBytes = (value) => { + if (!value) return NaN; + let str = value; + if (typeof value !== "string") { + try { + str = JSON.stringify(value); + } catch { + return NaN; + } + } + return str.length; +}; + +// Clear over-cap / media bodies in place and collect error codes — a port of the web-sdk's +// SessionRecorder.#filterOutLargeNetworkValues so behaviour matches session recording. +const applyCaps = (data, cfg) => { + const errors = []; + const payload = { ...data }; + + if (cfg.ignoreMediaResponse && isMediaContentType(payload.contentType)) { + payload.response = ""; + } else if (sizeInBytes(payload.response) > cfg.maxPayloadSize) { + payload.response = ""; + errors.push(RESPONSE_TOO_LARGE); + } + + if (sizeInBytes(payload.requestData) > cfg.maxPayloadSize) { + payload.requestData = ""; + errors.push(REQUEST_TOO_LARGE); + } + + payload.errors = errors; + return payload; +}; + +(() => { + let enabled = false; + let registered = false; + let cfg = { maxPayloadSize: 100 * 1024, ignoreMediaResponse: true }; + + const postToExtension = (action, payload) => { + window.postMessage({ source: "requestly:client", action, payload }, window.location.href); + }; + + const registerInterceptorOnce = () => { + if (registered) return; + // The web-sdk UMD declares a top-level `var Requestly`; reference it bare (same as + // sessionRecorderHelper.js does with `Requestly.SessionRecorder`) rather than via window, + // so it resolves the global binding regardless of how the file scope reflects onto window. + if (typeof Requestly === "undefined" || !Requestly?.Network?.intercept) return; // UMD not present yet + registered = true; + // overrideResponse=false → observe only, never block/alter the real response. + Requestly.Network.intercept( + /.*/, + (data) => { + if (!enabled) return; + postToExtension(CLIENT_MESSAGES.NETWORK_BODY_CAPTURED, applyCaps(data, cfg)); + }, + false + ); + }; + + window.addEventListener("message", (event) => { + if (event.source !== window || event.data?.source !== "requestly:extension") return; + + if (event.data.action === EXTENSION_MESSAGES.START_NETWORK_BODY_CAPTURE) { + const incoming = event.data.payload || {}; + if (typeof incoming.maxPayloadSize === "number") cfg.maxPayloadSize = incoming.maxPayloadSize; + if (typeof incoming.ignoreMediaResponse === "boolean") cfg.ignoreMediaResponse = incoming.ignoreMediaResponse; + enabled = true; + registerInterceptorOnce(); + } else if (event.data.action === EXTENSION_MESSAGES.STOP_NETWORK_BODY_CAPTURE) { + // Gate the callback off — do NOT call Network.clearInterceptors() (it would nuke every + // SDK consumer on the page, e.g. session recording). + enabled = false; + } + }); +})(); diff --git a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts index 997f102ce0..8b4bd23925 100644 --- a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts +++ b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts @@ -39,6 +39,7 @@ import { getNetworkRecordingState, getNetworkRecordingSummary, handleNetworkRecordingOnClientPageLoad, + onNetworkBodyCaptured, } from "../networkRecording"; export const initExternalMessageListener = () => { @@ -103,6 +104,11 @@ export const initMessageHandler = () => { onSessionRecordingStoppedNotification(sender.tab.id); break; + case CLIENT_MESSAGES.NETWORK_BODY_CAPTURED: + // Network Interceptor v2: an XHR/Fetch body+headers captured by the SDK page script. + onNetworkBodyCaptured(sender.tab?.id, message.payload); + break; + case EXTENSION_MESSAGES.START_RECORDING_EXPLICITLY: startRecordingExplicitly(message.tab ?? sender.tab, message.showWidget); break; diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts index 8ec9381bdd..22eaaeea5a 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts @@ -1,7 +1,33 @@ import { Entry, Header, QueryString } from "har-format"; -/** HAR Entry plus our `_error` extension (set on failed/aborted requests). */ -export type NetworkHarEntry = Entry & { _error?: string }; +/** + * HAR Entry plus our extensions: + * - `_error`: set on failed/aborted requests (webRequest path). + * - `_truncated`: per-body cap codes when the SDK page script dropped an over-size / media body + * (101 = request too large, 102 = response too large). Lets LTS tell "dropped (too large)" + * from a genuinely empty body. Mirrors the web-sdk RQNetworkEventErrorCodes. + */ +export type NetworkHarEntry = Entry & { _error?: string; _truncated?: number[] }; + +/** + * Shape posted by the networkBodyRecorder page script (derived from the web-sdk + * Network interceptor callback). Headers are a plain name→value record. + */ +export interface SdkNetworkPayload { + api?: string; // "xmlhttprequest" | "fetch" + method: string; + url: string; + status: number; + statusText?: string; + requestHeaders?: Record; + responseHeaders?: Record; + requestData?: unknown; + response?: unknown; + contentType?: string; + responseTime?: number; + responseURL?: string; + errors?: number[]; +} /** * The HAR _resourceType enum (Chrome DevTools convention) differs from @@ -173,3 +199,85 @@ export const buildErrorEntry = ( _error: error, }; }; + +/** Record headers → HAR Header[]. */ +const recordToHarHeaders = (headers: Record | undefined): Header[] => + Object.entries(headers || {}).map(([name, value]) => ({ name, value: value ?? "" })); + +/** Coerce an SDK body (string | object | undefined) to a HAR body string. */ +const bodyToText = (body: unknown): string | undefined => { + if (body === undefined || body === null || body === "") return undefined; + if (typeof body === "string") return body; + try { + return JSON.stringify(body); + } catch { + return undefined; + } +}; + +const byteLength = (text: string | undefined): number => (text === undefined ? -1 : text.length); + +/** + * Build a HAR 1.2 Entry from an SDK (web-sdk Network interceptor) payload — the v2 source for + * XHR/Fetch. Unlike the webRequest path this carries request + response BODIES and headers, with + * no correlation needed (the payload is self-complete). `requestId` is extension-assigned. + */ +export const buildSdkEntry = (payload: SdkNetworkPayload, requestId: string): NetworkHarEntry => { + const responseTime = Math.max(0, Math.round(payload.responseTime ?? 0)); + // The SDK doesn't give a start timestamp; derive it so startedDateTime + time are consistent. + const startTime = Date.now() - responseTime; + + const requestText = bodyToText(payload.requestData); + const responseText = bodyToText(payload.response); + const requestContentType = payload.requestHeaders + ? payload.requestHeaders["content-type"] || payload.requestHeaders["Content-Type"] + : undefined; + + const entry: NetworkHarEntry = { + startedDateTime: new Date(startTime).toISOString(), + time: responseTime, + request: { + method: payload.method, + url: payload.url, + httpVersion: "", + cookies: [], + headers: recordToHarHeaders(payload.requestHeaders), + queryString: parseQueryString(payload.url), + headersSize: -1, + bodySize: requestText !== undefined ? requestText.length : -1, + }, + response: { + status: payload.status, + statusText: payload.statusText || "", + httpVersion: "", + cookies: [], + headers: recordToHarHeaders(payload.responseHeaders), + content: { + size: byteLength(responseText), + mimeType: payload.contentType || "", + }, + redirectURL: payload.responseURL && payload.responseURL !== payload.url ? payload.responseURL : "", + headersSize: -1, + bodySize: byteLength(responseText), + }, + cache: {}, + timings: { send: 0, wait: responseTime, receive: 0 }, + _resourceType: "xhr", // SDK only sees xhr/fetch; single-bucket to match v1 (api field has the split) + _request_id: requestId, + _fromCache: null, + }; + + // Only set postData when there's an actual request body (strict HAR: omit otherwise). + if (requestText !== undefined) { + entry.request.postData = { mimeType: requestContentType || "", text: requestText }; + } + // content.text only when a body survived the cap. + if (responseText !== undefined) { + entry.response.content.text = responseText; + } + if (payload.errors && payload.errors.length) { + entry._truncated = payload.errors; + } + + return entry; +}; diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index c16950ea50..dfc32f696e 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -1,15 +1,34 @@ import { tabService, TAB_SERVICE_DATA } from "../tabService"; -import { CLIENT_MESSAGES } from "common/constants"; +import { CLIENT_MESSAGES, EXTENSION_MESSAGES } from "common/constants"; import { ChangeType } from "common/storage"; +import { injectWebAccessibleScript } from "../utils"; import { isExtensionEnabled } from "../../../utils"; import { onVariableChange, Variable } from "../../variable"; -import { buildCompletedEntry, buildErrorEntry, CorrelationData, NetworkHarEntry } from "./harBuilder"; +import { + buildCompletedEntry, + buildErrorEntry, + buildSdkEntry, + CorrelationData, + NetworkHarEntry, + SdkNetworkPayload, +} from "./harBuilder"; + +// Recording config from the LTS start call. All optional. +// - maxDuration: time cap (no cap when omitted; see isOverMaxDuration). +// - maxPayloadSize: per-body cap (bytes) applied to SDK-captured request/response bodies (v2); +// defaults to DEFAULT_MAX_PAYLOAD_SIZE when omitted. +export interface NetworkRecordingConfig { + maxDuration?: number; + maxPayloadSize?: number; +} + +const DEFAULT_MAX_PAYLOAD_SIZE = 100 * 1024; // 100 KB, matches the web-sdk SessionRecorder default interface NetworkRecordingState { targetTabId: number; url: string; startTime: number; - config: { maxDuration?: number }; + config: NetworkRecordingConfig; // The LTS tab/window that started the recording. On stop we return focus here. // Both may be gone by stop time (user closed the tab/window mid-recording). senderTabId?: number; @@ -116,8 +135,15 @@ const stopKeepaliveIfIdle = () => { // to details.timeStamp + empty request headers. Expected, not an error. // 4. Orphans (started, never completed/errored — cancelled, navigated away) are swept by the // CORRELATION_TTL_MS pass in the keepalive ping. +// +// v2: XHR/Fetch are captured solely by the web-sdk Network interceptor (page script) — it carries +// headers AND bodies. We hard-suppress the webRequest path for "xmlhttprequest" (the resource type +// for both XHR and fetch) so there's exactly one source and no correlation needed for them. +const isSdkOwnedRequest = (type: chrome.webRequest.ResourceType): boolean => type === "xmlhttprequest"; + const onBeforeSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => { if (!activeRecordings.has(details.tabId)) return; + if (isSdkOwnedRequest(details.type)) return; // SDK owns xhr/fetch; don't populate correlationMap for them correlationMap.set(details.requestId, { startTime: details.timeStamp, requestHeaders: details.requestHeaders, @@ -139,6 +165,8 @@ const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) return; } + if (isSdkOwnedRequest(details.type)) return; // xhr/fetch come from the SDK page script, not webRequest + const correlation = correlationMap.get(details.requestId); correlationMap.delete(details.requestId); @@ -153,6 +181,8 @@ const onRequestError = (details: chrome.webRequest.WebResponseErrorDetails) => { const recording = activeRecordings.get(details.tabId); if (!recording) return; + if (isSdkOwnedRequest(details.type)) return; // xhr/fetch come from the SDK page script, not webRequest + const correlation = correlationMap.get(details.requestId); correlationMap.delete(details.requestId); @@ -185,6 +215,21 @@ const deliverEntry = (tabId: number, entry: NetworkHarEntry) => { }); }; +/** + * v2: an XHR/Fetch body+headers captured by the SDK page script (networkBodyRecorder) arrives + * here via the content-script relay. These are the SOLE source for xhr/fetch (webRequest is + * hard-suppressed for them), so we just build the HAR entry and feed the same buffer + stream + * path as v1 — no correlation. `tabId` comes from the message sender. + */ +export const onNetworkBodyCaptured = (tabId: number | undefined, payload: SdkNetworkPayload | undefined) => { + if (tabId === undefined || !payload) return; + if (!activeRecordings.has(tabId)) return; // not recording this tab (stale page script / race) + + const entry = buildSdkEntry(payload, nextRequestId()); + recordingEntries.get(tabId)?.push(entry); + deliverEntry(tabId, entry); +}; + // Why a recording ended — drives the message the side panel shows. // user – the user clicked Stop in the panel (no banner; just "Stopped") // max-duration – config.maxDuration elapsed (amber banner) @@ -322,6 +367,44 @@ export const initNetworkRecordingPort = () => { }); }; +// --- v2 body capture: inject the web-sdk Network interceptor into the recorded tab ---------- +// The web-sdk UMD exposes the global `Requestly` (incl. Network); networkBodyRecorder.ps.js uses +// it. Both are MAIN-world. executeScript is one-shot, so we re-inject on each navigation of the +// recorded tab (handled by chrome.webNavigation.onCommitted below). The content-script relay +// forwards the start/stop control signals to the page script. + +const injectBodyRecorder = async (tabId: number, frameId = 0) => { + try { + // 1) web-sdk UMD lib (exposes global Requestly.Network) + await injectWebAccessibleScript("libs/requestly-web-sdk.js", { tabId, frameIds: [frameId] }); + // 2) our page script that registers the interceptor + await injectWebAccessibleScript("page-scripts/networkBodyRecorder.ps.js", { tabId, frameIds: [frameId] }); + // 3) start signal with the resolved caps (relayed by the content script to the page) + sendBodyCaptureSignal(tabId, EXTENSION_MESSAGES.START_NETWORK_BODY_CAPTURE); + } catch { + // Injection can fail on restricted pages (e.g. chrome://, strict CSP) — body capture is + // best-effort; webRequest still covers non-xhr/fetch. Don't break the recording. + } +}; + +const sendBodyCaptureSignal = (tabId: number, action: string) => { + const recording = activeRecordings.get(tabId); + const payload = + action === EXTENSION_MESSAGES.START_NETWORK_BODY_CAPTURE + ? { maxPayloadSize: recording?.config.maxPayloadSize, ignoreMediaResponse: true } + : undefined; + // Relayed by the client content script → page (source "requestly:extension"). + chrome.tabs.sendMessage(tabId, { action, payload }).catch(() => {}); +}; + +// Re-inject on navigation of a recorded tab (executeScript is one-shot). Single-tab scoped, +// matching v1's model. Gated to active recordings; main frame only. +chrome.webNavigation.onCommitted.addListener((details) => { + if (details.frameId !== 0) return; + if (!activeRecordings.has(details.tabId)) return; + injectBodyRecorder(details.tabId, 0); +}); + // Synchronously-readable copy of IS_EXTENSION_ENABLED, so startNetworkRecording can reject a start // while the extension is off WITHOUT an async storage read — an await there would push // sidePanel.open() past its user-gesture window and the panel would never open. Seeded at init and @@ -372,12 +455,13 @@ const openPanel = (tabId: number) => { export const startNetworkRecording = ( url: string, - config: { maxDuration?: number } = {}, + config: NetworkRecordingConfig = {}, sender?: { tabId?: number; windowId?: number } ): Promise<{ success: boolean; targetTabId?: number; error?: string }> => { + // NOTE: kept synchronous up to chrome.tabs.create (no await) so the LTS sendMessage user gesture + // survives to the openPanel() call — chrome.sidePanel.open() requires an in-gesture call stack. // Reject a start while the extension is off, so the UI never says "disabled" with a live - // recording. Read from the in-memory cache (NOT an await) to keep the path to openPanel - // synchronous — see isExtensionEnabledCache. + // recording. Read from the in-memory cache (NOT an await) to keep that path synchronous. if (!isExtensionEnabledCache) { return Promise.resolve({ success: false, @@ -400,7 +484,9 @@ export const startNetworkRecording = ( targetTabId: tab.id, url, startTime: Date.now(), - config, + // Resolve maxPayloadSize to its default now so the body page script (v2) can read a + // concrete cap off state without re-defaulting. maxDuration stays undefined = no cap. + config: { ...config, maxPayloadSize: config.maxPayloadSize ?? DEFAULT_MAX_PAYLOAD_SIZE }, senderTabId: sender?.tabId, senderWindowId: sender?.windowId, }; @@ -418,7 +504,15 @@ export const startNetworkRecording = ( addWebRequestListeners(); startKeepalive(); + // Open the panel here, synchronously on the external-message path. chrome.sidePanel.open() + // requires a user gesture and must run within its call stack — the LTS sendMessage provides + // that gesture, but only as long as nothing awaits before this point (hence no async + // isExtensionEnabled check above). handleNetworkRecordingOnClientPageLoad re-opens it on + // later navigations of the recorded tab as a backstop. openPanel(tab.id); + // v2: the body recorder is injected via webNavigation.onCommitted, which fires for this new + // tab's initial navigation (and every later one). No explicit inject here — it would be too + // early (the document isn't committed yet). resolve({ success: true, targetTabId: tab.id }); }); @@ -506,6 +600,8 @@ export const stopNetworkRecording = ( streamCompleteToPorts(targetTabId); // Tell the side panel why it ended so it can show the right stopped state / banner. notifyPanelEnded(targetTabId, reason); + // v2: tell the page script to stop capturing bodies (gates its callback off; no clearInterceptors). + sendBodyCaptureSignal(targetTabId, EXTENSION_MESSAGES.STOP_NETWORK_BODY_CAPTURE); activeRecordings.delete(targetTabId); recordingEntries.delete(targetTabId); From eacad9c41e59461cb0eca5745fb5e1d97aa80894 Mon Sep 17 00:00:00 2001 From: nafees87n Date: Thu, 4 Jun 2026 15:20:56 +0530 Subject: [PATCH 23/23] =?UTF-8?q?fix(extension):=20network=20recording=20?= =?UTF-8?q?=E2=80=94=20dedupe=20SDK=20re-injection=20+=20deterministic=20p?= =?UTF-8?q?anel-to-tab=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review findings on the v2 / multi-tab paths: - H2 (duplicate entries): networkBodyRecorder re-injects on every webNavigation.onCommitted, which also fires for same-document (pushState/hash) navigations where the prior injection's Network.intercept is still live — a second interceptor registered and every XHR/Fetch was captured twice (distinct _request_id, so LTS dedup couldn't catch it). Guard install with a window.__rqNetworkBodyRecorderInstalled flag so re-injection into a live document no-ops; a real navigation (fresh window) still re-installs. - M3 (multi-tab panel mis-binding): the panel inferred its tab from tabs.query({active:true}), which binds to the wrong recording when multiple tabs record concurrently. The SW now opens the per-tab side panel with ?tabId= and the panel reads that from its URL — deterministic per-recording binding on Chrome/Edge. getNetworkRecordingState now returns the recording url so the header shows the correct recorded host. Firefox: single global sidebar can't bind per-tab, so it falls back to the active tab and shows that recording's host in the header (documented limitation; concurrent multi-tab recordings share the one sidebar). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NetworkRecordingPanel.tsx | 27 +++++++++++++------ .../src/page-scripts/networkBodyRecorder.js | 9 +++++++ .../services/networkRecording/index.ts | 9 ++++--- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx index b290704320..1cbc415f12 100644 --- a/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -66,20 +66,31 @@ const NetworkRecordingPanel: React.FC = () => { useEffect(() => { const init = async () => { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab?.id) return; - currentTabIdRef.current = tab.id; - try { - setTargetUrl(new URL(tab.url).hostname); - } catch { - setTargetUrl(tab.url || ""); + // Bind to the recording this panel is for. On Chrome/Edge the SW opens the per-tab panel + // with ?tabId=, so we read it from our own URL — deterministic even when + // multiple tabs are recording at once. Firefox has a single global sidebar (no per-tab URL), + // so fall back to the active tab; the sidebar then shows whichever recorded tab is active. + const tabIdParam = new URLSearchParams(window.location.search).get("tabId"); + let tabId = tabIdParam ? Number(tabIdParam) : null; + if (tabId === null) { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + tabId = tab?.id ?? null; } + if (tabId === null) return; + currentTabIdRef.current = tabId; - chrome.runtime.sendMessage({ action: "getNetworkRecordingState", tabId: tab.id }, (response) => { + chrome.runtime.sendMessage({ action: "getNetworkRecordingState", tabId }, (response) => { if (response?.active) { setEntries(response.entries || []); setRecordingStartTime(response.startTime); setIsRecording(true); + // Header host comes from the recording's own URL (the SW's source of truth), not the + // active tab — important on Firefox where the active tab may not be the recorded one. + try { + setTargetUrl(new URL(response.url).hostname); + } catch { + setTargetUrl(response.url || ""); + } } }); }; diff --git a/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js b/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js index b281d87597..2d5b31308e 100644 --- a/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js +++ b/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js @@ -58,6 +58,15 @@ const applyCaps = (data, cfg) => { }; (() => { + // Idempotency guard across re-injections into the SAME document. The SW re-injects this script + // on every webNavigation.onCommitted of the recorded tab, which also fires for same-document + // (history.pushState / hash) navigations — where the previous injection's IIFE and its + // Requestly.Network.intercept registration are still live. Without this, a second interceptor + // would register and every XHR/Fetch would be captured (and streamed) twice. The flag lives on + // window so it survives across separate injected-script scopes in the same document. + if (window.__rqNetworkBodyRecorderInstalled) return; + window.__rqNetworkBodyRecorderInstalled = true; + let enabled = false; let registered = false; let cfg = { maxPayloadSize: 100 * 1024, ignoreMediaResponse: true }; diff --git a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts index dfc32f696e..a05e0b72e7 100644 --- a/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -439,10 +439,12 @@ const firefoxSidebar = (globalThis as any).browser?.sidebarAction as { open?: () const openPanel = (tabId: number) => { if (sidePanelApi) { - // Chrome / Edge: per-tab side panel. + // Chrome / Edge: per-tab side panel. Pass targetTabId in the path so the panel binds to THIS + // recording deterministically — it must not infer its tab from tabs.query({active:true}), + // which mis-binds when multiple tabs are recording concurrently. sidePanelApi.setOptions({ tabId, - path: "sidepanel/network-recording/index.html", + path: `sidepanel/network-recording/index.html?tabId=${tabId}`, enabled: true, }); sidePanelApi.open({ tabId }).catch(() => {}); @@ -658,7 +660,7 @@ export const getNetworkRecordingSummary = ( export const getNetworkRecordingState = ( tabId: number -): { active: boolean; entries: NetworkHarEntry[]; startTime: number } | null => { +): { active: boolean; entries: NetworkHarEntry[]; startTime: number; url: string } | null => { const recording = activeRecordings.get(tabId); if (!recording) return null; @@ -666,6 +668,7 @@ export const getNetworkRecordingState = ( active: true, entries: recordingEntries.get(tabId) || [], startTime: recording.startTime, + url: recording.url, }; };