Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions browser-extension/common/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const EXTENSION_MESSAGES = {
TRIGGER_OPEN_CURL_MODAL: "triggerOpenCurlModal",
STOP_NETWORK_RECORDING: "stopNetworkRecording",
GET_NETWORK_RECORDING_STATE: "getNetworkRecordingState",
// v2 body capture: SW → client content script → page script (networkBodyRecorder) start/stop.
START_NETWORK_BODY_CAPTURE: "startNetworkBodyCapture",
STOP_NETWORK_BODY_CAPTURE: "stopNetworkBodyCapture",
};

export const EXTENSION_EXTERNAL_MESSAGES = {
Expand Down Expand Up @@ -79,6 +82,7 @@ export const CLIENT_MESSAGES = {
OPEN_CURL_IMPORT_MODAL: "openCurlImportModal",
NETWORK_EVENT_CAPTURED: "networkEventCaptured",
NETWORK_RECORDING_ENDED: "networkRecordingEnded",
NETWORK_BODY_CAPTURED: "networkBodyCaptured",
};

export const STORAGE_TYPE = "local";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const formatSize = (bytes: number | undefined): string => {
};

// Why a recording ended — mirrors the StopReason union in the service worker.
type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed";
type StopReason = "user" | "max-duration" | "connection-lost" | "tab-closed" | "extension-disabled";

// Banner shown for SW-initiated stops. `user` and `tab-closed` show no banner (the user knows /
// the panel is gone), so they're absent from this map.
Expand All @@ -46,6 +46,11 @@ const STOP_BANNERS: Partial<Record<StopReason, { icon: string; text: string; var
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 = () => {
Expand Down
11 changes: 11 additions & 0 deletions browser-extension/mv3/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,15 @@ export default [
},
plugins: commonPlugins,
},
{
...commonConfig,
// Network Interceptor v2 body capture. Uses the global Requestly.Network (web-sdk UMD injected
// separately), so no npm deps to resolve — commonPlugins (no nodeResolve) is sufficient.
input: "src/page-scripts/networkBodyRecorder.js",
output: {
file: `${OUTPUT_DIR}/page-scripts/networkBodyRecorder.ps.js`,
format: "iife",
},
plugins: commonPlugins,
},
];
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
});
};
102 changes: 102 additions & 0 deletions browser-extension/mv3/src/page-scripts/networkBodyRecorder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { CLIENT_MESSAGES, EXTENSION_MESSAGES } from "common/constants";

/**
* MAIN-world page script for Network Interceptor v2 — body + header capture for XHR/Fetch.
*
* v1 captures all resource types via chrome.webRequest in the service worker, but webRequest
* cannot read bodies. For XHR/Fetch we instead use the web-sdk Network interceptor (the same
* module session recording uses), which sees request + response headers AND bodies. The service
* worker hard-suppresses webRequest for xhr/fetch, so this is their sole source — no correlation.
*
* The web-sdk UMD (`libs/requestly-web-sdk.js`) is injected before this script and declares a
* top-level `var Requestly` (global binding), so we call `Requestly.Network.intercept(...)`
* directly — no import/bundle needed. (Same global-reference style as sessionRecorderHelper.js.)
*
* Caps: Network.intercept has no size options — those live only on SessionRecorder — so we port
* its `#filterOutLargeNetworkValues` here (media-skip + per-body maxPayloadSize, with error flags).
*/

// Mirrors web-sdk RQNetworkEventErrorCodes.
const REQUEST_TOO_LARGE = 101;
const RESPONSE_TOO_LARGE = 102;

const isMediaContentType = (contentType) => /^(image|audio|video)\/.+$/gi.test(contentType || "");

const sizeInBytes = (value) => {
if (!value) return NaN;
let str = value;
if (typeof value !== "string") {
try {
str = JSON.stringify(value);
} catch {
return NaN;
}
}
return str.length;
};

// Clear over-cap / media bodies in place and collect error codes — a port of the web-sdk's
// SessionRecorder.#filterOutLargeNetworkValues so behaviour matches session recording.
const applyCaps = (data, cfg) => {
const errors = [];
const payload = { ...data };

if (cfg.ignoreMediaResponse && isMediaContentType(payload.contentType)) {
payload.response = "";
} else if (sizeInBytes(payload.response) > cfg.maxPayloadSize) {
payload.response = "";
errors.push(RESPONSE_TOO_LARGE);
}

if (sizeInBytes(payload.requestData) > cfg.maxPayloadSize) {
payload.requestData = "";
errors.push(REQUEST_TOO_LARGE);
}

payload.errors = errors;
return payload;
};

(() => {
let enabled = false;
let registered = false;
let cfg = { maxPayloadSize: 100 * 1024, ignoreMediaResponse: true };

const postToExtension = (action, payload) => {
window.postMessage({ source: "requestly:client", action, payload }, window.location.href);
};

const registerInterceptorOnce = () => {
if (registered) return;
// The web-sdk UMD declares a top-level `var Requestly`; reference it bare (same as
// sessionRecorderHelper.js does with `Requestly.SessionRecorder`) rather than via window,
// so it resolves the global binding regardless of how the file scope reflects onto window.
if (typeof Requestly === "undefined" || !Requestly?.Network?.intercept) return; // UMD not present yet
registered = true;
// overrideResponse=false → observe only, never block/alter the real response.
Requestly.Network.intercept(
/.*/,
(data) => {
if (!enabled) return;
postToExtension(CLIENT_MESSAGES.NETWORK_BODY_CAPTURED, applyCaps(data, cfg));
},
false
);
};

window.addEventListener("message", (event) => {
if (event.source !== window || event.data?.source !== "requestly:extension") return;

if (event.data.action === EXTENSION_MESSAGES.START_NETWORK_BODY_CAPTURE) {
const incoming = event.data.payload || {};
if (typeof incoming.maxPayloadSize === "number") cfg.maxPayloadSize = incoming.maxPayloadSize;
if (typeof incoming.ignoreMediaResponse === "boolean") cfg.ignoreMediaResponse = incoming.ignoreMediaResponse;
enabled = true;
registerInterceptorOnce();
} else if (event.data.action === EXTENSION_MESSAGES.STOP_NETWORK_BODY_CAPTURE) {
// Gate the callback off — do NOT call Network.clearInterceptors() (it would nuke every
// SDK consumer on the page, e.g. session recording).
enabled = false;
}
});
})();
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
getNetworkRecordingState,
getNetworkRecordingSummary,
handleNetworkRecordingOnClientPageLoad,
onNetworkBodyCaptured,
} from "../networkRecording";

export const initExternalMessageListener = () => {
Expand Down Expand Up @@ -103,6 +104,11 @@ export const initMessageHandler = () => {
onSessionRecordingStoppedNotification(sender.tab.id);
break;

case CLIENT_MESSAGES.NETWORK_BODY_CAPTURED:
// Network Interceptor v2: an XHR/Fetch body+headers captured by the SDK page script.
onNetworkBodyCaptured(sender.tab?.id, message.payload);
break;

case EXTENSION_MESSAGES.START_RECORDING_EXPLICITLY:
startRecordingExplicitly(message.tab ?? sender.tab, message.showWidget);
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
import { Entry, Header, QueryString } from "har-format";

/** HAR Entry plus our `_error` extension (set on failed/aborted requests). */
export type NetworkHarEntry = Entry & { _error?: string };
/**
* HAR Entry plus our extensions:
* - `_error`: set on failed/aborted requests (webRequest path).
* - `_truncated`: per-body cap codes when the SDK page script dropped an over-size / media body
* (101 = request too large, 102 = response too large). Lets LTS tell "dropped (too large)"
* from a genuinely empty body. Mirrors the web-sdk RQNetworkEventErrorCodes.
*/
export type NetworkHarEntry = Entry & { _error?: string; _truncated?: number[] };

/**
* Shape posted by the networkBodyRecorder page script (derived from the web-sdk
* Network interceptor callback). Headers are a plain name→value record.
*/
export interface SdkNetworkPayload {
api?: string; // "xmlhttprequest" | "fetch"
method: string;
url: string;
status: number;
statusText?: string;
requestHeaders?: Record<string, string>;
responseHeaders?: Record<string, string>;
requestData?: unknown;
response?: unknown;
contentType?: string;
responseTime?: number;
responseURL?: string;
errors?: number[];
}

/**
* The HAR _resourceType enum (Chrome DevTools convention) differs from
Expand Down Expand Up @@ -173,3 +199,85 @@ export const buildErrorEntry = (
_error: error,
};
};

/** Record<name,value> headers → HAR Header[]. */
const recordToHarHeaders = (headers: Record<string, string> | 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;
};
Loading