Merge pull request #672 from getmaxun/snapshot-context

feat: inject snapshot bundle in browser context
This commit is contained in:
Karishma Shukla
2025-07-07 01:17:42 +05:30
committed by GitHub
3 changed files with 101 additions and 751 deletions

View File

@@ -80,7 +80,6 @@ interface ProcessedSnapshot {
scripts: number;
media: number;
};
totalCacheSize: number;
};
}
@@ -199,35 +198,9 @@ export class RemoteBrowser {
private snapshotDebounceTimeout: NodeJS.Timeout | null = null;
private isScrollTriggeredSnapshot = false;
/**
* Cache for network resources captured via CDP
* @private
*/
private networkResourceCache: Map<
string,
{
url: string;
content: string;
mimeType: string;
base64Encoded: boolean;
timestamp: number;
resourceType?: string;
statusCode?: number;
headers?: Record<string, any>;
}
> = new Map();
/**
* Set to track active network requests
* @private
*/
private activeRequests: Set<string> = new Set();
/**
* Flag to indicate if network monitoring is active
* @private
*/
private isNetworkMonitoringActive: boolean = false;
private networkRequestTimeout: NodeJS.Timeout | null = null;
private pendingNetworkRequests: string[] = [];
private readonly NETWORK_QUIET_PERIOD = 8000;
/**
* Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and
@@ -254,224 +227,6 @@ export class RemoteBrowser {
}, 30000); // Every 30 seconds
}
private processCSS(
cssContent: string,
cssUrl: string,
baseUrl: string,
resources?: any
): string {
try {
let processedContent = cssContent;
logger.debug(`Processing CSS from: ${cssUrl}`);
// Process @font-face declarations and collect font resources
processedContent = processedContent.replace(
/@font-face\s*\{([^}]*)\}/gi,
(fontFaceMatch, fontFaceContent) => {
let newFontFaceContent = fontFaceContent;
logger.debug(
`Processing @font-face block: ${fontFaceContent.substring(
0,
100
)}...`
);
newFontFaceContent = newFontFaceContent.replace(
/src\s*:\s*([^;}]+)[;}]/gi,
(srcMatch: any, srcValue: any) => {
let newSrcValue = srcValue;
newSrcValue = newSrcValue.replace(
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)(\s*format\s*\(\s*['"]?[^'")]*['"]?\s*\))?/gi,
(urlMatch: any, url: string, formatPart: any) => {
const originalUrl = url.trim();
logger.debug(`Found font URL in @font-face: ${originalUrl}`);
if (
originalUrl.startsWith("data:") ||
originalUrl.startsWith("blob:")
) {
return urlMatch;
}
try {
let absoluteUrl: string;
try {
absoluteUrl = new URL(originalUrl).href;
} catch (e) {
absoluteUrl = new URL(originalUrl, cssUrl || baseUrl)
.href;
}
const cachedResource =
this.networkResourceCache.get(absoluteUrl);
if (cachedResource && resources) {
const dataUrl = cachedResource.base64Encoded
? `data:${cachedResource.mimeType};base64,${cachedResource.content}`
: `data:${cachedResource.mimeType};base64,${Buffer.from(
cachedResource.content,
"utf-8"
).toString("base64")}`;
resources.fonts.push({
url: absoluteUrl,
dataUrl,
format: originalUrl.split(".").pop()?.split("?")[0],
});
}
// Keep original URL in CSS
return urlMatch;
} catch (e) {
logger.warn(
"Failed to process font URL in @font-face:",
originalUrl,
e
);
return urlMatch;
}
}
);
return `src: ${newSrcValue};`;
}
);
return `@font-face {${newFontFaceContent}}`;
}
);
// Process other url() references and collect resources
processedContent = processedContent.replace(
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
(match, url) => {
const originalUrl = url.trim();
if (
originalUrl.startsWith("data:") ||
originalUrl.startsWith("blob:")
) {
return match;
}
try {
let absoluteUrl: string;
try {
absoluteUrl = new URL(originalUrl).href;
} catch (e) {
absoluteUrl = new URL(originalUrl, cssUrl || baseUrl).href;
}
const cachedResource = this.networkResourceCache.get(absoluteUrl);
if (cachedResource && resources) {
const lowerMimeType = cachedResource.mimeType.toLowerCase();
if (lowerMimeType.includes("image/")) {
const dataUrl = cachedResource.base64Encoded
? `data:${cachedResource.mimeType};base64,${cachedResource.content}`
: `data:${cachedResource.mimeType};base64,${Buffer.from(
cachedResource.content,
"utf-8"
).toString("base64")}`;
resources.images.push({
src: absoluteUrl,
dataUrl,
alt: "",
});
} else if (
lowerMimeType.includes("font/") ||
lowerMimeType.includes("application/font")
) {
const dataUrl = cachedResource.base64Encoded
? `data:${cachedResource.mimeType};base64,${cachedResource.content}`
: `data:${cachedResource.mimeType};base64,${Buffer.from(
cachedResource.content,
"utf-8"
).toString("base64")}`;
resources.fonts.push({
url: absoluteUrl,
dataUrl,
format: originalUrl.split(".").pop()?.split("?")[0],
});
}
}
// Keep original URL in CSS
return match;
} catch (e) {
logger.warn(`Failed to process CSS URL: ${originalUrl}`, e);
return match;
}
}
);
// Process @import statements and collect stylesheets
processedContent = processedContent.replace(
/@import\s+(?:url\s*\(\s*)?['"]?([^'")]+)['"]?\s*\)?([^;]*);?/gi,
(match, url, mediaQuery) => {
const originalUrl = url.trim();
if (
originalUrl.startsWith("data:") ||
originalUrl.startsWith("blob:")
) {
return match;
}
try {
let absoluteUrl: string;
try {
absoluteUrl = new URL(originalUrl).href;
} catch (e) {
absoluteUrl = new URL(originalUrl, cssUrl || baseUrl).href;
}
const cachedResource = this.networkResourceCache.get(absoluteUrl);
if (
cachedResource &&
resources &&
cachedResource.mimeType.includes("css")
) {
const content = cachedResource.base64Encoded
? Buffer.from(cachedResource.content, "base64").toString(
"utf-8"
)
: cachedResource.content;
resources.stylesheets.push({
href: absoluteUrl,
content: this.processCSS(
content,
absoluteUrl,
baseUrl,
resources
),
media: mediaQuery ? mediaQuery.trim() : "all",
});
}
// Keep original @import
return match;
} catch (e) {
logger.warn(`Failed to process CSS @import: ${originalUrl}`, e);
return match;
}
}
);
logger.debug(`CSS processing completed for: ${cssUrl}`);
return processedContent;
} catch (error) {
logger.error("Failed to process CSS content:", error);
return cssContent; // Return original content if processing fails
}
}
private async processRRWebSnapshot(
snapshot: RRWebSnapshot
): Promise<ProcessedSnapshot> {
@@ -488,189 +243,14 @@ export class RemoteBrowser {
scripts: [] as Array<{ src: string; content: string; type?: string }>,
media: [] as Array<{ src: string; dataUrl: string; type: string }>,
};
const processNode = (node: RRWebSnapshot): RRWebSnapshot => {
const processedNode = { ...node };
// Process attributes if they exist
if (node.attributes) {
const newAttributes = { ...node.attributes };
// Process common attributes that contain URLs
const urlAttributes = ["src", "href", "data", "poster", "background"];
for (const attr of urlAttributes) {
if (newAttributes[attr]) {
const originalUrl = newAttributes[attr];
// Categorize and collect the resource instead of proxying
const lowerAttr = attr.toLowerCase();
const lowerUrl = originalUrl.toLowerCase();
if (lowerAttr === "src" && node.tagName?.toLowerCase() === "img") {
const cachedResource = this.networkResourceCache.get(originalUrl);
if (
cachedResource &&
cachedResource.mimeType.includes("image/")
) {
const dataUrl = cachedResource.base64Encoded
? `data:${cachedResource.mimeType};base64,${cachedResource.content}`
: `data:${cachedResource.mimeType};base64,${Buffer.from(
cachedResource.content,
"utf-8"
).toString("base64")}`;
resources.images.push({
src: originalUrl,
dataUrl,
alt: newAttributes.alt,
});
}
} else if (
lowerAttr === "href" &&
node.tagName?.toLowerCase() === "link"
) {
const rel = newAttributes.rel?.toLowerCase() || "";
if (rel.includes("stylesheet")) {
const cachedResource =
this.networkResourceCache.get(originalUrl);
if (cachedResource && cachedResource.mimeType.includes("css")) {
let content = cachedResource.base64Encoded
? Buffer.from(cachedResource.content, "base64").toString(
"utf-8"
)
: cachedResource.content;
// Process CSS to collect embedded resources
content = this.processCSS(
content,
originalUrl,
baseUrl,
resources
);
resources.stylesheets.push({
href: originalUrl,
content,
media: newAttributes.media || "all",
});
}
} else if (
rel.includes("font") ||
lowerUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/i)
) {
const cachedResource =
this.networkResourceCache.get(originalUrl);
if (cachedResource) {
const dataUrl = cachedResource.base64Encoded
? `data:${cachedResource.mimeType};base64,${cachedResource.content}`
: `data:${cachedResource.mimeType};base64,${Buffer.from(
cachedResource.content,
"utf-8"
).toString("base64")}`;
resources.fonts.push({
url: originalUrl,
dataUrl,
format: lowerUrl.split(".").pop()?.split("?")[0],
});
}
}
}
}
}
// Process srcset attribute - collect resources but keep original URLs
if (newAttributes.srcset) {
const originalSrcset = newAttributes.srcset;
originalSrcset.split(",").forEach((srcsetItem) => {
const parts = srcsetItem.trim().split(/\s+/);
const url = parts[0];
if (url && !url.startsWith("data:") && !url.startsWith("blob:")) {
const cachedResource = this.networkResourceCache.get(url);
if (
cachedResource &&
cachedResource.mimeType.includes("image/")
) {
const dataUrl = cachedResource.base64Encoded
? `data:${cachedResource.mimeType};base64,${cachedResource.content}`
: `data:${cachedResource.mimeType};base64,${Buffer.from(
cachedResource.content,
"utf-8"
).toString("base64")}`;
resources.images.push({
src: url,
dataUrl,
alt: newAttributes.alt,
});
}
}
});
}
processedNode.attributes = newAttributes;
}
// Process text content for style elements
if (node.tagName?.toLowerCase() === "style" && node.textContent) {
let content = node.textContent;
// Process CSS content to collect embedded resources
content = this.processCSS(content, "", baseUrl, resources);
processedNode.textContent = content;
}
// Recursively process child nodes
if (node.childNodes) {
processedNode.childNodes = node.childNodes.map(processNode);
}
return processedNode;
};
const processedSnapshot = processNode(snapshot);
// Add cached scripts and media
for (const [url, resource] of this.networkResourceCache.entries()) {
if (resource.mimeType.toLowerCase().includes("javascript")) {
const content = resource.base64Encoded
? Buffer.from(resource.content, "base64").toString("utf-8")
: resource.content;
resources.scripts.push({
src: url,
content,
type: "text/javascript",
});
} else if (
resource.mimeType.toLowerCase().includes("video/") ||
resource.mimeType.toLowerCase().includes("audio/")
) {
const dataUrl = resource.base64Encoded
? `data:${resource.mimeType};base64,${resource.content}`
: `data:${resource.mimeType};base64,${Buffer.from(
resource.content,
"utf-8"
).toString("base64")}`;
resources.media.push({
src: url,
dataUrl,
type: resource.mimeType,
});
}
}
const viewport = (await this.currentPage?.viewportSize()) || {
width: 1280,
height: 720,
};
return {
snapshot: processedSnapshot,
snapshot,
resources,
baseUrl,
viewport,
@@ -690,246 +270,10 @@ export class RemoteBrowser {
scripts: resources.scripts.length,
media: resources.media.length,
},
totalCacheSize: this.networkResourceCache.size,
},
};
}
/**
* Check if a resource should be cached based on its MIME type and URL
* @private
*/
private shouldCacheResource(mimeType: string, url: string): boolean {
const lowerMimeType = mimeType.toLowerCase();
const lowerUrl = url.toLowerCase();
// CSS Resources
if (
lowerMimeType.includes("text/css") ||
lowerMimeType.includes("application/css") ||
lowerUrl.endsWith(".css")
) {
return true;
}
// Font Resources
if (
lowerMimeType.includes("font/") ||
lowerMimeType.includes("application/font") ||
lowerMimeType.includes("application/x-font") ||
lowerUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/)
) {
return true;
}
// Image Resources
if (
lowerMimeType.includes("image/") ||
lowerUrl.match(/\.(jpg|jpeg|png|gif|webp|svg|ico|bmp|tiff|avif)(\?.*)?$/)
) {
return true;
}
// JavaScript Resources
if (
lowerMimeType.includes("javascript") ||
lowerMimeType.includes("text/js") ||
lowerMimeType.includes("application/js") ||
lowerUrl.match(/\.js(\?.*)?$/)
) {
return true;
}
// Media Resources
if (
lowerMimeType.includes("video/") ||
lowerMimeType.includes("audio/") ||
lowerUrl.match(
/\.(mp4|webm|ogg|avi|mov|wmv|flv|mp3|wav|m4a|aac|flac)(\?.*)?$/
)
) {
return true;
}
// Document Resources
if (
lowerMimeType.includes("application/pdf") ||
lowerMimeType.includes("application/msword") ||
lowerMimeType.includes("application/vnd.ms-") ||
lowerMimeType.includes("application/vnd.openxmlformats-") ||
lowerUrl.match(/\.(pdf|doc|docx|xls|xlsx|ppt|pptx)(\?.*)?$/)
) {
return true;
}
// Manifest and Icon Resources
if (
lowerMimeType.includes("application/manifest+json") ||
lowerUrl.includes("manifest.json") ||
lowerUrl.includes("browserconfig.xml")
) {
return true;
}
// SVG Resources (can be images or fonts)
if (lowerMimeType.includes("image/svg+xml") || lowerUrl.endsWith(".svg")) {
return true;
}
// Other common web resources
if (
lowerMimeType.includes("application/octet-stream") &&
lowerUrl.match(/\.(woff2?|ttf|otf|eot|css|js)(\?.*)?$/)
) {
return true;
}
return false;
}
/**
* Clean up old cached resources to prevent memory leaks
* @private
*/
private cleanupResourceCache(): void {
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
for (const [url, resource] of this.networkResourceCache.entries()) {
if (now - resource.timestamp > maxAge) {
this.networkResourceCache.delete(url);
}
}
if (this.networkResourceCache.size > 200) {
const entries = Array.from(this.networkResourceCache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
for (let i = 0; i < 50; i++) {
this.networkResourceCache.delete(entries[i][0]);
}
}
logger.debug(
`Resource cache cleaned up. Current size: ${this.networkResourceCache.size}`
);
}
/**
* Initialize network monitoring via CDP to capture all resources
* @private
*/
private async initializeNetworkMonitoring(): Promise<void> {
if (!this.client || this.isNetworkMonitoringActive) {
return;
}
try {
await this.client.send("Network.enable");
await this.client.send("Runtime.enable");
await this.client.send("Page.enable");
await this.client.send("Network.setRequestInterception", {
patterns: [
{ urlPattern: "*", resourceType: "Stylesheet" },
{ urlPattern: "*", resourceType: "Image" },
{ urlPattern: "*", resourceType: "Font" },
{ urlPattern: "*", resourceType: "Script" },
{ urlPattern: "*", resourceType: "Media" },
{ urlPattern: "*", resourceType: "Document" },
{ urlPattern: "*", resourceType: "Manifest" },
{ urlPattern: "*", resourceType: "Other" },
],
});
this.isNetworkMonitoringActive = true;
logger.info("Enhanced network monitoring enabled via CDP");
this.client.on(
"Network.responseReceived",
async ({ requestId, response, type }) => {
const mimeType = response.mimeType?.toLowerCase() || "";
const url = response.url;
const resourceType = type;
logger.debug(
`Resource received: ${resourceType} - ${mimeType} - ${url}`
);
if (this.shouldCacheResource(mimeType, url)) {
this.activeRequests.add(requestId);
try {
const { body, base64Encoded } = await this.client!.send(
"Network.getResponseBody",
{ requestId }
);
this.networkResourceCache.set(url, {
url,
content: body,
mimeType: response.mimeType || "application/octet-stream",
base64Encoded,
timestamp: Date.now(),
resourceType,
statusCode: response.status,
headers: response.headers,
});
logger.debug(
`Cached ${resourceType} resource: ${url} (${mimeType})`
);
} catch (error) {
logger.warn(
`Failed to capture ${resourceType} resource body for ${url}:`,
error
);
} finally {
this.activeRequests.delete(requestId);
}
}
}
);
this.client.on(
"Network.requestIntercepted",
async ({ interceptionId, request }) => {
try {
await this.client!.send("Network.continueInterceptedRequest", {
interceptionId,
});
logger.debug(`Request intercepted and continued: ${request.url}`);
} catch (error) {
logger.warn(
`Failed to continue intercepted request for ${request.url}:`,
error
);
}
}
);
this.client.on(
"Network.loadingFailed",
({ requestId, errorText, type }) => {
this.activeRequests.delete(requestId);
logger.debug(`Network request failed (${type}): ${errorText}`);
}
);
this.client.on("Network.loadingFinished", ({ requestId }) => {
this.activeRequests.delete(requestId);
});
// Clean up cache periodically
setInterval(() => {
this.cleanupResourceCache();
}, 60000);
} catch (error) {
logger.error("Failed to initialize enhanced network monitoring:", error);
this.isNetworkMonitoringActive = false;
}
}
private initializeMemoryManagement(): void {
setInterval(() => {
const memoryUsage = process.memoryUsage();
@@ -1098,9 +442,27 @@ export class RemoteBrowser {
url.includes("api/") ||
url.includes("ajax")
) {
setTimeout(async () => {
this.pendingNetworkRequests.push(url);
if (this.networkRequestTimeout) {
clearTimeout(this.networkRequestTimeout);
this.networkRequestTimeout = null;
}
logger.debug(
`Network request received: ${url}. Total pending: ${this.pendingNetworkRequests.length}`
);
this.networkRequestTimeout = setTimeout(async () => {
logger.info(
`Network quiet period reached. Processing ${this.pendingNetworkRequests.length} requests`
);
this.pendingNetworkRequests = [];
this.networkRequestTimeout = null;
await this.makeAndEmitDOMSnapshot();
}, 800);
}, this.NETWORK_QUIET_PERIOD);
}
});
}
@@ -1253,10 +615,10 @@ export class RemoteBrowser {
patchedGetter.apply(navigator);
patchedGetter.toString();`
);
this.currentPage = await this.context.newPage();
await this.currentPage.addInitScript({ path: './server/src/browser-management/classes/rrweb-bundle.js' });
await this.context.addInitScript({ path: './server/src/browser-management/classes/rrweb-bundle.js' });
this.currentPage = await this.context.newPage();
await this.setupPageEventListeners(this.currentPage);
@@ -1275,20 +637,10 @@ export class RemoteBrowser {
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
await blocker.disableBlockingInPage(this.currentPage);
console.log('Adblocker initialized');
if (this.client) {
await this.initializeNetworkMonitoring();
logger.info("Network monitoring initialized successfully");
}
} catch (error: any) {
console.warn('Failed to initialize adblocker, continuing without it:', error.message);
// Still need to set up the CDP session even if blocker fails
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
if (this.client) {
await this.initializeNetworkMonitoring();
logger.info("Network monitoring initialized successfully");
}
}
success = true;
@@ -1351,6 +703,11 @@ export class RemoteBrowser {
}>,
limit: number = 5
): Promise<Array<Record<string, string>>> {
if (page.isClosed()) {
logger.warn("Page is closed, cannot extract list data");
return [];
}
return await page.evaluate(
async ({ listSelector, fields, limit }: {
listSelector: string;
@@ -2119,45 +1476,7 @@ export class RemoteBrowser {
this.isDOMStreamingActive = false;
}
}
/**
* Wait for network requests to become idle
* @private
*/
private async waitForNetworkIdle(timeout: number = 2000): Promise<void> {
const startTime = Date.now();
while (this.activeRequests.size > 0 && Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (this.activeRequests.size > 0) {
logger.debug(
`Network idle timeout reached with ${this.activeRequests.size} pending requests`
);
}
}
/**
* Stop network monitoring
* @private
*/
private async stopNetworkMonitoring(): Promise<void> {
if (!this.client || !this.isNetworkMonitoringActive) {
return;
}
try {
await this.client.send("Network.disable");
this.isNetworkMonitoringActive = false;
this.networkResourceCache.clear();
this.activeRequests.clear();
logger.info("Network monitoring stopped");
} catch (error) {
logger.error("Error stopping network monitoring:", error);
}
}
/**
* CDP-based DOM snapshot creation using captured network resources
*/
@@ -2176,9 +1495,6 @@ export class RemoteBrowser {
return;
}
// Wait for network to become idle
await this.waitForNetworkIdle();
// Double-check page state after network wait
if (this.currentPage.isClosed()) {
logger.debug("Skipping DOM snapshot - page closed during network wait");
@@ -2221,7 +1537,10 @@ export class RemoteBrowser {
if (typeof window.rrwebSnapshot === "undefined") {
throw new Error("rrweb-snapshot library not available");
}
return window.rrwebSnapshot.snapshot(document);
return window.rrwebSnapshot.snapshot(document, {
inlineImages: true,
collectFonts: true,
});
});
// Process the snapshot to proxy resources
@@ -2278,6 +1597,13 @@ export class RemoteBrowser {
this.domUpdateInterval = null;
}
if (this.networkRequestTimeout) {
clearTimeout(this.networkRequestTimeout);
this.networkRequestTimeout = null;
}
this.pendingNetworkRequests = [];
if (this.client) {
try {
await this.client.send("DOM.disable");
@@ -2290,7 +1616,7 @@ export class RemoteBrowser {
logger.info("DOM streaming stopped successfully");
}
/**
/**rrweb-bundle
* Terminates the screencast session and closes the remote browser.
* If an interpretation was running it will be stopped.
* @returns {Promise<void>}
@@ -2306,10 +1632,13 @@ export class RemoteBrowser {
clearInterval(this.screencastInterval);
}
if (this.domUpdateInterval) {
clearInterval(this.domUpdateInterval);
}
if (this.client) {
await this.stopScreencast();
await this.stopDOM();
await this.stopNetworkMonitoring();
}
if (this.browser) {
@@ -2445,8 +1774,6 @@ export class RemoteBrowser {
await this.stopScreencast();
this.currentPage = page;
await this.currentPage.addInitScript({ path: './server/src/browser-management/classes/rrweb-bundle.js' });
await this.setupPageEventListeners(this.currentPage);
//await this.currentPage.setViewportSize({ height: 400, width: 900 })
@@ -2479,8 +1806,6 @@ export class RemoteBrowser {
await this.currentPage?.close();
this.currentPage = newPage;
if (this.currentPage) {
await this.currentPage.addInitScript({ path: './server/src/browser-management/classes/rrweb-bundle.js' });
await this.setupPageEventListeners(this.currentPage);
this.client = await this.currentPage.context().newCDPSession(this.currentPage);

View File

@@ -492,7 +492,8 @@ const handleChangeUrl = async (activeBrowser: RemoteBrowser, page: Page, url: st
await generator.onChangeUrl(url, page);
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 100000 });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
logger.log("debug", `Went to ${url}`);
} catch (e) {
const { message } = e as Error;

View File

@@ -296,23 +296,23 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
if (element) {
setCurrentHighlight({
element,
rect: rect,
rect: rect,
selector,
elementInfo: {
...elementInfo,
tagName: elementInfo?.tagName ?? "",
isDOMMode: true,
},
childSelectors,
childSelectors,
});
if (onHighlight) {
onHighlight({
rect: rect,
rect: rect,
elementInfo: {
...elementInfo,
tagName: elementInfo?.tagName ?? "",
isDOMMode: true,
isDOMMode: true,
},
selector,
childSelectors,
@@ -670,8 +670,8 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
if (socket) {
socket.emit("dom:scroll", {
deltaX,
deltaY
})
deltaY,
});
}
notifyLastAction("scroll");
}
@@ -789,27 +789,51 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
let rebuiltHTML = tempDoc.documentElement.outerHTML;
rebuiltHTML = "<!DOCTYPE html>\n" + rebuiltHTML;
const minimalCSS = `
<style>
/* Minimal styles - don't override too much */
html, body {
margin: 0;
padding: 8px;
overflow-x: hidden;
}
/* Hide scrollbars but keep functionality */
::-webkit-scrollbar { width: 0px; background: transparent; }
body { scrollbar-width: none; -ms-overflow-style: none; }
/* Prevent form submissions and navigation */
form { pointer-events: none; }
a[href] { pointer-events: ${isInCaptureMode ? "none" : "auto"}; }
</style>
const enhancedCSS = `
/* rrweb rebuilt content styles */
html, body {
margin: 0 !important;
padding: 8px !important;
overflow-x: hidden !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
/* Hide scrollbars for all elements */
*::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
* {
scrollbar-width: none !important; /* Firefox */
-ms-overflow-style: none !important; /* Internet Explorer 10+ */
}
/* Make everything interactive */
* {
cursor: "pointer" !important;
}
`;
if (rebuiltHTML.includes("<head>")) {
const headTagRegex = /<head[^>]*>/i;
const cssInjection = `
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<base href="${snapshotData.baseUrl}">
<style>${enhancedCSS}</style>
`;
if (headTagRegex.test(rebuiltHTML)) {
rebuiltHTML = rebuiltHTML.replace(
"<head>",
`<head><base href="${snapshotData.baseUrl}">${minimalCSS}`