+ );
+};
+
+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..a0eaacb445 100644
--- a/browser-extension/mv3/rollup.config.js
+++ b/browser-extension/mv3/rollup.config.js
@@ -50,6 +50,13 @@ const processManifest = (content) => {
},
},
};
+
+ if (manifestJson.externally_connectable?.matches) {
+ // Dev/beta-only LTS test origins — never shipped to production (this whole block is gated on
+ // !isProductionBuildMode). The local harness and the local LTS page connect over http.
+ manifestJson.externally_connectable.matches.push("http://localhost:3099/*");
+ manifestJson.externally_connectable.matches.push("http://load-local.bsstag.com/*");
+ }
}
return JSON.stringify(manifestJson, null, 2);
@@ -99,6 +106,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 +148,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/index.ts b/browser-extension/mv3/src/content-scripts/client/index.ts
index fbba2aa661..0857cb6c7b 100644
--- a/browser-extension/mv3/src/content-scripts/client/index.ts
+++ b/browser-extension/mv3/src/content-scripts/client/index.ts
@@ -3,6 +3,7 @@ import { initSessionRecording } from "../common/sessionRecorder";
import { getVariable, Variable } from "../../service-worker/variable";
import { initPageScriptMessageListener } from "./pageScriptMessageListener";
import { initTestRuleHandler } from "./testRuleHandler";
+import { initNetworkRecordingWidgetHandler } from "./networkRecordingWidgetHandler";
import { initExtensionMessageListener } from "../common/extensionMessageListener";
if (document.doctype?.name === "html" || document.contentType?.includes("html")) {
@@ -13,6 +14,7 @@ if (document.doctype?.name === "html" || document.contentType?.includes("html"))
initSessionRecording();
initPageScriptMessageListener();
initTestRuleHandler();
+ initNetworkRecordingWidgetHandler();
}
});
}
diff --git a/browser-extension/mv3/src/content-scripts/client/networkRecordingWidgetHandler.ts b/browser-extension/mv3/src/content-scripts/client/networkRecordingWidgetHandler.ts
new file mode 100644
index 0000000000..f0aa6166be
--- /dev/null
+++ b/browser-extension/mv3/src/content-scripts/client/networkRecordingWidgetHandler.ts
@@ -0,0 +1,54 @@
+import { CLIENT_MESSAGES, EXTENSION_MESSAGES } from "common/constants";
+
+/**
+ * Shows a small floating widget on a recorded tab when the network-recording side panel is closed,
+ * and hides it when the panel reopens. The widget's "Open panel" click asks the SW to reopen the
+ * side panel.
+ *
+ * NOTE (gesture caveat): sidePanel.open() needs a live user gesture, and the gesture does not
+ * survive the content-script → SW message hop — so the SW-side reopen may be rejected by Chrome.
+ * This is a deliberate first-pass to verify behaviour in practice; if it doesn't open, the reopen
+ * mechanism needs rethinking (see the panel-reopen discussion).
+ */
+
+const TAG_NAME = "rq-network-recording-widget";
+
+const getWidget = (): HTMLElement | null => document.querySelector(TAG_NAME);
+
+const showWidget = () => {
+ let widget = getWidget();
+ if (!widget) {
+ // createElement is fine from the isolated world — the element class is defined in the MAIN
+ // world (customElements.js) and upgrades this node. Do NOT touch customElements.* here; it's
+ // null in this context.
+ widget = document.createElement(TAG_NAME);
+ widget.classList.add("rq-element");
+ widget.addEventListener("reopen", () => {
+ chrome.runtime.sendMessage({ action: EXTENSION_MESSAGES.REOPEN_NETWORK_RECORDING_PANEL });
+ });
+ document.documentElement.appendChild(widget);
+ }
+ widget.dispatchEvent(new CustomEvent("show"));
+};
+
+const hideWidget = () => {
+ getWidget()?.dispatchEvent(new CustomEvent("hide"));
+};
+
+export const initNetworkRecordingWidgetHandler = () => {
+ // Top frame only — the client content script runs in all_frames, but the widget belongs to the
+ // page, not its (many) ad/tracker iframes.
+ if (window.top !== window) return;
+
+ chrome.runtime.onMessage.addListener((message) => {
+ switch (message.action) {
+ case CLIENT_MESSAGES.SHOW_NETWORK_RECORDING_WIDGET:
+ showWidget();
+ break;
+ case CLIENT_MESSAGES.HIDE_NETWORK_RECORDING_WIDGET:
+ hideWidget();
+ break;
+ }
+ return false;
+ });
+};
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..b79d2af518 100644
--- a/browser-extension/mv3/src/manifest.edge.json
+++ b/browser-extension/mv3/src/manifest.edge.json
@@ -21,6 +21,7 @@
"service_worker": "serviceWorker.js"
},
"externally_connectable": {
+ "ids": ["khkpfminnhkjeabcnhpjcfhegbbodpjk"],
"matches": ["https://*.bsstag.com/*", "https://*.browserstack.com/*"]
},
"web_accessible_resources": [
@@ -57,11 +58,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..f1fa8879b7
--- /dev/null
+++ b/browser-extension/mv3/src/page-scripts/networkBodyRecorder.js
@@ -0,0 +1,113 @@
+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;
+ // Init default; overwritten by the SW's resolved value on the START signal (keep in sync with
+ // DEFAULT_MAX_PAYLOAD_SIZE in networkRecording/index.ts).
+ let cfg = { maxPayloadSize: 200 * 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..da6b7c5029 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,42 @@ import {
import { sendMessageToApp } from "./sender";
import { triggerOpenCurlModalMessage, updateExtensionStatus } from "../utils";
import extensionIconManager from "../extensionIconManager";
+import {
+ startNetworkRecording,
+ stopNetworkRecording,
+ getNetworkRecordingState,
+ getNetworkRecordingSummary,
+ handleNetworkRecordingOnClientPageLoad,
+ onNetworkBodyCaptured,
+ reopenNetworkRecordingPanel,
+} 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 +90,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 +105,16 @@ 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.REOPEN_NETWORK_RECORDING_PANEL:
+ // Floating widget asked to reopen the closed side panel for this tab.
+ reopenNetworkRecordingPanel(sender.tab?.id);
+ break;
+
case EXTENSION_MESSAGES.START_RECORDING_EXPLICITLY:
startRecordingExplicitly(message.tab ?? sender.tab, message.showWidget);
break;
@@ -227,6 +265,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..8dcbd5b529
--- /dev/null
+++ b/browser-extension/mv3/src/service-worker/services/networkRecording/index.ts
@@ -0,0 +1,751 @@
+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.
+// - fallbackUrl: where to send the user on stop if the originating LTS tab+window are both gone;
+// defaults to DEFAULT_FALLBACK_URL when omitted.
+export interface NetworkRecordingConfig {
+ maxDuration?: number;
+ maxPayloadSize?: number;
+ fallbackUrl?: string;
+}
+
+const DEFAULT_MAX_PAYLOAD_SIZE = 200 * 1024; // 200 KB per-body cap (LTS-overridable via config.maxPayloadSize)
+
+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;
+}
+
+// Opened only when the originating LTS tab AND its window are both gone at stop time, so the user
+// lands back in an LTS context. LTS can override per-recording via config.fallbackUrl (e.g. a
+// session-specific deep link); this is the default when it doesn't.
+const DEFAULT_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. onClosed/onOpened are Chrome 142+/141+,
+// so they're optional and feature-detected before use.
+type PanelInfo = { path: string; tabId?: number; windowId: number };
+const sidePanelApi = (chrome as any).sidePanel as
+ | {
+ setOptions: (opts: { tabId?: number; path?: string; enabled: boolean }) => Promise;
+ open: (opts: { tabId: number }) => Promise;
+ onClosed?: { addListener: (cb: (info: PanelInfo) => void) => void };
+ onOpened?: { addListener: (cb: (info: PanelInfo) => void) => void };
+ }
+ | undefined;
+
+if (sidePanelApi) {
+ sidePanelApi.setOptions({ enabled: false }).catch(() => {});
+
+ // When the recorded tab's panel is closed, show the floating reopen widget on that tab; hide it
+ // again when the panel reopens. Feature-detected (Chrome 142+/141+); older Chrome just won't get
+ // the widget.
+ //
+ // Resolve which recorded tab a panel open/close refers to. Chrome gives info.tabId only for
+ // tab-specific panels; if it's absent, fall back to an active recording.
+ const resolveRecordedTab = (info: PanelInfo): number | undefined => {
+ if (info.tabId !== undefined && activeRecordings.has(info.tabId)) return info.tabId;
+ if (info.tabId !== undefined) return undefined; // a different (non-recorded) panel
+ // No tabId: pick an active recording whose tab is in this window.
+ for (const [tabId] of activeRecordings) {
+ // best-effort; we don't store windowId, so just take the first active recording
+ return tabId;
+ }
+ return undefined;
+ };
+
+ sidePanelApi.onClosed?.addListener((info) => {
+ const tabId = resolveRecordedTab(info);
+ if (tabId !== undefined) {
+ chrome.tabs.sendMessage(tabId, { action: CLIENT_MESSAGES.SHOW_NETWORK_RECORDING_WIDGET }).catch(() => {});
+ }
+ });
+
+ sidePanelApi.onOpened?.addListener((info) => {
+ const tabId = resolveRecordedTab(info);
+ if (tabId !== undefined) {
+ chrome.tabs.sendMessage(tabId, { action: CLIENT_MESSAGES.HIDE_NETWORK_RECORDING_WIDGET }).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).
+};
+
+/**
+ * Reopen the panel for a recorded tab on request from the floating widget (the panel was closed).
+ * NOTE: sidePanel.open() requires a live user gesture; the widget-click → content-script →
+ * runtime.sendMessage → here hop loses it, so Chrome may reject this open(). First-pass to verify
+ * empirically — if it doesn't open, the reopen path needs a different mechanism.
+ */
+export const reopenNetworkRecordingPanel = (tabId: number | undefined) => {
+ if (tabId === undefined || !activeRecordings.has(tabId)) return;
+ openPanel(tabId);
+};
+
+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: recording.config.fallbackUrl || DEFAULT_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");
+});