From 43e1625be5d49632b0c83078a70f59c229c085c8 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Mon, 1 Jun 2026 12:49:24 +0200 Subject: [PATCH 1/9] feat: add capture assets types --- packages/rrweb-snapshot/src/snapshot.ts | 8 +- packages/rrweb/src/types.ts | 16 ++-- packages/types/src/index.ts | 102 ++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 3d32a88a71..e41516230a 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -391,7 +391,7 @@ function serializeNode( blockClass: string | RegExp; blockSelector: string | null; needsMask: boolean; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; @@ -542,7 +542,7 @@ function serializeElementNode( doc: Document; blockClass: string | RegExp; blockSelector: string | null; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; maskInputOptions: MaskInputOptions; maskInputFn: MaskInputFn | undefined; dataURLOptions?: DataURLOptions; @@ -929,7 +929,7 @@ export function serializeNodeWithId( maskTextClass: string | RegExp; maskTextSelector: string | null; skipChild: boolean; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; needsMask?: boolean; @@ -1242,7 +1242,7 @@ function snapshot( blockSelector?: string | null; maskTextClass?: string | RegExp; maskTextSelector?: string | null; - inlineStylesheet?: boolean; + inlineStylesheet?: boolean | 'all'; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; maskInputFn?: MaskInputFn; diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 0bd68c0619..87e1d7ca2a 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -17,6 +17,7 @@ import type { blockClass, canvasMutationCallback, customElementCallback, + captureAssetsParam, eventWithTime, fontCallback, hooksParam, @@ -58,10 +59,9 @@ export type recordOptions = { slimDOMOptions?: SlimDOMOptions | 'all' | true; ignoreCSSAttributes?: Set; /** - * @deprecated Since 2.0.0. This option is still supported, but is planned to - * be superseded by future captureAssets asset recording APIs. + * @deprecated asset-branch compatibility for `captureAssets.stylesheets` */ - inlineStylesheet?: boolean; + inlineStylesheet?: boolean | 'all'; hooks?: hooksParam; packFn?: PackFn; sampling?: SamplingStrategy; @@ -73,10 +73,14 @@ export type recordOptions = { userTriggeredOnInput?: boolean; collectFonts?: boolean; /** - * @deprecated Since 2.0.0. This option is still supported, but is planned to - * be superseded by future captureAssets asset recording APIs. + * @deprecated asset-branch compatibility for `captureAssets.images` */ inlineImages?: boolean; + /** + * Upcoming asset-branch support; exposed as compatibility plumbing while + * recorder implementation is wired in. + */ + captureAssets?: captureAssetsParam; plugins?: RecordPlugin[]; // departed, please use sampling options mousemoveWait?: number; @@ -103,7 +107,7 @@ export type observerParam = { maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; keepIframeSrcFn: KeepIframeSrcFn; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; styleSheetRuleCb: styleSheetRuleCallback; styleDeclarationCb: styleDeclarationCallback; canvasMutationCb: canvasMutationCallback; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9ed8a7344e..69e92743f8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -19,6 +19,12 @@ export type loadedEvent = { data: unknown; }; +export type assetStatus = { + url: string; + status: 'capturing' | 'captured' | 'media-mismatch' | 'error' | 'refused'; + timeout?: number; +}; + export type fullSnapshotEvent = { type: EventType.FullSnapshot; data: { @@ -27,9 +33,21 @@ export type fullSnapshotEvent = { top: number; left: number; }; + /* + * the assets associated with this snapshot + * info is used to delay first FullSnapshot render until e.g. stylesheet + * assets have been received by the replayer + * could also be useful for server-side processing of the event stream + * without having to delve into the structure of this full snapshot + */ + capturedAssetStatuses?: assetStatus[]; }; }; +export type fullSnapshotEventWithTime = fullSnapshotEvent & { + timestamp: number; +}; + export type incrementalSnapshotEvent = { type: EventType.IncrementalSnapshot; data: incrementalData; @@ -60,6 +78,54 @@ export type pluginEvent = { }; }; +export type captureAssetsParam = Partial<{ + /** + * Captures object URLs (blobs, files, media sources). + * More info: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL + */ + objectURLs: boolean; + /** + * Allowlist of origins to capture object URLs from. + * [origin, origin, ...] to capture from specific origins. + * e.g. ['https://example.com', 'https://www.example.com'] + * Set to `true` to capture from all origins. + * Set to `false` or `[]` to disable capturing from any origin (apart from object URLs or when inlineStylesheet=='all') + */ + origins: string[] | true | false; + /** + * capture images irrespective of origin (populated from inlineImages setting) + */ + images: boolean; + /** + * capture videos irrespective of origin + */ + video: boolean; + /** + * capture audio irrespective of origin + */ + audio: boolean; + /** + * capture stylesheets irrespective of origin (populated from inlineStylesheets setting) + */ + stylesheets: boolean | 'without-fetch'; + /* + * in milliseconds, default 2000 + * stylesheets are captured as assets in order to take their processing off the main thread + * this number may need to be reduced to ensure that stylesheet assets are emitted + * in time + */ + processStylesheetsWithin: number; + /* + * if set, process stylesheets with less than this number of css rules immediately/synchronously, + * and include directly in the snapshot without a separate asset event + */ + stylesheetsRuleThreshold: number; + /** + * In a mutation context, we are already deferred, so performance related capturing can happen immediately (without a separate asset event) + */ + _fromMutation: true; +}>; + export type assetEvent = { type: EventType.Asset; data: assetParam; @@ -69,6 +135,13 @@ export type assetEventWithTime = assetEvent & { timestamp: number; }; +export type asset = { + element: HTMLElement; + attr: string; + value: string; + styleId?: number; +}; + export enum IncrementalSource { Mutation, MouseMove, @@ -739,6 +812,35 @@ export type TakeTypedKeyValues = Pick< TakeTypeHelper[keyof TakeTypeHelper] >; +export type RebuildAssetManagerUnknownStatus = { status: 'unknown' }; +export type RebuildAssetManagerLoadingStatus = { status: 'loading' }; +export type RebuildAssetManagerLoadedStatus = { + status: 'loaded'; + url: string; + cssTexts?: string[]; +}; +export type RebuildAssetManagerFailedStatus = { status: 'failed' }; +export type RebuildAssetManagerFinalStatus = + | RebuildAssetManagerLoadedStatus + | RebuildAssetManagerFailedStatus; +export type RebuildAssetManagerStatus = + | RebuildAssetManagerUnknownStatus + | RebuildAssetManagerLoadingStatus + | RebuildAssetManagerFinalStatus; + +export interface RebuildAssetManagerInterface { + add(event: assetEvent): Promise; + get(url: string): RebuildAssetManagerStatus; + whenReady(url: string): Promise; + manageAttribute( + n: Element, + id: number, + attribute: string, + originalValue: string, + serializedNode?: serializedElementNodeWithId, + ): void; +} + export enum NodeType { Document, DocumentType, From ec1b211b08bfd7895a05db0c1423351e49ee5f26 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Mon, 1 Jun 2026 13:09:19 +0200 Subject: [PATCH 2/9] feat(snapshot): detect captured assets --- packages/rrweb-snapshot/src/index.ts | 4 + packages/rrweb-snapshot/src/snapshot.ts | 205 +++++++++++-- packages/rrweb-snapshot/src/utils.ts | 164 +++++++++- packages/rrweb-snapshot/test/snapshot.test.ts | 289 +++++++++++++++++- packages/rrweb-snapshot/test/utils.test.ts | 94 ++++++ 5 files changed, 724 insertions(+), 32 deletions(-) diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index 0218c36bb7..6094a142cb 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -1,4 +1,5 @@ import snapshot, { + getHref, serializeNodeWithId, transformAttribute, ignoreAttribute, @@ -9,6 +10,7 @@ import snapshot, { classMatchesRegex, IGNORED_NODE, genId, + getSourcesFromSrcset, } from './snapshot'; import rebuild, { buildNodeWithSN, @@ -23,6 +25,7 @@ export * from './types'; export * from './utils'; export { + getHref, snapshot, serializeNodeWithId, rebuild, @@ -40,4 +43,5 @@ export { classMatchesRegex, IGNORED_NODE, genId, + getSourcesFromSrcset, }; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index e41516230a..085c776cdf 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -16,6 +16,8 @@ import type { attributes, mediaAttributes, DataURLOptions, + asset, + captureAssetsParam, } from '@rrweb/types'; import { Mirror, @@ -31,6 +33,7 @@ import { absolutifyURLs, markCssSplits, } from './snapshot-utils'; +import { lowerIfExists, shouldCaptureAsset, stringifyCssRules } from './utils'; import dom from '@rrweb/utils'; let _id = 1; @@ -42,6 +45,11 @@ export function genId(): number { return _id++; } +let _styleId = 1; +export function genStyleId(): number { + return _styleId++; +} + function getValidTagName(element: HTMLElement): Lowercase { if (element instanceof HTMLFormElement) { return 'form'; @@ -66,7 +74,11 @@ let canvasCtx: CanvasRenderingContext2D | null; const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space // eslint-disable-next-line no-control-regex const SRCSET_COMMAS_OR_SPACES = /^[, \t\n\r\u000c]+/; -function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { +function parseSrcsetString( + doc: Document, + attributeValue: string, + urlCallback: (doc: Document, url: string) => string, +) { /* run absoluteToDoc over every url in the srcset @@ -103,13 +115,13 @@ function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { let url = collectCharacters(SRCSET_NOT_SPACES); if (url.slice(-1) === ',') { // aside: according to spec more than one comma at the end is a parse error, but we ignore that - url = absoluteToDoc(doc, url.substring(0, url.length - 1)); + url = urlCallback(doc, url.substring(0, url.length - 1)); // the trailing comma splits the srcset, so the interpretion is that // another url will follow, and the descriptor is empty output.push(url); } else { let descriptorsStr = ''; - url = absoluteToDoc(doc, url); + url = urlCallback(doc, url); let inParens = false; // eslint-disable-next-line no-constant-condition while (true) { @@ -140,6 +152,21 @@ function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { return output.join(', '); } +function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { + return parseSrcsetString(doc, attributeValue, (doc, url) => + absoluteToDoc(doc, url), + ); +} + +export function getSourcesFromSrcset(attributeValue: string): string[] { + const urls = new Set(); + parseSrcsetString(document, attributeValue, (_, url) => { + urls.add(url); + return url; + }); + return Array.from(urls); +} + const cachedDocument = new WeakMap(); export function absoluteToDoc(doc: Document, attributeValue: string): string { @@ -154,7 +181,7 @@ function isSVGElement(el: Element): boolean { return Boolean(el.tagName === 'svg' || (el as SVGElement).ownerSVGElement); } -function getHref(doc: Document, customHref?: string) { +export function getHref(doc: Document, customHref?: string) { let a = cachedDocument.get(doc); if (!a) { a = doc.createElement('a'); @@ -397,6 +424,7 @@ function serializeNode( maskInputFn: MaskInputFn | undefined; dataURLOptions?: DataURLOptions; inlineImages: boolean; + captureAssets?: captureAssetsParam; recordCanvas: boolean; keepIframeSrcFn: KeepIframeSrcFn; /** @@ -404,6 +432,7 @@ function serializeNode( */ newlyAddedElement?: boolean; cssCaptured?: boolean; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNode | false { const { @@ -418,10 +447,12 @@ function serializeNode( maskInputFn, dataURLOptions = {}, inlineImages, + captureAssets = {}, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, cssCaptured = false, + onAssetDetected, } = options; // Only record root id when document object is not the base document const rootId = getRootId(doc, mirror); @@ -457,10 +488,12 @@ function serializeNode( maskInputFn, dataURLOptions, inlineImages, + captureAssets, recordCanvas, keepIframeSrcFn, newlyAddedElement, rootId, + onAssetDetected, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -547,6 +580,7 @@ function serializeElementNode( maskInputFn: MaskInputFn | undefined; dataURLOptions?: DataURLOptions; inlineImages: boolean; + captureAssets?: captureAssetsParam; recordCanvas: boolean; keepIframeSrcFn: KeepIframeSrcFn; /** @@ -554,6 +588,7 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNode | false { const { @@ -565,28 +600,77 @@ function serializeElementNode( maskInputFn, dataURLOptions = {}, inlineImages, + captureAssets = {}, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, rootId, + onAssetDetected, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); let attributes: attributes = {}; const len = n.attributes.length; + if (tagName === 'link' && inlineStylesheet && !needBlock) { + const link = n as HTMLLinkElement; + if (link.href && lowerIfExists(link.rel) === 'stylesheet' && link.sheet) { + let sheetRules: CSSRuleList | undefined; + try { + sheetRules = link.sheet.cssRules; + } catch (e) { + // Cross-origin stylesheets are handled by asset capture when enabled. + } + if ( + sheetRules && + (!onAssetDetected || + captureAssets._fromMutation || + (captureAssets.stylesheetsRuleThreshold !== undefined && + sheetRules.length < captureAssets.stylesheetsRuleThreshold)) + ) { + attributes._cssText = stringifyCssRules(sheetRules, link.href); + } + } + } for (let i = 0; i < len; i++) { const attr = n.attributes[i]; + if (attr.name === '' || attr.name === "''") { + continue; + } + if (attributes._cssText && ['href', 'rel'].includes(attr.name)) { + continue; + } if (!ignoreAttribute(tagName, attr.name, attr.value)) { - attributes[attr.name] = transformAttribute( + const value = transformAttribute( doc, tagName, toLowerCase(attr.name), attr.value, ); + let { name } = attr; + if ( + value && + typeof value === 'string' && + onAssetDetected && + !needBlock && + shouldCaptureAsset(n, attr.name, value, captureAssets) + ) { + onAssetDetected({ + element: n, + attr: attr.name, + value, + }); + name = `rr_captured_${name}`; + } + attributes[name] = value; } } // remote css - if (tagName === 'link' && inlineStylesheet) { + if ( + tagName === 'link' && + inlineStylesheet && + !attributes._cssText && + !onAssetDetected + ) { //TODO: maybe replace this `.styleSheets` with original one const stylesheet = Array.from(doc.styleSheets).find((s) => { return s.href === (n as HTMLLinkElement).href; @@ -601,15 +685,47 @@ function serializeElementNode( attributes._cssText = cssText; } } - if (tagName === 'style' && (n as HTMLStyleElement).sheet) { - let cssText = stringifyStylesheet( - (n as HTMLStyleElement).sheet as CSSStyleSheet, - ); - if (cssText) { - if (n.childNodes.length > 1) { - cssText = markCssSplits(cssText, n as HTMLStyleElement); + if ( + tagName === 'style' && + inlineStylesheet && + captureAssets.stylesheets !== false && + (n as HTMLStyleElement).sheet + ) { + const styleEl = n as HTMLStyleElement; + const sheet = styleEl.sheet as CSSStyleSheet; + let sheetBaseHref = getHref(doc); + if (sheetBaseHref === '') { + sheetBaseHref = document.location.href; + } + let styleRules: CSSRuleList | undefined; + try { + styleRules = sheet.cssRules; + } catch (e) { + // Inaccessible sheets are handled by asset capture when enabled. + } + if ( + styleRules && + (!onAssetDetected || + captureAssets._fromMutation || + (captureAssets.stylesheetsRuleThreshold !== undefined && + styleRules.length < captureAssets.stylesheetsRuleThreshold)) + ) { + let cssText = stringifyStylesheet(sheet); + if (cssText) { + if (n.childNodes.length > 1) { + cssText = markCssSplits(cssText, styleEl); + } + attributes._cssText = cssText; } - attributes._cssText = cssText; + } else if (onAssetDetected && !captureAssets._fromMutation) { + const styleId = genStyleId(); + onAssetDetected({ + element: n, + attr: 'css_text', + value: sheetBaseHref, + styleId, + }); + attributes.rr_css_text = `${sheetBaseHref}#rr_style_el:${styleId}`; } } // form fields @@ -792,16 +908,6 @@ function serializeElementNode( }; } -function lowerIfExists( - maybeAttr: string | number | boolean | undefined | null, -): string { - if (maybeAttr === undefined || maybeAttr === null) { - return ''; - } else { - return (maybeAttr as string).toLowerCase(); - } -} - export function slimDOMDefaults( _slimDOMOptions: SlimDOMOptions | 'all' | true | false | undefined, ) { @@ -939,6 +1045,7 @@ export function serializeNodeWithId( dataURLOptions?: DataURLOptions; keepIframeSrcFn?: KeepIframeSrcFn; inlineImages?: boolean; + captureAssets?: captureAssetsParam; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; @@ -953,6 +1060,7 @@ export function serializeNodeWithId( ) => unknown; stylesheetLoadTimeout?: number; cssCaptured?: boolean; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNodeWithId | null { const { @@ -970,6 +1078,7 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions = {}, inlineImages = false, + captureAssets = {}, recordCanvas = false, onSerialize, onIframeLoad, @@ -979,10 +1088,24 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, cssCaptured = false, + onAssetDetected, } = options; let { needsMask } = options; let { preserveWhiteSpace = true } = options; + if (onAssetDetected) { + if (captureAssets.images === undefined && inlineImages) { + captureAssets.images = true; + } + if (captureAssets.stylesheets === undefined) { + if (inlineStylesheet) { + captureAssets.stylesheets = 'without-fetch'; + } else { + captureAssets.stylesheets = false; + } + } + } + if (!needsMask) { // perf: if needsMask = true, children won't also need to check const checkAncestors = needsMask === undefined; // if false, we've already checked ancestors @@ -1006,10 +1129,12 @@ export function serializeNodeWithId( maskInputFn, dataURLOptions, inlineImages, + captureAssets, recordCanvas, keepIframeSrcFn, newlyAddedElement, cssCaptured, + onAssetDetected, }); if (!_serializedNode) { // TODO: dev only @@ -1081,6 +1206,7 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1090,6 +1216,7 @@ export function serializeNodeWithId( stylesheetLoadTimeout, keepIframeSrcFn, cssCaptured: false, + onAssetDetected, }; if ( @@ -1098,11 +1225,17 @@ export function serializeNodeWithId( (serializedNode as elementNode).attributes.value !== undefined ) { // value parameter in DOM reflects the correct value, so ignore childNode + } else if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'iframe' && + (serializedNode as elementNode).attributes.rr_captured_src !== undefined + ) { + // Captured iframe-like assets should not also recurse into rendered fallback contents. } else { if ( serializedNode.type === NodeType.Element && - (serializedNode as elementNode).attributes._cssText !== undefined && - typeof serializedNode.attributes._cssText === 'string' + (serializedNode.attributes.rr_css_text || + serializedNode.attributes._cssText) ) { bypassOptions.cssCaptured = true; } @@ -1134,7 +1267,8 @@ export function serializeNodeWithId( if ( serializedNode.type === NodeType.Element && - serializedNode.tagName === 'iframe' + serializedNode.tagName === 'iframe' && + (serializedNode as elementNode).attributes.rr_captured_src === undefined ) { onceIframeLoaded( n as HTMLIFrameElement, @@ -1157,6 +1291,10 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets: { + ...captureAssets, + _fromMutation: true, + }, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1165,6 +1303,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onAssetDetected, }); if (serializedIframeNode) { @@ -1209,6 +1348,10 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets: { + ...captureAssets, + _fromMutation: true, + }, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1217,6 +1360,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onAssetDetected, }); if (serializedLinkNode) { @@ -1249,6 +1393,7 @@ function snapshot( slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; + captureAssets?: captureAssetsParam; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; @@ -1263,6 +1408,7 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNodeWithId | null { const { @@ -1273,6 +1419,7 @@ function snapshot( maskTextSelector = null, inlineStylesheet = true, inlineImages = false, + captureAssets = {}, recordCanvas = false, maskAllInputs = false, maskTextFn, @@ -1285,6 +1432,7 @@ function snapshot( iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, + onAssetDetected, keepIframeSrcFn = () => false, } = options || {}; const maskInputOptions: MaskInputOptions = @@ -1329,6 +1477,7 @@ function snapshot( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1338,6 +1487,7 @@ function snapshot( stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, + onAssetDetected, }); } @@ -1361,6 +1511,7 @@ export function visitSnapshot( export function cleanupSnapshot() { // allow a new recording to start numbering nodes from scratch _id = 1; + _styleId = 1; } export default snapshot; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 260ba6e47a..0bb52cba75 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -26,6 +26,7 @@ import type { documentTypeNode, textNode, elementNode, + captureAssetsParam, } from '@rrweb/types'; import dom from '@rrweb/utils'; @@ -134,15 +135,22 @@ export function stringifyStylesheet(s: CSSStyleSheet): string | null { // an inline + `); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected) as elementNode; + const style = findElement(serialized, 'style')!; + + expect(onAssetDetected).toHaveBeenCalledWith({ + element: el.querySelector('style'), + attr: 'css_text', + styleId: expect.any(Number), + value: 'http://localhost:3000/', + }); + expect(style.attributes.rr_css_text).toContain('#rr_style_el:'); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('does not detect style element assets when stylesheet capture is disabled', () => { + const el = render(`
+ +
`); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected, { + stylesheets: false, + origins: ['https://example.com'], + }) as elementNode; + const style = findElement(serialized, 'style')!; + + expect(onAssetDetected).not.toHaveBeenCalled(); + expect(style.attributes.rr_css_text).toBeUndefined(); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('does not detect style element assets when inlineStylesheet is disabled', () => { + const el = render(`
+ +
`); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode( + el, + onAssetDetected, + { + origins: ['https://example.com'], + }, + false, + false, + ) as elementNode; + const style = findElement(serialized, 'style')!; + + expect(onAssetDetected).not.toHaveBeenCalled(); + expect(style.attributes.rr_css_text).toBeUndefined(); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('detects inaccessible style elements so record can report refused asset status', () => { + const el = render(`
+ +
`); + const styleEl = el.querySelector('style')!; + Object.defineProperty(styleEl, 'sheet', { + value: { + get cssRules() { + throw new DOMException('cssRules inaccessible', 'SecurityError'); + }, + }, + }); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected) as elementNode; + const style = findElement(serialized, 'style')!; + + // Snapshot's contract is detection only: it marks css_text for capture and + // preserves the original element. Refused/error status is owned by record's + // asset manager through capturedAssetStatuses. + expect(onAssetDetected).toHaveBeenCalledWith({ + element: styleEl, + attr: 'css_text', + styleId: expect.any(Number), + value: 'http://localhost:3000/', + }); + expect(onAssetDetected.mock.calls[0][0]).not.toHaveProperty('status'); + expect(style.attributes.rr_css_text).toContain('#rr_style_el:'); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('detects media source assets when enabled', () => { + const el = render(`
+ + +
`); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected, { + video: true, + audio: true, + }) as elementNode; + const video = findElement(serialized, 'video')!; + const audio = findElement(serialized, 'audio')!; + + expect(onAssetDetected).toHaveBeenCalledWith({ + element: el.querySelector('video source'), + attr: 'src', + value: 'https://example.com/show.mp4', + }); + expect(onAssetDetected).toHaveBeenCalledWith({ + element: el.querySelector('audio source'), + attr: 'src', + value: 'https://example.com/sound.mp3', + }); + expect((video.childNodes[0] as elementNode).attributes).toMatchObject({ + rr_captured_src: 'https://example.com/show.mp4', + }); + expect((audio.childNodes[0] as elementNode).attributes).toMatchObject({ + rr_captured_src: 'https://example.com/sound.mp3', + }); + }); +}); diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index f4b68245cc..fa9fe5c258 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -6,6 +6,7 @@ import { escapeImportStatement, extractFileExtension, fixSafariColons, + shouldCaptureAsset, isNodeMetaEqual, stringifyStylesheet, } from '../src/utils'; @@ -326,4 +327,97 @@ describe('utils', () => { ); }); }); + + describe('shouldCaptureAsset', () => { + it('identifies picture srcset image sources when image capture is enabled', () => { + const picture = document.createElement('picture'); + const source = document.createElement('source'); + source.srcset = 'https://example.com/img1.png'; + source.src = 'https://example.com/img2.png'; + const fallbackImg = document.createElement('img'); + fallbackImg.src = 'https://example.com/img3.png'; + + picture.append(source); + picture.append(fallbackImg); + + expect( + shouldCaptureAsset(source, 'srcset', source.srcset, { images: true }), + ).toBe(true); + expect( + shouldCaptureAsset(source, 'src', source.src, { images: true }), + ).toBe(false); + expect( + shouldCaptureAsset(fallbackImg, 'src', fallbackImg.src, { + images: true, + }), + ).toBe(true); + expect( + shouldCaptureAsset(source, 'srcset', source.srcset, { images: false }), + ).toBe(false); + }); + + it('identifies media source assets only for enabled media types', () => { + const video = document.createElement('video'); + const videoSource = document.createElement('source'); + videoSource.src = 'https://example.com/show.mp4'; + videoSource.srcset = 'https://example.com/show@2x.mp4'; + video.append(videoSource); + + const audio = document.createElement('audio'); + const audioSource = document.createElement('source'); + audioSource.src = 'https://example.com/sound.mp3'; + audio.append(audioSource); + + expect( + shouldCaptureAsset(videoSource, 'src', videoSource.src, { + video: true, + }), + ).toBe(true); + expect( + shouldCaptureAsset(videoSource, 'srcset', videoSource.srcset, { + video: true, + }), + ).toBe(false); + expect( + shouldCaptureAsset(videoSource, 'src', videoSource.src, { + video: false, + }), + ).toBe(false); + expect( + shouldCaptureAsset(audioSource, 'src', audioSource.src, { + audio: true, + }), + ).toBe(true); + }); + + it('identifies loaded stylesheet links according to stylesheet config', () => { + const element = document.createElement('link'); + element.setAttribute('rel', 'StyleSheet'); + Object.defineProperty(element, 'sheet', { + value: true, + }); + + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: false, + stylesheets: false, + }), + ).toBe(false); + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: false, + stylesheets: true, + }), + ).toBe(true); + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: ['https://example.com'], + stylesheets: 'without-fetch', + }), + ).toBe(true); + }); + }); }); From cde1d98b8fdcc1a74ab86aacf1b37cd93ccbbec1 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Mon, 1 Jun 2026 13:35:33 +0200 Subject: [PATCH 3/9] feat(record): emit captured asset events --- packages/rrweb/src/record/index.ts | 119 +- packages/rrweb/src/record/mutation.ts | 85 +- .../src/record/observers/asset-manager.ts | 407 ++++++ packages/rrweb/src/types.ts | 9 + packages/rrweb/test/html/assets/subtitles.vtt | 16 + packages/rrweb/test/record/asset.test.ts | 1154 +++++++++++++++++ 6 files changed, 1775 insertions(+), 15 deletions(-) create mode 100644 packages/rrweb/src/record/observers/asset-manager.ts create mode 100644 packages/rrweb/test/html/assets/subtitles.vtt create mode 100644 packages/rrweb/test/record/asset.test.ts diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index fac5359790..91ef900605 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -27,6 +27,14 @@ import { type scrollCallback, type canvasMutationParam, type adoptedStyleSheetParam, + type assetParam, + type asset, + type assetStatus, + type fullSnapshotEvent, + type fullSnapshotEventWithTime, + type assetEventWithTime, + type attributeMutation, + type serializedElementNodeWithId, } from '@rrweb/types'; import type { CrossOriginIframeMessageEventContent } from '../types'; import { IframeManager } from './iframe-manager'; @@ -40,11 +48,16 @@ import { unregisterErrorHandler, } from './error-handler'; import dom from '@rrweb/utils'; +import AssetManager from './observers/asset-manager'; -let wrappedEmit!: (e: eventWithoutTime, isCheckout?: boolean) => void; +let wrappedEmit!: ( + e: eventWithoutTime | eventWithTime, + isCheckout?: boolean, +) => void; let takeFullSnapshot!: (isCheckout?: boolean) => void; let canvasManager!: CanvasManager; +let assetManager!: AssetManager; let recording = false; // Multiple tools (i.e. MooTools, Prototype.js) override Array.from and drop support for the 2nd parameter @@ -95,12 +108,35 @@ function record( userTriggeredOnInput = false, collectFonts = false, inlineImages = false, + captureAssets: _captureAssets, plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), errorHandler, } = options; + const captureAssets: Exclude< + recordOptions['captureAssets'], + undefined + > = { + objectURLs: true, + origins: false, + ..._captureAssets, + }; + + if (inlineImages && captureAssets.images === undefined) { + captureAssets.images = true; + } + if (captureAssets.stylesheets === undefined) { + if (inlineStylesheet === 'all') { + captureAssets.stylesheets = true; + } else if (inlineStylesheet === true) { + captureAssets.stylesheets = 'without-fetch'; + } else if (inlineStylesheet === false) { + captureAssets.stylesheets = false; + } + } + registerErrorHandler(errorHandler); const inEmittingFrame = recordCrossOriginIframes @@ -182,9 +218,11 @@ function record( } return e as unknown as T; }; - wrappedEmit = (r: eventWithoutTime, isCheckout?: boolean) => { + wrappedEmit = (r: eventWithoutTime | eventWithTime, isCheckout?: boolean) => { const e = r as eventWithTime; - e.timestamp = nowTimestamp(); + if (!('timestamp' in e) || e.timestamp === undefined) { + e.timestamp = nowTimestamp(); + } if ( mutationBuffers[0]?.isFrozen() && e.type !== EventType.FullSnapshot && @@ -260,6 +298,16 @@ function record( }, }); + const wrappedAssetEmit = (p: assetParam, snapshotTimestamp?: number | true) => + wrappedEmit({ + type: EventType.Asset, + data: p, + timestamp: + snapshotTimestamp === true + ? assetManager.lastFullSnapshotTimestamp + : snapshotTimestamp, + } as assetEventWithTime); + const wrappedAdoptedStyleSheetEmit = (a: adoptedStyleSheetParam) => wrappedEmit({ type: EventType.IncrementalSnapshot, @@ -273,6 +321,29 @@ function record( mutationCb: wrappedMutationEmit, adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, }); + const emitCapturedStylesheetAttributes = ( + childSn: serializedElementNodeWithId, + ) => { + const capturedAttributes: attributeMutation['attributes'] = + Object.fromEntries( + Object.entries(childSn.attributes).filter(([name]) => + name.startsWith('rr_captured_'), + ), + ) as attributeMutation['attributes']; + if (Object.keys(capturedAttributes).length) { + wrappedMutationEmit({ + adds: [], + removes: [], + texts: [], + attributes: [ + { + id: childSn.id, + attributes: capturedAttributes, + }, + ], + }); + } + }; const iframeManager = new IframeManager({ mirror, @@ -308,6 +379,12 @@ function record( dataURLOptions, }); + assetManager = new AssetManager({ + mutationCb: wrappedAssetEmit, + win: window, + captureAssets, + }); + const shadowDomManager = new ShadowDomManager({ mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, @@ -323,11 +400,13 @@ function record( maskInputFn, recordCanvas, inlineImages, + captureAssets, sampling, slimDOMOptions, iframeManager, stylesheetManager, canvasManager, + assetManager, keepIframeSrcFn, processedNodeManager, }, @@ -356,13 +435,17 @@ function record( shadowDomManager.init(); mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting + const capturedAssetStatuses: assetStatus[] = []; + const fullSnapshotTimestamp = nowTimestamp(); + assetManager.lastFullSnapshotTimestamp = fullSnapshotTimestamp; + const node = snapshot(document, { mirror, blockClass, blockSelector, maskTextClass, maskTextSelector, - inlineStylesheet, + inlineStylesheet: Boolean(inlineStylesheet), maskAllInputs: maskInputOptions, maskTextFn, maskInputFn, @@ -370,6 +453,7 @@ function record( dataURLOptions, recordCanvas, inlineImages, + captureAssets, onSerialize: (n) => { if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); @@ -388,6 +472,15 @@ function record( }, onStylesheetLoad: (linkEl, childSn) => { stylesheetManager.attachLinkElement(linkEl, childSn); + emitCapturedStylesheetAttributes(childSn); + }, + onAssetDetected: (asset: asset) => { + const assetStatus = assetManager.capture(asset, true); + if (Array.isArray(assetStatus)) { + capturedAssetStatuses.push(...assetStatus); + } else { + capturedAssetStatuses.push(assetStatus); + } }, keepIframeSrcFn, }); @@ -396,14 +489,19 @@ function record( return console.warn('Failed to snapshot the document'); } + const data: fullSnapshotEvent['data'] = { + node, + initialOffset: getWindowScroll(window), + }; + if (capturedAssetStatuses.length) { + data.capturedAssetStatuses = capturedAssetStatuses; + } wrappedEmit( { type: EventType.FullSnapshot, - data: { - node, - initialOffset: getWindowScroll(window), - }, - }, + timestamp: fullSnapshotTimestamp, + data, + } as fullSnapshotEventWithTime, isCheckout, ); mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror @@ -518,6 +616,7 @@ function record( recordDOM, recordCanvas, inlineImages, + captureAssets, userTriggeredOnInput, collectFonts, doc, @@ -533,6 +632,7 @@ function record( shadowDomManager, processedNodeManager, canvasManager, + assetManager, ignoreCSSAttributes, plugins: plugins @@ -615,6 +715,7 @@ function record( } }); processedNodeManager.destroy(); + assetManager.reset(); recording = false; unregisterErrorHandler(); }; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 08e927a98f..67c9713de7 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -1,4 +1,5 @@ import { + absolutifyURLs, serializeNodeWithId, transformAttribute, IGNORED_NODE, @@ -19,6 +20,9 @@ import type { removedNodeMutation, addedNodeMutation, Optional, + asset, + attributeMutation, + serializedElementNodeWithId, } from '@rrweb/types'; import { isBlocked, @@ -31,8 +35,10 @@ import { inDom, getShadowHost, closestElementOfNode, + nowTimestamp, } from '../utils'; import dom from '@rrweb/utils'; +import { isProcessingStyleElement } from './observers/asset-manager'; type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; @@ -183,6 +189,7 @@ export default class MutationBuffer { private keepIframeSrcFn: observerParam['keepIframeSrcFn']; private recordCanvas: observerParam['recordCanvas']; private inlineImages: observerParam['inlineImages']; + private captureAssets: observerParam['captureAssets']; private slimDOMOptions: observerParam['slimDOMOptions']; private dataURLOptions: observerParam['dataURLOptions']; private doc: observerParam['doc']; @@ -192,6 +199,7 @@ export default class MutationBuffer { private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; private processedNodeManager: observerParam['processedNodeManager']; + private assetManager: observerParam['assetManager']; private unattachedDoc: HTMLDocument; public init(options: MutationBufferParam) { @@ -207,6 +215,7 @@ export default class MutationBuffer { 'maskTextFn', 'maskInputFn', 'keepIframeSrcFn', + 'captureAssets', 'recordCanvas', 'inlineImages', 'slimDOMOptions', @@ -218,6 +227,7 @@ export default class MutationBuffer { 'shadowDomManager', 'canvasManager', 'processedNodeManager', + 'assetManager', ] as const ).forEach((key) => { // just a type trick, the runtime result is correct @@ -266,6 +276,8 @@ export default class MutationBuffer { return; } + const now = nowTimestamp(); + // delay any modification of the mirror until this function // so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed @@ -327,6 +339,10 @@ export default class MutationBuffer { maskInputFn: this.maskInputFn, slimDOMOptions: this.slimDOMOptions, dataURLOptions: this.dataURLOptions, + captureAssets: { + ...this.captureAssets, + _fromMutation: true, + }, recordCanvas: this.recordCanvas, inlineImages: this.inlineImages, onSerialize: (currentN) => { @@ -349,8 +365,12 @@ export default class MutationBuffer { }, onStylesheetLoad: (link, childSn) => { this.stylesheetManager.attachLinkElement(link, childSn); + this.emitCapturedStylesheetAttributes(childSn); }, cssCaptured, + onAssetDetected: (asset: asset) => { + this.assetManager.capture(asset, now); + }, }); if (sn) { adds.push({ @@ -453,13 +473,22 @@ export default class MutationBuffer { .map((text) => { const n = text.node; const parent = dom.parentNode(n); - if (parent && (parent as Element).tagName === 'TEXTAREA') { - // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea - this.genTextAreaValueMutation(parent as HTMLTextAreaElement); + let value = text.value; + if (parent) { + const parentEl = parent as Element; + if (parentEl.tagName === 'TEXTAREA') { + // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea + this.genTextAreaValueMutation(parent as HTMLTextAreaElement); + } else if (parentEl.tagName === 'STYLE') { + if (isProcessingStyleElement(parentEl)) { + return { id: -1, value: null }; + } + value = absolutifyURLs(value, this.doc.baseURI); + } } return { id: this.mirror.getId(n), - value: text.value, + value, }; }) // no need to include them on added elements, as they have just been serialized with up to date attribubtes @@ -547,6 +576,30 @@ export default class MutationBuffer { }); }; + private emitCapturedStylesheetAttributes = ( + childSn: serializedElementNodeWithId, + ) => { + const capturedAttributes: attributeMutation['attributes'] = + Object.fromEntries( + Object.entries(childSn.attributes).filter(([name]) => + name.startsWith('rr_captured_'), + ), + ) as attributeMutation['attributes']; + if (Object.keys(capturedAttributes).length) { + this.mutationCb({ + adds: [], + removes: [], + texts: [], + attributes: [ + { + id: childSn.id, + attributes: capturedAttributes, + }, + ], + }); + } + }; + private processMutation = (m: mutationRecord) => { if (isIgnored(m.target, this.mirror, this.slimDOMOptions)) { return; @@ -636,13 +689,30 @@ export default class MutationBuffer { } if (!ignoreAttribute(target.tagName, attributeName, value)) { - // overwrite attribute if the mutations was triggered in same time - item.attributes[attributeName] = transformAttribute( + let transformedValue = transformAttribute( this.doc, toLowerCase(target.tagName), toLowerCase(attributeName), value, ); + if ( + transformedValue && + this.assetManager.shouldCapture( + target, + attributeName, + transformedValue, + this.captureAssets, + ) + ) { + this.assetManager.capture({ + element: target, + attr: attributeName, + value: transformedValue, + }); + attributeName = `rr_captured_${attributeName}`; + } + // overwrite attribute if the mutations was triggered in same time + item.attributes[attributeName] = transformedValue; if (attributeName === 'style') { if (!this.unattachedDoc) { try { @@ -703,6 +773,9 @@ export default class MutationBuffer { this.genTextAreaValueMutation(m.target as HTMLTextAreaElement); return; // any removedNodes won't have been in mirror either } + if (isProcessingStyleElement(m.target as HTMLElement)) { + return; + } m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts new file mode 100644 index 0000000000..8cf1d7f231 --- /dev/null +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -0,0 +1,407 @@ +import type { + IWindow, + SerializedCanvasArg, + SerializedCssTextArg, + asset, + assetCallback, + assetStatus, + captureAssetsParam, + eventWithTime, + listenerHandler, +} from '@rrweb/types'; +import { encode } from 'base64-arraybuffer'; +import { patch } from '@rrweb/utils'; +import { + absolutifyURLs, + getSourcesFromSrcset, + shouldCaptureAsset, + splitCssText, + stringifyCssRules, +} from 'rrweb-snapshot'; + +import type { ProcessingStyleElement, recordOptions } from '../../types'; + +export function isProcessingStyleElement( + el: Element, +): el is ProcessingStyleElement { + return '__rrProcessingStylesheet' in el; +} + +export default class AssetManager { + private urlObjectMap = new Map(); + private urlTextMap = new Map(); + private capturedURLs = new Set(); + private capturingURLs = new Set(); + private failedURLs = new Set(); + private resetHandlers: listenerHandler[] = []; + private mutationCb: assetCallback; + public readonly config: Exclude< + recordOptions['captureAssets'], + undefined + >; + + public lastFullSnapshotTimestamp = 0; + + public reset() { + this.urlObjectMap.clear(); + this.urlTextMap.clear(); + this.capturedURLs.clear(); + this.capturingURLs.clear(); + this.failedURLs.clear(); + this.resetHandlers.forEach((h) => h()); + this.resetHandlers = []; + } + + constructor(options: { + mutationCb: assetCallback; + win: IWindow; + captureAssets: Exclude< + recordOptions['captureAssets'], + undefined + >; + }) { + const { win } = options; + + this.mutationCb = options.mutationCb; + this.config = options.captureAssets; + + const urlObjectMap = this.urlObjectMap; + + if (this.config.objectURLs || this.config.images) { + try { + const restoreHandler = patch( + win.URL, + 'createObjectURL', + function (original: (obj: File | Blob | MediaSource) => string) { + return function (obj: File | Blob | MediaSource) { + const url = original.apply(this, [obj]); + urlObjectMap.set(url, obj); + return url; + }; + }, + ); + this.resetHandlers.push(restoreHandler); + } catch { + console.error('failed to patch URL.createObjectURL'); + } + + try { + const restoreHandler = patch( + win.URL, + 'revokeObjectURL', + function (original: (objectURL: string) => void) { + return function (objectURL: string) { + urlObjectMap.delete(objectURL); + return original.apply(this, [objectURL]); + }; + }, + ); + this.resetHandlers.push(restoreHandler); + } catch { + console.error('failed to patch URL.revokeObjectURL'); + } + } + } + + public async getURLObject( + url: string, + ): Promise { + const object = this.urlObjectMap.get(url); + if (object) { + return object; + } + const text = this.urlTextMap.get(url); + if (text) { + return text; + } + + try { + const response = await fetch(url); + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('text/css')) { + return await response.text(); + } + return await response.blob(); + } catch (e) { + console.warn(`getURLObject failed for ${url}`); + throw e; + } + } + + private captureStylesheet( + sheetBaseHref: string, + el: HTMLLinkElement | HTMLStyleElement, + styleId?: number, + snapshotTimestamp?: number | true, + ): assetStatus { + let cssRules: CSSRuleList; + let url = sheetBaseHref; + if (styleId) { + url += `#rr_style_el:${styleId}`; + } else if (el.getAttribute('media') !== null) { + const linkAppliedQuery = matchMedia(el.getAttribute('media') as string); + if (!linkAppliedQuery.matches) { + try { + try { + linkAppliedQuery.addEventListener('change', () => + this.captureStylesheet(sheetBaseHref, el, styleId), + ); + } catch { + linkAppliedQuery.addListener(() => + this.captureStylesheet(sheetBaseHref, el, styleId), + ); + } + return { + url, + status: 'media-mismatch', + }; + } catch { + // Cannot listen for media changes, so capture now. + } + } + } + const eventTimestamp = this.getEventTimestamp(snapshotTimestamp); + + try { + cssRules = el.sheet!.cssRules; + } catch (e) { + if (el.tagName === 'STYLE') { + return { + url, + status: 'refused', + }; + } + if (this.capturedURLs.has(url)) { + return { + url, + status: 'captured', + }; + } + if (this.capturingURLs.has(url)) { + return { + url, + status: 'capturing', + }; + } + if (this.failedURLs.has(url)) { + return { + url, + status: 'error', + }; + } + this.capturingURLs.add(url); + void this.getURLObject(url) + .then((cssText) => { + this.capturedURLs.add(url); + this.capturingURLs.delete(url); + + if (cssText && typeof cssText === 'string') { + const payload: SerializedCssTextArg = { + rr_type: 'CssText', + cssTexts: [absolutifyURLs(cssText, sheetBaseHref)], + }; + this.mutationCb( + { + url, + payload, + }, + eventTimestamp, + ); + } + }) + .catch(this.fetchCatcher(url, eventTimestamp)); + return { + url, + status: 'capturing', + }; + } + + const processStylesheet = () => { + cssRules = el.sheet!.cssRules; + const cssText = stringifyCssRules(cssRules, sheetBaseHref); + const payload: SerializedCssTextArg = { + rr_type: 'CssText', + cssTexts: [cssText], + }; + if (styleId) { + if (el.childNodes.length > 1) { + payload.cssTexts = splitCssText(cssText, el as HTMLStyleElement); + } + this.mutationCb( + { + url, + payload, + }, + eventTimestamp, + ); + } else { + this.mutationCb( + { + url: sheetBaseHref, + payload, + }, + eventTimestamp, + ); + } + if (isProcessingStyleElement(el)) { + delete el.__rrProcessingStylesheet; + } + }; + + let timeout = this.config.processStylesheetsWithin; + if (!timeout && timeout !== 0) { + timeout = 2000; + } + if (timeout <= 0) { + processStylesheet(); + return { + url, + status: 'captured', + }; + } + if (window.requestIdleCallback !== undefined) { + if (el.tagName === 'STYLE') { + (el as ProcessingStyleElement).__rrProcessingStylesheet = true; + timeout = Math.floor(timeout / 2); + } + requestIdleCallback(processStylesheet, { + timeout, + }); + return { + url, + status: 'capturing', + timeout, + }; + } + + setTimeout(processStylesheet, 0); + return { + url, + status: 'capturing', + timeout: 100, + }; + } + + public capture( + asset: asset, + snapshotTimestamp?: number | true, + ): assetStatus | assetStatus[] { + if ('sheet' in asset.element) { + return this.captureStylesheet( + asset.value, + asset.element as HTMLStyleElement | HTMLLinkElement, + asset.styleId, + snapshotTimestamp, + ); + } + if (asset.attr === 'srcset') { + const statuses: assetStatus[] = []; + getSourcesFromSrcset(asset.value).forEach((url) => { + statuses.push(this.captureUrl(url, snapshotTimestamp)); + }); + return statuses; + } + return this.captureUrl(asset.value, snapshotTimestamp); + } + + private captureUrl( + url: string, + snapshotTimestamp?: number | true, + ): assetStatus { + const eventTimestamp = this.getEventTimestamp(snapshotTimestamp); + if (this.capturedURLs.has(url)) { + return { + url, + status: 'captured', + }; + } + if (this.capturingURLs.has(url)) { + return { + url, + status: 'capturing', + }; + } + if (this.failedURLs.has(url)) { + return { + url, + status: 'error', + }; + } + this.capturingURLs.add(url); + void this.getURLObject(url) + .then(async (object) => { + if (object && (object instanceof File || object instanceof Blob)) { + const arrayBuffer = await object.arrayBuffer(); + const base64 = encode(arrayBuffer); + + const payload: SerializedCanvasArg = { + rr_type: 'Blob', + type: object.type, + data: [ + { + rr_type: 'ArrayBuffer', + base64, + }, + ], + }; + + this.capturedURLs.add(url); + this.capturingURLs.delete(url); + + this.mutationCb( + { + url, + payload, + }, + eventTimestamp, + ); + } + }) + .catch(this.fetchCatcher(url, eventTimestamp)); + + return { + url, + status: 'capturing', + }; + } + + private getEventTimestamp(snapshotTimestamp?: number | true) { + return snapshotTimestamp === true + ? this.lastFullSnapshotTimestamp + : snapshotTimestamp; + } + + private fetchCatcher(url: string, snapshotTimestamp?: number) { + return (e: unknown) => { + let message = ''; + if (e instanceof Error) { + message = e.message; + } else if (typeof e === 'string') { + message = e; + } else if (e && typeof e === 'object' && 'toString' in e) { + message = (e as { toString(): string }).toString(); + } + this.mutationCb( + { + url, + failed: { + message, + }, + }, + snapshotTimestamp, + ); + + this.failedURLs.add(url); + this.capturingURLs.delete(url); + }; + } + + public shouldCapture( + n: Element, + attribute: string, + value: string, + config: captureAssetsParam, + ): boolean { + return shouldCaptureAsset(n, attribute, value, config); + } +} diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 87e1d7ca2a..def1902b68 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -10,6 +10,7 @@ import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; import type { RRNode } from 'rrdom'; import type { CanvasManager } from './record/observers/canvas/canvas-manager'; +import type AssetManager from './record/observers/asset-manager'; import type { StylesheetManager } from './record/stylesheet-manager'; import type { DataURLOptions, @@ -115,6 +116,7 @@ export type observerParam = { fontCb: fontCallback; sampling: SamplingStrategy; recordDOM: boolean; + captureAssets: captureAssetsParam; recordCanvas: boolean; inlineImages: boolean; userTriggeredOnInput: boolean; @@ -128,6 +130,7 @@ export type observerParam = { shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; processedNodeManager: ProcessedNodeManager; + assetManager: AssetManager; ignoreCSSAttributes: Set; plugins: Array<{ observer: ( @@ -152,6 +155,7 @@ export type MutationBufferParam = Pick< | 'maskTextFn' | 'maskInputFn' | 'keepIframeSrcFn' + | 'captureAssets' | 'recordCanvas' | 'inlineImages' | 'slimDOMOptions' @@ -163,6 +167,7 @@ export type MutationBufferParam = Pick< | 'shadowDomManager' | 'canvasManager' | 'processedNodeManager' + | 'assetManager' >; export type ReplayPlugin = { @@ -236,3 +241,7 @@ export type CrossOriginIframeMessageEvent = MessageEvent; export type ErrorHandler = (error: unknown) => void | boolean; + +export interface ProcessingStyleElement extends HTMLStyleElement { + __rrProcessingStylesheet?: true; +} diff --git a/packages/rrweb/test/html/assets/subtitles.vtt b/packages/rrweb/test/html/assets/subtitles.vtt new file mode 100644 index 0000000000..c56d8d687d --- /dev/null +++ b/packages/rrweb/test/html/assets/subtitles.vtt @@ -0,0 +1,16 @@ +WEBVTT + +00:00:00.000 --> 00:00:00.999 line:80% +Hildy! + +00:00:01.000 --> 00:00:01.499 line:80% +How are you? + +00:00:01.500 --> 00:00:02.999 line:80% +Tell me, is the lord of the universe in? + +00:00:03.000 --> 00:00:04.299 line:80% +Yes, he's in - in a bad humor + +00:00:04.300 --> 00:00:06.000 line:80% +Somebody must've stolen the crown jewels diff --git a/packages/rrweb/test/record/asset.test.ts b/packages/rrweb/test/record/asset.test.ts new file mode 100644 index 0000000000..03e8e372a8 --- /dev/null +++ b/packages/rrweb/test/record/asset.test.ts @@ -0,0 +1,1154 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as puppeteer from 'puppeteer'; +import type { recordOptions } from '../../src/types'; +import type { listenerHandler, eventWithTime, assetEvent } from '@rrweb/types'; +import { EventType, IncrementalSource } from '@rrweb/types'; +import { + getServerURL, + launchPuppeteer, + startServer, + waitForRAF, + stripBase64, +} from '../utils'; +import type * as http from 'http'; +import { vi } from 'vitest'; + +interface ISuite { + code: string; + browser: puppeteer.Browser; + page: puppeteer.Page; + events: eventWithTime[]; + server: http.Server; + serverURL: string; + serverB: http.Server; + serverBURL: string; +} + +interface IWindow extends Window { + rrweb: { + record: ( + options: recordOptions, + ) => listenerHandler | undefined; + addCustomEvent(tag: string, payload: T): void; + pack: (e: eventWithTime) => string; + }; + emit: (e: eventWithTime) => undefined; + snapshots: eventWithTime[]; +} +type ExtraOptions = { + captureAssets?: recordOptions['captureAssets']; +}; + +const BASE64_PNG_RECTANGLE = + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAWtJREFUeF7t1cEJAEAIxEDtv2gProo8xgpCwuLezI3LGFhBMi0+iCCtHoLEeggiSM1AjMcPESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4ViIIDEDMRwLESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4TwVjsedWCiXGAAAAABJRU5ErkJggg=='; + +async function injectRecordScript( + frame: puppeteer.Frame, + options?: ExtraOptions, +) { + await frame.addScriptTag({ + path: path.resolve(__dirname, '../../dist/rrweb.umd.cjs'), + }); + options = options || {}; + await frame.evaluate((options) => { + (window as unknown as IWindow).snapshots = []; + const { record, pack } = (window as unknown as IWindow).rrweb; + const config: recordOptions = { + captureAssets: options.captureAssets, + emit(event) { + (window as unknown as IWindow).snapshots.push(event); + (window as unknown as IWindow).emit(event); + }, + }; + record(config); + }, options); + + for (const child of frame.childFrames()) { + await injectRecordScript(child, options); + } +} + +const setup = function ( + this: ISuite, + content: string, + options?: ExtraOptions, +): ISuite { + const ctx = {} as ISuite; + beforeAll(async () => { + ctx.browser = await launchPuppeteer(); + ctx.server = await startServer(); + ctx.serverURL = getServerURL(ctx.server); + ctx.serverB = await startServer(); + ctx.serverBURL = getServerURL(ctx.serverB); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs'); + ctx.code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + ctx.page = await ctx.browser.newPage(); + await ctx.page.goto(`${ctx.serverURL}/html/blank.html`); + await ctx.page.setContent( + content + .replace(/\{SERVER_URL\}/g, ctx.serverURL) + .replace(/\{SERVER_B_URL\}/g, ctx.serverBURL), + ); + // await ctx.page.evaluate(ctx.code); + await waitForRAF(ctx.page); + ctx.events = []; + await ctx.page.exposeFunction('emit', (e: eventWithTime) => { + if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { + return; + } + ctx.events.push(e); + }); + + ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + if ( + options?.captureAssets?.origins && + Array.isArray(options.captureAssets.origins) + ) { + options.captureAssets.origins = options.captureAssets.origins.map( + (origin) => origin.replace(/\{SERVER_URL\}/g, ctx.serverURL), + ); + } + await injectRecordScript(ctx.page.mainFrame(), options); + }); + + afterEach(async () => { + await ctx.page.close(); + }); + + afterAll(async () => { + await ctx.browser.close(); + ctx.server.close(); + ctx.serverB.close(); + }); + + return ctx; +}; + +describe('asset capturing', function (this: ISuite) { + vi.setConfig({ testTimeout: 100_000 }); + + describe('objectURLs: true with incremental snapshots', function (this: ISuite) { + const ctx: ISuite = setup.call( + this, + ` + + + + + `, + { + captureAssets: { + objectURLs: true, + origins: false, + }, + }, + ); + + it('will emit asset when included as img attribute mutation', async () => { + const url = (await ctx.page.evaluate(() => { + return new Promise((resolve) => { + // create a blob of an image, then create an object URL for the blob + // and append it to the DOM as `src` attribute of an existing image + const img = document.createElement('img'); + document.body.appendChild(img); + + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 100; + const context = canvas.getContext('2d')!; + context.fillStyle = 'red'; + context.fillRect(0, 0, 100, 100); + + canvas.toBlob((blob) => { + if (!blob) return; + + const url = URL.createObjectURL(blob); + img.src = url; + resolve(url); + }); + }); + })) as string; + await waitForRAF(ctx.page); + // await ctx.page.waitForTimeout(40_000); + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const expected: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: { + rr_type: 'Blob', + data: [ + { + rr_type: 'ArrayBuffer', + base64: expect.any(String), + }, + ], + }, + }, + }; + expect(events[events.length - 1]).toMatchObject(expected); + }); + + it('will emit asset when included with new img', async () => { + const url = (await ctx.page.evaluate(() => { + return new Promise((resolve) => { + // create a blob of an image, then create an object URL for the blob and append it to the DOM as image `src` attribute + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 100; + const context = canvas.getContext('2d')!; + context.fillStyle = 'red'; + context.fillRect(0, 0, 100, 100); + + canvas.toBlob((blob) => { + if (!blob) return; + + const url = URL.createObjectURL(blob); + const img = document.createElement('img'); + img.src = url; + document.body.appendChild(img); + resolve(url); + }); + }); + })) as string; + await waitForRAF(ctx.page); + // await ctx.page.waitForTimeout(40_000); + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const expected: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: { + rr_type: 'Blob', + data: [ + { + rr_type: 'ArrayBuffer', + base64: expect.any(String), + }, + ], + }, + }, + }; + expect(events[events.length - 1]).toMatchObject(expected); + }); + }); + + describe('objectURLs: true with fullSnapshot', function (this: ISuite) { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + captureAssets: { + objectURLs: true, + origins: false, + }, + }, + ); + + it('will emit asset when included with existing img', async () => { + await waitForRAF(ctx.page); + const url = (await ctx.page.evaluate(() => { + return document.querySelector('img')?.src; + })) as string; + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const expected: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: { + rr_type: 'Blob', + data: [ + { + rr_type: 'ArrayBuffer', + base64: BASE64_PNG_RECTANGLE, // base64 + }, + ], + }, + }, + }; + expect(events[events.length - 1]).toMatchObject(expected); + }); + }); + describe('captureAssets partial defaults', function (this: ISuite) { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + captureAssets: { + stylesheets: true, + }, + }, + ); + + it('keeps default objectURLs true when only stylesheets is configured', async () => { + await waitForRAF(ctx.page); + const url = (await ctx.page.evaluate(() => { + return document.querySelector('img')?.src; + })) as string; + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url, + payload: expect.objectContaining({ + rr_type: 'Blob', + data: [ + { + rr_type: 'ArrayBuffer', + base64: BASE64_PNG_RECTANGLE, + }, + ], + }), + }, + }), + ); + }); + }); + describe('objectURLs: false', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + `, + { + captureAssets: { + objectURLs: false, + origins: false, + }, + }, + ); + it("shouldn't capture ObjectURLs when its turned off in config", async () => { + const url = (await ctx.page.evaluate(() => { + return new Promise((resolve) => { + // create a blob of an image, then create an object URL for the blob and append it to the DOM as image `src` attribute + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 100; + const context = canvas.getContext('2d')!; + context.fillStyle = 'red'; + context.fillRect(0, 0, 100, 100); + + canvas.toBlob((blob) => { + if (!blob) return; + + const url = URL.createObjectURL(blob); + const img = document.createElement('img'); + img.src = url; + document.body.appendChild(img); + resolve(url); + }); + }); + })) as string; + await waitForRAF(ctx.page); + // await ctx.page.waitForTimeout(40_000); + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('data urls', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + ); + + it("shouldn't re-capture data:urls", async () => { + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect no event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('origins: false', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + captureAssets: { + origins: false, + objectURLs: false, + }, + }, + ); + + it("shouldn't capture any urls", async () => { + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect no event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('origins: []', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + captureAssets: { + origins: [], + objectURLs: false, + }, + }, + ); + + it("shouldn't capture any urls", async () => { + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect no event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('origins: true', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + captureAssets: { + origins: true, + objectURLs: false, + }, + }, + ); + + it('capture all urls', async () => { + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect an event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + + describe('origins: true with invalid urls', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + + `, + { + captureAssets: { + origins: true, + objectURLs: false, + }, + }, + ); + + it('capture invalid url', async () => { + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect an event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `failprotocol://example.com/image.png`, + failed: { + message: 'Failed to fetch', + }, + }, + }), + ); + }); + + it('capture url failed due to CORS', async () => { + // Puppeteer has issues with failed requests below 19.8.0 (more info: https://github.com/puppeteer/puppeteer/pull/9883) + // TODO: re-enable next line after upgrading to puppeteer 19.8.0 + // await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + + // TODO: remove next line after upgrading to puppeteer 19.8.0 + await ctx.page.waitForTimeout(500); + + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect an event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `https://example.com/image.png`, + failed: { + message: 'Failed to fetch', + }, + }, + }), + ); + }); + }); + + describe('origins: ["http://localhost:xxxxx/"]', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
foobar
+ + + `, + { + captureAssets: { + origins: ['{SERVER_URL}'], + objectURLs: false, + }, + }, + ); + + [ + '{SERVER_URL}/html/assets/robot.png?body', + '{SERVER_URL}/html/assets/robot.png?img', + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?audio', + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?video', + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?source', + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?embed', + '{SERVER_URL}/html/assets/subtitles.vtt', + '{SERVER_URL}/html/assets/robot.png?1x', + '{SERVER_URL}/html/assets/robot.png?2x', + '{SERVER_URL}/html/assets/robot.png?input-type-image', + '{SERVER_URL}/html/assets/robot.png?iframe', + //'{SERVER_URL}/html/assets/doc.pdf?iframe', + '{SERVER_URL}/html/assets/robot.png?svg', + '{SERVER_URL}/html/assets/robot.png?svg2', + '{SERVER_URL}/html/assets/robot.png?svg3', + '{SERVER_URL}/html/assets/robot.png?table', + '{SERVER_URL}/html/assets/robot.png?td', + ].forEach((u) => { + it(`should capture ${u} with origin defined in config`, async () => { + const url = u.replace(/\{SERVER_URL\}/g, ctx.serverURL); + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect an event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url, + payload: expect.any(Object), + }, + }), + ); + }); + }); + + it("shouldn't capture assets within a blocked section", async () => { + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: expect.stringContaining('should-be-blocked'), + payload: expect.any(Object), + }, + }), + ); + }); + + it("shouldn't capture assets with origin not defined in config", async () => { + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `${ctx.serverBURL}/html/assets/robot.png?img`, + payload: expect.any(Object), + }, + }), + ); + }); + + it("shouldn't capture iframe src assets if srcdoc overrides", async () => { + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect an event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `${ctx.serverBURL}/html/assets/robot.png?should-ignore`, + payload: expect.any(Object), + }, + }), + ); + }); + }); + + describe('origins: ["http://localhost:xxxxx/"] with audio and video explicitly off', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + + + + `, + { + captureAssets: { + origins: ['{SERVER_URL}'], + objectURLs: false, + video: false, + audio: false, + }, + }, + ); + + it(`should capture robot.png with origin defined in config (and it's not video/audio)`, async () => { + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = stripBase64( + await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ), + ); + + const url = '{SERVER_URL}/html/assets/robot.png?img'.replace( + /\{SERVER_URL\}/g, + ctx.serverURL, + ); + + // make sure we are capturing other assets + expect(events).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url, + payload: expect.any(Object), + }, + }), + ); + + [ + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?audio', + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?video', + '{SERVER_URL}/html/assets/1-minute-of-silence.mp3?source', + '{SERVER_URL}/html/assets/subtitles.vtt', + ].forEach((u) => { + const url = u.replace(/\{SERVER_URL\}/g, ctx.serverURL); + // expect an event to be emitted with `event.type` === EventType.Asset + expect(events).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url, + payload: expect.any(Object), + }, + }), + ); + }); + }); + }); + + describe('jsdelivr with CORS restrictions', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + +`, + { + captureAssets: { + origins: ['https://cdn.jsdelivr.net'], + objectURLs: false, + }, + }, + ); + + it('should capture 3rd party CORS stylesheets as assets if origin matches', async () => { + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + // expect an event to be emitted with `event.type` === EventType.Asset + expect(stripBase64(events)).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css`, + payload: { + rr_type: 'CssText', + cssTexts: [expect.stringContaining('body')], + }, + }, + }), + ); + }); + + it('will emit asset after a 3rd party CORS stylesheet insertion mutation', async () => { + // don't wait at all here, we want to get the mutations in before network events + await ctx.page?.evaluate(() => { + const static_link = document.querySelector('link[rel="stylesheet"]'); + if (static_link) { + static_link.remove(); + } + const link = document.createElement('link'); + document.body.appendChild(link); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute( + 'href', + 'https://cdn.jsdelivr.net/npm/pure@2.67.0/index.css', // different version to that in the static HTML); + ); + }); + await waitForRAF(ctx.page); + await ctx.page.waitForNetworkIdle({ idleTime: 300 }); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + + const mutationEvents = events.filter( + (e) => + e.type === EventType.IncrementalSnapshot && + e.data.source === IncrementalSource.Mutation, + ); + expect(mutationEvents[0]).toMatchObject({ + data: { + adds: [ + { + node: { + attributes: { + href: expect.stringContaining('2.67.0'), // not rr_captured_href + }, + }, + }, + ], + }, + }); + + expect(mutationEvents[1]).toMatchObject({ + data: { + attributes: [ + { + attributes: { + rr_captured_href: expect.stringContaining('2.67.0'), // this signals that the stylesheet has has loaded + }, + }, + ], + }, + }); + + const assetEvents = events.filter((e) => e.type === EventType.Asset); + expect(assetEvents.length).toEqual(2); // both should be present as both were present on the page (albeit momentarily) + const expected: assetEvent[] = [ + { + type: EventType.Asset, + data: { + url: expect.stringContaining('2.85.0'), + payload: { + rr_type: 'CssText', + cssTexts: [expect.stringContaining('body')], + }, + }, + }, + { + type: EventType.Asset, + data: { + url: expect.stringContaining('2.67.0'), + payload: { + rr_type: 'CssText', + cssTexts: [expect.stringContaining('body')], + }, + }, + }, + ]; + // to fix: assets can be emitted in either order + expect(assetEvents).toMatchObject(expected); + }); + }); + + describe('stylesheets=true', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + +`, + { + captureAssets: { + origins: [], + objectURLs: false, + stylesheets: true, + }, + }, + ); + + it('will not emit asset after a style element insertion mutation, but rather include the cssText directly in the mutation', async () => { + // we include directly as the mutation is already off the main thread + await ctx.page.waitForNetworkIdle({ idleTime: 100 }); + await waitForRAF(ctx.page); + + await ctx.page?.evaluate(() => { + const styleEl = document.createElement('style'); + styleEl.append(document.createTextNode('.inlineme { color: red; }')); + document.body.appendChild(styleEl); + }); + await waitForRAF(ctx.page); + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const anyAssetEvents = events.filter((e) => e.type === EventType.Asset); + expect(anyAssetEvents).toMatchObject([]); + const mutationEvents = events.filter( + (e) => e.type === EventType.IncrementalSnapshot, + ); + expect(mutationEvents).toMatchObject([ + { + data: { + adds: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + attributes: { + _cssText: '.inlineme { color: red; }', + }, + }), + }), + ]), + }, + }, + ]); + }); + }); + + describe('full snapshot stylesheet asset timestamps', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + +`, + { + captureAssets: { + origins: false, + objectURLs: false, + stylesheets: true, + processStylesheetsWithin: 0, + }, + }, + ); + + it('uses the full snapshot timestamp for synchronous stylesheet assets', async () => { + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const fullSnapshotEvent = events.find( + (e) => e.type === EventType.FullSnapshot, + ); + const assetEvent = events.find( + (e) => + e.type === EventType.Asset && + e.data.payload?.rr_type === 'CssText' && + e.data.payload.cssTexts.some((cssText) => + cssText.includes('.sync-capture'), + ), + ); + + expect(fullSnapshotEvent).toBeDefined(); + expect(assetEvent).toMatchObject({ + timestamp: fullSnapshotEvent?.timestamp, + }); + expect(assetEvent?.timestamp).not.toBe(0); + }); + }); + + describe('full snapshot asset failure timestamps', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + + + +`, + { + captureAssets: { + origins: true, + objectURLs: false, + }, + }, + ); + + it('uses the full snapshot timestamp for full snapshot asset failures', async () => { + await ctx.page.waitForTimeout(500); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const url = 'failprotocol://example.com/full-snapshot-image.png'; + const fullSnapshotEvent = events.find( + (e) => e.type === EventType.FullSnapshot, + ); + const failureEvent = events.find( + (e) => e.type === EventType.Asset && e.data.url === url, + ); + + expect(fullSnapshotEvent).toMatchObject({ + data: { + capturedAssetStatuses: expect.arrayContaining([ + { + url, + status: 'capturing', + }, + ]), + }, + }); + expect(failureEvent).toMatchObject({ + timestamp: fullSnapshotEvent?.timestamp, + data: { + url, + failed: { + message: 'Failed to fetch', + }, + }, + }); + }); + }); +}); From 25ab05265f7269fee98b1815ba29e14807f2144b Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Mon, 1 Jun 2026 14:02:27 +0200 Subject: [PATCH 4/9] feat(replay): apply captured asset events --- .../rrweb/src/replay/asset-manager/index.ts | 357 ++++++++++++++ .../src/replay/asset-manager/update-srcset.ts | 26 + packages/rrweb/src/replay/index.ts | 132 +++++ packages/rrweb/src/replay/machine.ts | 97 +++- .../asset-integration-test-ts-loading.png | Bin 0 -> 10923 bytes ...oesnt-display-broken-image-icon-2-snap.png | Bin 0 -> 10784 bytes ...ncorporate-assets-emitted-later-1-snap.png | Bin 0 -> 10802 bytes ...corporate-assets-streamed-later-1-snap.png | Bin 0 -> 10784 bytes ...e-mode-when-asset-fails-to-load-1-snap.png | Bin 0 -> 11662 bytes ...de-when-asset-never-gets-loaded-1-snap.png | Bin 0 -> 11662 bytes ...ile-src-is-changed-in-live-mode-1-snap.png | Bin 0 -> 28392 bytes ...set-red-square-in-non-live-mode-1-snap.png | Bin 0 -> 10784 bytes ...ed-asset-robot-in-non-live-mode-1-snap.png | Bin 0 -> 28392 bytes ...dified-via-incremental-mutation-1-snap.png | Bin 0 -> 10784 bytes ...stylesheet-assets-to-avoid-fouc-1-snap.png | Bin 0 -> 10747 bytes ...ute-until-the-asset-is-loaded-2-1-snap.png | Bin 0 -> 10831 bytes ...-style-elements-within-the-body-1-snap.png | Bin 0 -> 11595 bytes .../test/replay/asset-integration.test.ts | 348 ++++++++++++++ packages/rrweb/test/replay/asset-unit.test.ts | 454 ++++++++++++++++++ .../fixtures/assets-body-inline-style.ts | 117 +++++ .../test/replay/fixtures/assets-mutation.ts | 141 ++++++ .../assets-src-changed-before-asset-loaded.ts | 164 +++++++ packages/rrweb/test/replay/fixtures/assets.ts | 183 +++++++ 23 files changed, 2006 insertions(+), 13 deletions(-) create mode 100644 packages/rrweb/src/replay/asset-manager/index.ts create mode 100644 packages/rrweb/src/replay/asset-manager/update-srcset.ts create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-loading.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-on-mutation-should-add-bogus-src-attribute-until-the-asset-is-loaded-so-chrome-doesnt-display-broken-image-icon-2-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-streamed-later-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-fails-to-load-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-never-gets-loaded-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-correct-asset-when-assets-are-loading-while-src-is-changed-in-live-mode-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-red-square-in-non-live-mode-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-robot-in-non-live-mode-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-support-urls-src-modified-via-incremental-mutation-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-with-adding-src-attribute-until-the-asset-is-loaded-2-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-should-correctly-rebuild-style-elements-within-the-body-1-snap.png create mode 100644 packages/rrweb/test/replay/asset-integration.test.ts create mode 100644 packages/rrweb/test/replay/asset-unit.test.ts create mode 100644 packages/rrweb/test/replay/fixtures/assets-body-inline-style.ts create mode 100644 packages/rrweb/test/replay/fixtures/assets-mutation.ts create mode 100644 packages/rrweb/test/replay/fixtures/assets-src-changed-before-asset-loaded.ts create mode 100644 packages/rrweb/test/replay/fixtures/assets.ts diff --git a/packages/rrweb/src/replay/asset-manager/index.ts b/packages/rrweb/src/replay/asset-manager/index.ts new file mode 100644 index 0000000000..41df77e59c --- /dev/null +++ b/packages/rrweb/src/replay/asset-manager/index.ts @@ -0,0 +1,357 @@ +import type { + RebuildAssetManagerFinalStatus, + RebuildAssetManagerInterface, + RebuildAssetManagerStatus, + assetEvent, + SerializedCssTextArg, + SerializedCanvasArg, + serializedElementNodeWithId, +} from '@rrweb/types'; +import { deserializeArg } from '../canvas/deserialize-args'; +import { + getSourcesFromSrcset, + adaptCssForReplay, + type BuildCache, +} from 'rrweb-snapshot'; +import type { RRElement } from 'rrdom'; +import { updateSrcset } from './update-srcset'; + +function buildStyleNode( + _n: serializedElementNodeWithId | HTMLStyleElement, + styleEl: HTMLStyleElement, + cssText: string, + options: { + hackCss: boolean; + cache: BuildCache; + }, +) { + const { hackCss, cache } = options; + while (styleEl.firstChild) { + styleEl.removeChild(styleEl.firstChild); + } + if (hackCss) { + cssText = adaptCssForReplay(cssText, cache); + } + styleEl.appendChild(styleEl.ownerDocument.createTextNode(cssText)); +} + +export default class AssetManager implements RebuildAssetManagerInterface { + private originalToObjectURLMap: Map> = new Map(); + private urlToStylesheetMap: Map> = new Map(); + private nodeIdAttributeHijackedMap: Map> = + new Map(); + private loadingURLs: Set = new Set(); + private failedURLs: Set = new Set(); + private callbackMap: Map< + string, + Array<(status: RebuildAssetManagerFinalStatus) => void> + > = new Map(); + private liveMode: boolean; + private cache: BuildCache; + public expectedAssets: Set | null = null; + public replayerApproxTs = 0; + + constructor({ liveMode, cache }: { liveMode: boolean; cache: BuildCache }) { + this.liveMode = liveMode; + this.cache = cache; + } + + public async add(event: assetEvent & { timestamp: number }) { + const { data } = event; + const { url, payload, failed } = { payload: false, failed: false, ...data }; + if (failed) { + this.failedURLs.add(url); + this.executeCallbacks(url, { status: 'failed' }); + return; + } + if (this.loadingURLs.has(url)) { + return; + } + this.loadingURLs.add(url); + if (this.expectedAssets !== null) { + this.expectedAssets.delete(url); + } + + // tracks if deserializing did anything, not really needed for AssetManager + const status = { + isUnchanged: true, + }; + + if (payload.rr_type === 'CssText') { + const cssPayload = payload as SerializedCssTextArg; + let assets = this.urlToStylesheetMap.get(url); + if (!assets) { + assets = new Map(); + this.urlToStylesheetMap.set(url, assets); + } + assets.set(event.timestamp, cssPayload.cssTexts); + this.loadingURLs.delete(url); + this.failedURLs.delete(url); + this.executeCallbacks(url, { + status: 'loaded', + url, + cssTexts: cssPayload.cssTexts, + }); + } else { + // TODO: extract the logic only needed for assets from deserializeArg + const result = (await deserializeArg( + new Map(), + null, + status, + )(payload as SerializedCanvasArg)) as Blob | MediaSource; + const objectURL = URL.createObjectURL(result); + let assets = this.originalToObjectURLMap.get(url); + if (!assets) { + assets = new Map(); + this.originalToObjectURLMap.set(url, assets); + } + assets.set(event.timestamp, objectURL); + this.loadingURLs.delete(url); + this.failedURLs.delete(url); + this.executeCallbacks(url, { status: 'loaded', url: objectURL }); + } + } + + private executeCallbacks( + url: string, + status: RebuildAssetManagerFinalStatus, + ) { + const callbacks = this.callbackMap.get(url); + while (callbacks && callbacks.length > 0) { + const callback = callbacks.pop(); + if (!callback) { + break; + } + callback(status); + } + } + + // TODO: turn this into a true promise that throws if the asset fails to load + public async whenReady(url: string): Promise { + const currentStatus = this.get(url); + if ( + currentStatus.status === 'loaded' || + currentStatus.status === 'failed' + ) { + return currentStatus; + } else if ( + currentStatus.status === 'unknown' && + this.expectedAssets !== null && + this.expectedAssets.size === 0 && + !this.liveMode + ) { + // we don't expect assets to arrive later + return { + status: 'failed', + }; + } + let resolve: (status: RebuildAssetManagerFinalStatus) => void; + const promise = new Promise((r) => { + resolve = r; + }); + if (!this.callbackMap.has(url)) { + this.callbackMap.set(url, []); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.callbackMap.get(url)!.push(resolve!); + + return promise; + } + + public get(url: string): RebuildAssetManagerStatus { + let tsResult: Map | Map | undefined; + tsResult = this.urlToStylesheetMap.get(url); + if (!tsResult) { + tsResult = this.originalToObjectURLMap.get(url); + } + if (tsResult) { + let result; + let bestTs: number | null = null; + // pick the asset with a timestamp closest to the current replayer value + // preferring ones that loaded after (assuming these are the ones that + // were triggered by the most recently played snapshot) + tsResult.forEach((value, ts) => { + if (bestTs === null) { + result = value; + bestTs = ts; + } else if (this.replayerApproxTs <= ts) { + if (bestTs < this.replayerApproxTs || ts < bestTs) { + result = value; + bestTs = ts; + } + } else if (bestTs < ts) { + result = value; + bestTs = ts; + } + }); + if (result === undefined) { + // satisfy typings + } else if (this.urlToStylesheetMap.has(url)) { + return { + status: 'loaded', + url, + cssTexts: result, + }; + } else { + return { + status: 'loaded', + url: result, + }; + } + } + + if (this.loadingURLs.has(url)) { + return { + status: 'loading', + }; + } + + if (this.failedURLs.has(url)) { + return { + status: 'failed', + }; + } + + return { + status: 'unknown', + }; + } + + public async manageAttribute( + node: RRElement | Element, + nodeId: number, + attribute: string, + serializedValue: string, + serializedNode?: serializedElementNodeWithId, + ): Promise { + const preloadedStatus = this.get(serializedValue); + + let isCssTextElement = false; + if (node.nodeName === 'STYLE') { + // includes s (these are recreated as @@ -223,7 +239,7 @@ ${JSON.stringify(defaultOptions(options))} apiUrl(`/recordings/${recordingId}/events`), { headers: { - Authorization: 'Bearer ' + TEST_API_KEY, + Authorization: 'Bearer ' + TEST_READ_API_KEY, }, }, ); @@ -232,11 +248,16 @@ ${JSON.stringify(defaultOptions(options))} } return res.json(); }, - (events) => events.length > 0, + (events) => events.some((event) => event.type === EventType.Asset), { timeout: 7000, interval: 200 }, ); expect(serverEvents.length).toBeGreaterThan(1); + expect(serverEvents).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); serverEvents.forEach((e) => { // TODO: these should probably not be returned in the first place @@ -254,7 +275,7 @@ ${JSON.stringify(defaultOptions(options))} async () => { const res = await fetch(apiUrl(`/recordings/${recordingId}`), { headers: { - Authorization: 'Bearer ' + TEST_API_KEY, + Authorization: 'Bearer ' + TEST_READ_API_KEY, }, }); if (!res.ok) { @@ -364,7 +385,7 @@ ${JSON.stringify(defaultOptions(options))} apiUrl(`/replay?meta[sessionId]=${options.meta.sessionId}`), { headers: { - Authorization: 'Bearer ' + TEST_API_KEY, + Authorization: 'Bearer ' + TEST_READ_API_KEY, }, }, );