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..fa1528b34a 100644 --- a/browser-extension/common/src/constants.ts +++ b/browser-extension/common/src/constants.ts @@ -43,10 +43,18 @@ 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", + // 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 = { GET_EXTENSION_METADATA: "getExtensionMetadata", + START_NETWORK_RECORDING: "startNetworkRecording", + STOP_NETWORK_RECORDING: "stopNetworkRecording", + GET_NETWORK_RECORDING_SUMMARY: "getNetworkRecordingSummary", }; export const CLIENT_MESSAGES = { @@ -72,6 +80,9 @@ export const CLIENT_MESSAGES = { NOTIFY_RECORD_UPDATED: "notifyRecordUpdated", NOTIFY_EXTENSION_STATUS_UPDATED: "notifyExtensionStatusUpdated", 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 new file mode 100644 index 0000000000..1cbc415f12 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/NetworkRecordingPanel.tsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +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 = { + document: "document", + stylesheet: "css", + script: "js", + image: "img", + font: "font", + media: "media", + websocket: "ws", + xhr: "xhr", + other: "other", +}; + +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 || bytes < 0) return "—"; // -1 = size unknown (HAR sentinel) + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + 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" | "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. +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", + }, + "extension-disabled": { + icon: "⚠", + text: "Requestly was disabled — 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); + const listRef = useRef(null); + + useEffect(() => { + const init = async () => { + // 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 }, (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 || ""); + } + } + }); + }; + + init(); + + const listener = (message: any) => { + 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"); + } + }; + + 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]); + + // 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 && stickToBottomRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [entries.length]); + + const handleStop = useCallback(() => { + chrome.runtime.sendMessage({ + action: "stopNetworkRecording", + targetTabId: currentTabIdRef.current, + }); + setIsRecording(false); + setStopReason("user"); + }, []); + + 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; + }); + }, [entries, filter]); + + const counts = useMemo(() => { + const total = filteredEntries.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) + ).length; + return { total, xhr, docs, static: staticCount }; + }, [filteredEntries]); + + return ( +
+
+
+
+ {isRecording && } + {isRecording ? "Recording" : "Stopped"} + {formatTime(elapsedTime)} +
+ {isRecording && ( + + )} +
+ {targetUrl &&
{targetUrl}
} +
+ + {!isRecording && stopReason && STOP_BANNERS[stopReason] && ( +
+ {STOP_BANNERS[stopReason].icon} + {STOP_BANNERS[stopReason].text} +
+ )} + +
+
+ {counts.total} + Total +
+
+ {counts.xhr} + XHR +
+
+ {counts.docs} + Docs +
+
+ {counts.static} + Static +
+
+ + + +
+ {filteredEntries.map((entry) => ( + + ))} + {filteredEntries.length === 0 && ( +
+ {entries.length === 0 ? "Waiting for network requests..." : "No requests match the current filter"} +
+ )} +
+ +
+ 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..fd8fe7c779 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/components/NetworkEventRow.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { NetworkEntry } 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 splitUrl = (url: string): { host: string; path: string } => { + try { + const parsed = new URL(url); + return { host: parsed.host, path: parsed.pathname + parsed.search || "/" }; + } catch { + return { host: "", path: url }; + } +}; + +interface NetworkEventRowProps { + entry: NetworkEntry; + typeDisplay: string; + formatSize: (bytes: number | undefined) => string; +} + +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; + const { host, path } = splitUrl(url); + + return ( +
+
+ + {method} + + + {path} + +
+
+ {host && ( + <> + + {host} + + · + + )} + + {isError ? error : status} + + · + {typeDisplay} + · + {formatSize(entry.response.content.size)} +
+
+ ); +}; + +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..85a52d48a5 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/index.css @@ -0,0 +1,366 @@ +* { + 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; +} + +.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; + 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; + flex-wrap: wrap; + gap: 6px; +} + +.method-chip { + background: #2a2a2a; + border: 1px solid #333; + border-radius: 16px; + color: #9e9e9e; + padding: 4px 10px; + font-size: 12px; + white-space: nowrap; + 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-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; +} + +.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: flex-end; + padding: 6px 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..66ee80af33 --- /dev/null +++ b/browser-extension/common/src/sidepanel/network-recording/types.ts @@ -0,0 +1,11 @@ +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/rollup.config.js b/browser-extension/mv3/rollup.config.js index b92612b2ce..1aa435de84 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` }, ], }), @@ -140,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/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/manifest.firefox.json b/browser-extension/mv3/src/manifest.firefox.json index 4ccbe0eaf8..56fc0141e3 100644 --- a/browser-extension/mv3/src/manifest.firefox.json +++ b/browser-extension/mv3/src/manifest.firefox.json @@ -41,6 +41,12 @@ "default_title": "__MSG_extIconTitle__", "default_popup": "popup/popup.html" }, + "sidebar_action": { + "default_title": "Network Recording", + "default_panel": "sidepanel/network-recording/index.html", + "default_icon": "resources/images/128x128.png", + "open_at_install": false + }, "devtools_page": "devtools/devtools.html", "icons": { "16": "resources/images/16x16.png", 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..2d5b31308e --- /dev/null +++ b/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js @@ -0,0 +1,111 @@ +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; +}; + +(() => { + // 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 }; + + 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/index.ts b/browser-extension/mv3/src/service-worker/index.ts index 848aa642c0..c6136efec4 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, initNetworkRecordingExtensionToggleListener } from "./services/networkRecording"; // initialize (async () => { @@ -19,4 +20,6 @@ import { initWebRequestInterceptor } from "./services/webRequestInterceptor"; initContextMenu(); initWebRequestInterceptor(); initDevtoolsListener(); + initNetworkRecordingPort(); + initNetworkRecordingExtensionToggleListener(); })(); 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..8b4bd23925 100644 --- a/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts +++ b/browser-extension/mv3/src/service-worker/services/messageHandler/listener.ts @@ -33,15 +33,41 @@ import { import { sendMessageToApp } from "./sender"; import { triggerOpenCurlModalMessage, updateExtensionStatus } from "../utils"; import extensionIconManager from "../extensionIconManager"; +import { + startNetworkRecording, + stopNetworkRecording, + getNetworkRecordingState, + getNetworkRecordingSummary, + handleNetworkRecordingOnClientPageLoad, + onNetworkBodyCaptured, +} from "../networkRecording"; 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, + }); }); + return true; + + case EXTENSION_EXTERNAL_MESSAGES.START_NETWORK_RECORDING: + 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: + sendResponse(stopNetworkRecording(message.payload?.targetTabId)); + break; + + case EXTENSION_EXTERNAL_MESSAGES.GET_NETWORK_RECORDING_SUMMARY: + sendResponse(getNetworkRecordingSummary(message.payload?.targetTabId)); break; } }); @@ -63,6 +89,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: @@ -77,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; @@ -227,6 +259,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/harBuilder.ts b/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts new file mode 100644 index 0000000000..22eaaeea5a --- /dev/null +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/harBuilder.ts @@ -0,0 +1,283 @@ +import { Entry, Header, QueryString } from "har-format"; + +/** + * 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 + * 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; +}; + +// Returns -1 (HAR's "size unknown" sentinel) when content-length is absent/unparseable, +// so the UI can distinguish "unknown" from a real 0-byte body. +const parseContentLength = (headers: chrome.webRequest.HttpHeader[] | undefined): number => { + const value = parseHeaderValue(headers, "content-length"); + if (!value) return -1; + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? -1 : 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: -1, mimeType: "" }, + redirectURL: "", + headersSize: -1, + bodySize: -1, + }, + cache: {}, + timings: { send: 0, wait: 0, receive: 0 }, + _resourceType: mapResourceType(details.type), + _request_id: requestId, + _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 new file mode 100644 index 0000000000..a05e0b72e7 --- /dev/null +++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts @@ -0,0 +1,702 @@ +import { tabService, TAB_SERVICE_DATA } from "../tabService"; +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, + 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: 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; + 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. +// 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, +// 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(); + +const NETWORK_RECORDING_PORT = "network-recording"; + +// Opaque, globally-unique id per entry. crypto.randomUUID() (not a counter) so ids never +// collide across a service-worker restart mid-recording — LTS dedups on _request_id across +// reconnects, and a counter would reset to 0 on restart and re-issue ids LTS already saw. +const nextRequestId = (): string => crypto.randomUUID(); + +// Accessed dynamically so the Firefox build (which has no sidePanel) lints clean — +// the chrome.sidePanel API surface is Chrome/Edge only. +const sidePanelApi = (chrome as any).sidePanel as + | { + setOptions: (opts: { tabId?: number; path?: string; enabled: boolean }) => Promise; + open: (opts: { tabId: number }) => Promise; + } + | undefined; + +if (sidePanelApi) { + sidePanelApi.setOptions({ enabled: false }).catch(() => {}); +} + +// --- 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. 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) 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 = () => { + if (activeRecordings.size > 0) return; + if (keepalivePingId !== undefined) { + clearInterval(keepalivePingId); + keepalivePingId = undefined; + } +}; +// ------------------------------------------------------------------------------------------- + +// --- 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. +// +// 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, + }); +}; + +// 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 => + recording.config.maxDuration !== undefined && Date.now() - recording.startTime > recording.config.maxDuration; + +const onRequestCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => { + const recording = activeRecordings.get(details.tabId); + if (!recording) return; + + // 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; + } + + 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); + + const entry = buildCompletedEntry(details, correlation, nextRequestId()); + 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"]); + +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); + + if (IGNORED_ERRORS.has(details.error)) return; + + const entry = buildErrorEntry(details, correlation, nextRequestId(), details.error); + recordingEntries.get(details.tabId)?.push(entry); + deliverEntry(details.tabId, entry); +}; + +/** 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, + entry, + 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. + } + }); +}; + +/** + * 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) +// 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. */ +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) => { + const subs = subscriptions.get(tabId); + if (!subs) return; + subs.forEach((port) => { + try { + port.postMessage({ type: "complete" }); + } catch { + /* ignore */ + } + }); +}; + +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"]); + } + if (!chrome.webRequest.onErrorOccurred.hasListener(onRequestError)) { + chrome.webRequest.onErrorOccurred.addListener(onRequestError, { urls: [""] }); + } +}; + +const removeWebRequestListeners = () => { + chrome.webRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + 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; + } +}; + +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) => { + 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); + }); +}; + +/** + * 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") { + // Reject subscriptions to tabs that were never recorded, so LTS can tell a bad + // targetTabId from a genuinely-empty recording. + if (!activeRecordings.has(tabId) && !recordingEntries.has(tabId)) { + port.postMessage({ type: "error", error: `No recording for tab ${tabId}` }); + return; + } + + 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); + 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)) { + port.postMessage({ type: "complete" }); + } + } else if (msg.action === "unsubscribe") { + subscriptions.get(tabId)?.delete(port); + if (subscriptions.get(tabId)?.size === 0) subscriptions.delete(tabId); + } + }); + + port.onDisconnect.addListener(() => removePortFromAllSubscriptions(port)); + }); +}; + +// --- 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 +// 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; + +/** + * 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 = 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")); + }, + // 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. +const firefoxSidebar = (globalThis as any).browser?.sidebarAction as { open?: () => Promise } | undefined; + +const openPanel = (tabId: number) => { + if (sidePanelApi) { + // 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?tabId=${tabId}`, + 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(() => {}); + } + // Safari / other: no panel API → no-op (capture + streaming still work). +}; + +export const startNetworkRecording = ( + url: string, + 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 that path synchronous. + 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." }); + } + + 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 = { + targetTabId: tab.id, + url, + startTime: Date.now(), + // 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, + }; + + activeRecordings.set(tab.id, state); + 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(); + // 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 }); + }); + }); +}; + +export interface RecordingSummary { + targetTabId: number; + url: string; + startTime: number; + endTime: number; + duration: number; + totalCount: number; +} + +const buildSummary = (recording: NetworkRecordingState, totalCount: number): RecordingSummary => { + const endTime = Date.now(); + return { + targetTabId: recording.targetTabId, + url: recording.url, + startTime: recording.startTime, + endTime, + duration: endTime - recording.startTime, + totalCount, + }; +}; + +// 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}` }; + } + + 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 + // 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)); + + // 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); + // 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); + tabService.removeData(targetTabId, TAB_SERVICE_DATA.NETWORK_RECORDING); + + if (activeRecordings.size === 0) { + removeWebRequestListeners(); + } + stopKeepaliveIfIdle(); + + // Leave the panel open showing the stopped state + reason banner; the user closes it. + // 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); + } + + return { success: true }; +}; + +// Summaries are retained for a short window after a recording ends so a stream consumer can +// fetch them on `complete` even though the buffer/state are already torn down. +const recentSummaries = new Map(); +const SUMMARY_RETENTION_MS = 5 * 60 * 1000; + +const retainSummary = (summary: RecordingSummary) => { + recentSummaries.set(summary.targetTabId, summary); + setTimeout(() => { + const current = recentSummaries.get(summary.targetTabId); + if (current === summary) recentSummaries.delete(summary.targetTabId); + }, SUMMARY_RETENTION_MS); +}; + +/** + * 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 } => { + 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 summary for tab ${targetTabId}` }; +}; + +export const getNetworkRecordingState = ( + tabId: number +): { active: boolean; entries: NetworkHarEntry[]; startTime: number; url: string } | null => { + const recording = activeRecordings.get(tabId); + if (!recording) return null; + + return { + active: true, + entries: recordingEntries.get(tabId) || [], + startTime: recording.startTime, + url: recording.url, + }; +}; + +export const handleNetworkRecordingOnClientPageLoad = (tab: chrome.tabs.Tab) => { + const recordingData = tabService.getData(tab.id, TAB_SERVICE_DATA.NETWORK_RECORDING); + if (!recordingData?.active) return; + openPanel(tab.id); +}; + +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); + // 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) { + removeWebRequestListeners(); + } + stopKeepaliveIfIdle(); +}; + +chrome.tabs.onRemoved.addListener((tabId) => { + if (!activeRecordings.has(tabId)) return; + cleanupRecording(tabId); +}); 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/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); 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..11a4187b3b --- /dev/null +++ b/browser-extension/mv3/test/network-recording-test.html @@ -0,0 +1,256 @@ + + + + + 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

+

+ In the real flow the user clicks Stop in the side panel of the recorded tab. + This button is just a fallback — either way the summary below arrives via the stream's + complete event. +

+ +
+ +
+

Recording Summary

+
No summary yet — start and stop a 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"); +});