From 2b58e8d3781a18881c24e6e4bb8e40dab8a0b976 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 8 Jun 2023 16:12:54 +0200 Subject: [PATCH 001/183] Add Asset event type and capture assets --- packages/rrweb-snapshot/package.json | 2 +- packages/rrweb-snapshot/src/snapshot.ts | 52 +- packages/rrweb-snapshot/src/utils.ts | 19 + packages/rrweb-snapshot/test/snapshot.test.ts | 86 ++- packages/rrweb/src/record/index.ts | 26 + packages/rrweb/src/record/mutation.ts | 13 + .../src/record/observers/asset-manager.ts | 180 ++++++ packages/rrweb/src/types.ts | 18 + packages/rrweb/test/record/asset.test.ts | 558 ++++++++++++++++++ .../test/record/cross-origin-iframes.test.ts | 7 +- packages/types/src/index.ts | 19 +- yarn.lock | 7 +- 12 files changed, 978 insertions(+), 9 deletions(-) create mode 100644 packages/rrweb/src/record/observers/asset-manager.ts create mode 100644 packages/rrweb/test/record/asset.test.ts diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 70d5a104a0..7602c9c79c 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -60,7 +60,7 @@ "@types/puppeteer": "^5.4.4", "puppeteer": "^17.1.3", "ts-node": "^7.0.1", - "tslib": "^1.9.3", + "tslib": "^2.5.3", "typescript": "^5.4.5", "vite": "^5.3.1", "vite-plugin-dts": "^3.9.1", diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index cd3d7189b7..4ad8e1e62e 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -28,6 +28,7 @@ import { extractFileExtension, absolutifyURLs, markCssSplits, + getUrlsFromSrcset, } from './utils'; import dom from '@rrweb/utils'; @@ -405,6 +406,13 @@ function serializeNode( */ newlyAddedElement?: boolean; cssCaptured?: boolean; + /** + * Called when an asset is detected. + * Example of assets: + * - `src` attribute in `img` tags. + * - `srcset` attribute in `img` tags. + */ + onAssetDetected?: (result: { urls: string[] }) => unknown; }, ): serializedNode | false { const { @@ -423,6 +431,7 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement = false, cssCaptured = false, + onAssetDetected, } = options; // Only record root id when document object is not the base document const rootId = getRootId(doc, mirror); @@ -462,6 +471,7 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + onAssetDetected, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -555,6 +565,13 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + /** + * Called when an asset is detected. + * Example of assets: + * - `src` attribute in `img` tags. + * - `srcset` attribute in `img` tags. + */ + onAssetDetected?: (result: { urls: string[] }) => unknown; }, ): serializedNode | false { const { @@ -570,10 +587,12 @@ function serializeElementNode( keepIframeSrcFn, newlyAddedElement = false, rootId, + onAssetDetected = false, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); let attributes: attributes = {}; + const assets: string[] = []; const len = n.attributes.length; for (let i = 0; i < len; i++) { const attr = n.attributes[i]; @@ -688,7 +707,16 @@ function serializeElementNode( } } // save image offline - if (tagName === 'img' && inlineImages) { + if (tagName === 'img' && onAssetDetected) { + if (attributes.src) { + assets.push(attributes.src.toString()); + } + if (attributes.srcset) { + assets.push(...getUrlsFromSrcset(attributes.srcset.toString())); + } + // TODO: decide if inlineImages should still be supported, + // and if so if it should be moved into `rrweb` package. + } else if (tagName === 'img' && inlineImages) { if (!canvasService) { canvasService = doc.createElement('canvas'); canvasCtx = canvasService.getContext('2d'); @@ -780,6 +808,9 @@ function serializeElementNode( } catch (e) { // In case old browsers don't support customElements } + if (assets.length && onAssetDetected) { + onAssetDetected({ urls: assets }); + } return { type: NodeType.Element, @@ -929,6 +960,13 @@ export function serializeNodeWithId( ) => unknown; stylesheetLoadTimeout?: number; cssCaptured?: boolean; + /** + * Called when an asset is detected. + * Example of assets: + * - `src` attribute in `img` tags. + * - `srcset` attribute in `img` tags. + */ + onAssetDetected?: (result: { urls: string[] }) => unknown; }, ): serializedNodeWithId | null { const { @@ -955,6 +993,7 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, cssCaptured = false, + onAssetDetected, } = options; let { needsMask } = options; let { preserveWhiteSpace = true } = options; @@ -986,6 +1025,7 @@ export function serializeNodeWithId( keepIframeSrcFn, newlyAddedElement, cssCaptured, + onAssetDetected, }); if (!_serializedNode) { // TODO: dev only @@ -1066,6 +1106,7 @@ export function serializeNodeWithId( stylesheetLoadTimeout, keepIframeSrcFn, cssCaptured: false, + onAssetDetected, }; if ( @@ -1239,6 +1280,13 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + /** + * Called when an asset is detected. + * Example of assets: + * - `src` attribute in `img` tags. + * - `srcset` attribute in `img` tags. + */ + onAssetDetected?: (result: { urls: string[] }) => unknown; }, ): serializedNodeWithId | null { const { @@ -1261,6 +1309,7 @@ function snapshot( iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, + onAssetDetected, keepIframeSrcFn = () => false, } = options || {}; const maskInputOptions: MaskInputOptions = @@ -1330,6 +1379,7 @@ function snapshot( stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, + onAssetDetected, }); } diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 862a3e5bf4..b999cf537f 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -501,3 +501,22 @@ export function markCssSplits( ): string { return splitCssText(cssText, style).join('/* rr_split */'); } + +export function getUrlsFromSrcset(srcset: string): string[] { + const urls: string[] = []; + const parts = srcset.split(','); + for (let i = 0; i < parts.length; i++) { + const trimmed = parts[i].trim(); + const spaceIndex = trimmed.indexOf(' '); + if (spaceIndex === -1) { + // If no descriptor is specified, it's a single URL. + urls.push(trimmed); + } else { + // Otherwise, it's one or more URLs followed by a single descriptor. + // Since we don't know how long the URL will be, we'll assume it's everything + // after the first space. + urls.push(trimmed.substring(0, spaceIndex)); + } + } + return urls; +} diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 5778eb0aff..97f731669f 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -194,7 +194,7 @@ describe('scrollTop/scrollLeft', () => { }; it('should serialize scroll positions', () => { - const el = render(`
+ const el = render(`
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
`); el.scrollTop = 10; @@ -253,3 +253,87 @@ describe('jsdom snapshot', () => { }); }); }); + +describe('onAssetDetected callback', () => { + const serializeNode = ( + node: Node, + onAssetDetected: (result: { urls: string[] }) => void, + ): serializedNodeWithId | null => { + return serializeNodeWithId(node, { + doc: document, + mirror: new Mirror(), + blockClass: 'blockblock', + blockSelector: null, + maskTextClass: 'maskmask', + maskTextSelector: null, + skipChild: false, + inlineStylesheet: true, + maskTextFn: undefined, + maskInputFn: undefined, + slimDOMOptions: {}, + newlyAddedElement: false, + inlineImages: false, + onAssetDetected, + }); + }; + + const render = (html: string): HTMLDivElement => { + document.write(html); + return document.querySelector('div')!; + }; + + it('should detect `src` attribute in image', () => { + const el = render(`
+ +
`); + + const callback = jest.fn(); + serializeNode(el, callback); + expect(callback).toHaveBeenCalledWith({ + urls: ['https://example.com/image.png'], + }); + }); + + it('should detect `set` attribute in image with ObjectURL', () => { + const el = render(`
+ +
`); + + const callback = jest.fn(); + serializeNode(el, callback); + expect(callback).toHaveBeenCalledWith({ + urls: ['blob:https://example.com/e81acc2b-f460-4aec-91b3-ce9732b837c4'], + }); + }); + it('should detect `srcset` attribute in image', () => { + const el = render(`
+ +
`); + + const callback = jest.fn(); + serializeNode(el, callback); + expect(callback).toHaveBeenCalledWith({ + urls: [ + 'https://example.com/images/team-photo.jpg', + 'https://example.com/images/team-photo-retina.jpg', + ], + }); + }); + + it('should detect `src` attribute in two images', () => { + const el = render(`
+ + +
`); + + const callback = jest.fn(); + serializeNode(el, callback); + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenCalledWith({ + urls: ['https://example.com/image.png'], + }); + expect(callback).toHaveBeenCalledWith({ + urls: ['https://example.com/image2.png'], + }); + }); +}); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 1308c378a6..0683d0ee81 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -27,6 +27,7 @@ import { type scrollCallback, type canvasMutationParam, type adoptedStyleSheetParam, + type assetParam, } from '@rrweb/types'; import type { CrossOriginIframeMessageEventContent } from '../types'; import { IframeManager } from './iframe-manager'; @@ -40,11 +41,13 @@ import { unregisterErrorHandler, } from './error-handler'; import dom from '@rrweb/utils'; +import AssetManager from './observers/asset-manager'; let wrappedEmit!: (e: eventWithoutTime, 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,6 +98,10 @@ function record( userTriggeredOnInput = false, collectFonts = false, inlineImages = false, + assetCaptureConfig = { + captureObjectURLs: true, + captureOrigins: false, + }, plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), @@ -279,6 +286,12 @@ function record( }, }); + const wrappedAssetEmit = (p: assetParam) => + wrappedEmit({ + type: EventType.Asset, + data: p, + }); + const wrappedAdoptedStyleSheetEmit = (a: adoptedStyleSheetParam) => wrappedEmit({ type: EventType.IncrementalSnapshot, @@ -327,6 +340,12 @@ function record( dataURLOptions, }); + assetManager = new AssetManager({ + mutationCb: wrappedAssetEmit, + win: window, + assetCaptureConfig, + }); + const shadowDomManager = new ShadowDomManager({ mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, @@ -349,6 +368,7 @@ function record( canvasManager, keepIframeSrcFn, processedNodeManager, + assetManager, }, mirror, }); @@ -408,6 +428,11 @@ function record( onStylesheetLoad: (linkEl, childSn) => { stylesheetManager.attachLinkElement(linkEl, childSn); }, + onAssetDetected: (assets) => { + assets.urls.forEach((url) => { + assetManager.capture(url); + }); + }, keepIframeSrcFn, }); @@ -552,6 +577,7 @@ function record( shadowDomManager, processedNodeManager, canvasManager, + assetManager, ignoreCSSAttributes, plugins: plugins diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 42170b4940..bba9b6a978 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -193,6 +193,7 @@ export default class MutationBuffer { private canvasManager: observerParam['canvasManager']; private processedNodeManager: observerParam['processedNodeManager']; private unattachedDoc: HTMLDocument; + private assetManager: observerParam['assetManager']; public init(options: MutationBufferParam) { ( @@ -218,6 +219,7 @@ export default class MutationBuffer { 'shadowDomManager', 'canvasManager', 'processedNodeManager', + 'assetManager', ] as const ).forEach((key) => { // just a type trick, the runtime result is correct @@ -351,6 +353,11 @@ export default class MutationBuffer { this.stylesheetManager.attachLinkElement(link, childSn); }, cssCaptured, + onAssetDetected: (assets) => { + assets.urls.forEach((url) => { + this.assetManager.capture(url); + }); + }, }); if (sn) { adds.push({ @@ -605,6 +612,12 @@ export default class MutationBuffer { } else { return; } + } else if ( + target.tagName === 'IMG' && + (attributeName === 'src' || attributeName === 'srcset') && + value + ) { + this.assetManager.capture(value); } if (!item) { item = { 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..0f5b1486aa --- /dev/null +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -0,0 +1,180 @@ +import type { + IWindow, + SerializedCanvasArg, + eventWithTime, + listenerHandler, +} from '@rrweb/types'; +import type { assetCallback } from '@rrweb/types'; +import { encode } from 'base64-arraybuffer'; + +import { patch } from '../../utils'; +import type { recordOptions } from '../../types'; + +export default class AssetManager { + private urlObjectMap = 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['assetCaptureConfig'], + undefined + >; + + public reset() { + this.urlObjectMap.clear(); + this.capturedURLs.clear(); + this.capturingURLs.clear(); + this.failedURLs.clear(); + this.resetHandlers.forEach((h) => h()); + } + + constructor(options: { + mutationCb: assetCallback; + win: IWindow; + assetCaptureConfig: Exclude< + recordOptions['assetCaptureConfig'], + undefined + >; + }) { + const { win } = options; + + this.mutationCb = options.mutationCb; + this.config = options.assetCaptureConfig; + + const urlObjectMap = this.urlObjectMap; + + if (this.config.captureObjectURLs) { + 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 shouldIgnore(url: string): boolean { + const originsToIgnore = ['data:']; + const urlIsBlob = url.startsWith(`blob:${window.location.origin}/`); + + // Check if url is a blob and we should ignore blobs + if (urlIsBlob) return !this.config.captureObjectURLs; + + // Check if url matches any ignorable origins + for (const origin of originsToIgnore) { + if (url.startsWith(origin)) return true; + } + + // Check the captureOrigins + const captureOrigins = this.config.captureOrigins; + if (typeof captureOrigins === 'boolean') { + return !captureOrigins; + } else if (Array.isArray(captureOrigins)) { + const urlOrigin = new URL(url).origin; + return !captureOrigins.includes(urlOrigin); + } + + return false; + } + + public async getURLObject( + url: string, + ): Promise { + const object = this.urlObjectMap.get(url); + if (object) { + return object; + } + + try { + const response = await fetch(url); + const blob = await response.blob(); + console.log('getURLObject', url, blob); + return blob; + } catch (e) { + console.warn(`getURLObject failed for ${url}`); + throw e; + } + } + + public capture(url: string): { + status: 'capturing' | 'captured' | 'error' | 'refused'; + } { + console.log('capture', url, this.shouldIgnore(url)); + if (this.shouldIgnore(url)) return { status: 'refused' }; + + if (this.capturedURLs.has(url)) { + return { status: 'captured' }; + } else if (this.capturingURLs.has(url)) { + return { status: 'capturing' }; + } else if (this.failedURLs.has(url)) { + return { status: 'error' }; + } + this.capturingURLs.add(url); + console.log('capturing'); + void this.getURLObject(url) + .then(async (object) => { + console.log('captured', url); + if (object) { + let payload: SerializedCanvasArg; + if (object instanceof File || object instanceof Blob) { + const arrayBuffer = await object.arrayBuffer(); + const base64 = encode(arrayBuffer); // cpu intensive, probably good idea to move all of this to a webworker + + payload = { + rr_type: 'Blob', + type: object.type, + data: [ + { + rr_type: 'ArrayBuffer', + base64, // base64 + }, + ], + }; + + this.capturedURLs.add(url); + this.capturingURLs.delete(url); + + this.mutationCb({ + url, + payload, + }); + } + } + }) + .catch(() => { + // TODO: add mutationCb for failed urls + this.failedURLs.add(url); + this.capturingURLs.delete(url); + }); + + return { status: 'capturing' }; + } +} diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 75be0fc629..890b39fecc 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -40,6 +40,7 @@ import type { UnpackFn, } from '@rrweb/types'; import type ProcessedNodeManager from './record/processed-node-manager'; +import type AssetManager from './record/observers/asset-manager'; export type recordOptions = { emit?: (e: T, isCheckout?: boolean) => void; @@ -69,6 +70,21 @@ export type recordOptions = { userTriggeredOnInput?: boolean; collectFonts?: boolean; inlineImages?: boolean; + assetCaptureConfig?: { + /** + * Captures object URLs (blobs, files, media sources). + * More info: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL + */ + captureObjectURLs: 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` capture from all origins. + * Set to `false` or `[]` to disable capturing from any origin apart from object URLs. + */ + captureOrigins: string[] | true | false; + }; plugins?: RecordPlugin[]; // departed, please use sampling options mousemoveWait?: number; @@ -116,6 +132,7 @@ export type observerParam = { shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; processedNodeManager: ProcessedNodeManager; + assetManager: AssetManager; ignoreCSSAttributes: Set; plugins: Array<{ observer: ( @@ -151,6 +168,7 @@ export type MutationBufferParam = Pick< | 'shadowDomManager' | 'canvasManager' | 'processedNodeManager' + | 'assetManager' >; export type ReplayPlugin = { diff --git a/packages/rrweb/test/record/asset.test.ts b/packages/rrweb/test/record/asset.test.ts new file mode 100644 index 0000000000..dfe486b406 --- /dev/null +++ b/packages/rrweb/test/record/asset.test.ts @@ -0,0 +1,558 @@ +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 } from '@rrweb/types'; +import { + getServerURL, + launchPuppeteer, + startServer, + waitForRAF, +} from '../utils'; +import type * as http from 'http'; + +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 = { + assetCaptureConfig?: recordOptions['assetCaptureConfig']; +}; + +const BASE64_PNG_RECTANGLE = + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAWtJREFUeF7t1cEJAEAIxEDtv2gProo8xgpCwuLezI3LGFhBMi0+iCCtHoLEeggiSM1AjMcPESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4ViIIDEDMRwLESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4TwVjsedWCiXGAAAAABJRU5ErkJggg=='; + +async function injectRecordScript( + frame: puppeteer.Frame, + options?: ExtraOptions, +) { + await frame.addScriptTag({ + path: path.resolve(__dirname, '../../dist/rrweb-all.js'), + }); + options = options || {}; + await frame.evaluate((options) => { + (window as unknown as IWindow).snapshots = []; + const { record, pack } = (window as unknown as IWindow).rrweb; + const config: recordOptions = { + assetCaptureConfig: options.assetCaptureConfig, + 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.js'); + ctx.code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + ctx.page = await ctx.browser.newPage(); + await ctx.page.goto('about:blank'); + 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); + await ctx.page.waitForTimeout(500); // FIXME!! + 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?.assetCaptureConfig?.captureOrigins && + Array.isArray(options.assetCaptureConfig.captureOrigins) + ) { + options.assetCaptureConfig.captureOrigins = + options.assetCaptureConfig.captureOrigins.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 caching', function (this: ISuite) { + jest.setTimeout(100_000); + + describe('captureObjectURLs: true with incremental snapshots', function (this: ISuite) { + const ctx: ISuite = setup.call( + this, + ` + + + + + `, + { + assetCaptureConfig: { + captureObjectURLs: true, + captureOrigins: 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: BASE64_PNG_RECTANGLE, // base64 + }, + ], + }, + }, + }; + console.log(events); + 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: BASE64_PNG_RECTANGLE, // base64 + }, + ], + }, + }, + }; + console.log(events); + expect(events[events.length - 1]).toMatchObject(expected); + }); + }); + + describe('captureObjectURLs: true with fullSnapshot', function (this: ISuite) { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + assetCaptureConfig: { + captureObjectURLs: true, + captureOrigins: 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('captureObjectURLs: false', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + `, + { + assetCaptureConfig: { + captureObjectURLs: false, + captureOrigins: 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(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 + console.log(events); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('captureOrigins: false', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + assetCaptureConfig: { + captureOrigins: false, + captureObjectURLs: 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(events).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('captureOrigins: []', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + assetCaptureConfig: { + captureOrigins: [], + captureObjectURLs: 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(events).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('captureOrigins: true', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + { + assetCaptureConfig: { + captureOrigins: true, + captureObjectURLs: 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(events).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); + }); + }); + describe('captureOrigins: ["http://localhost:xxxxx/"]', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + + `, + { + assetCaptureConfig: { + captureOrigins: ['{SERVER_URL}'], + captureObjectURLs: false, + }, + }, + ); + + it('should capture assets with origin 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 an event to be emitted with `event.type` === EventType.Asset + expect(events).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `${ctx.serverURL}/html/assets/robot.png`, + 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 an event to be emitted with `event.type` === EventType.Asset + expect(events).not.toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `${ctx.serverBURL}/html/assets/robot.png`, + payload: expect.any(Object), + }, + }), + ); + }); + }); +}); diff --git a/packages/rrweb/test/record/cross-origin-iframes.test.ts b/packages/rrweb/test/record/cross-origin-iframes.test.ts index a0bb6bd18e..3dac989f40 100644 --- a/packages/rrweb/test/record/cross-origin-iframes.test.ts +++ b/packages/rrweb/test/record/cross-origin-iframes.test.ts @@ -25,6 +25,8 @@ interface ISuite { events: eventWithTime[]; server: http.Server; serverURL: string; + serverB: http.Server; + serverBURL: string; } interface IWindow extends Window { @@ -86,10 +88,7 @@ const setup = function ( content: string, options?: ExtraOptions, ): ISuite { - const ctx = {} as ISuite & { - serverB: http.Server; - serverBURL: string; - }; + const ctx = {} as ISuite; beforeAll(async () => { ctx.browser = await launchPuppeteer(); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 75155cab34..b88522db6a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,6 +13,7 @@ export enum EventType { Meta, Custom, Plugin, + Asset, } export type domContentLoadedEvent = { @@ -66,6 +67,14 @@ export type pluginEvent = { }; }; +export type assetEvent = { + type: EventType.Asset; + data: { + url: string; + payload: SerializedCanvasArg; + }; +}; + export enum IncrementalSource { Mutation, MouseMove, @@ -170,7 +179,8 @@ export type eventWithoutTime = | incrementalSnapshotEvent | metaEvent | customEvent - | pluginEvent; + | pluginEvent + | assetEvent; /** * @deprecated intended for internal use @@ -615,6 +625,13 @@ export type customElementParam = { export type customElementCallback = (c: customElementParam) => void; +export type assetParam = { + url: string; + payload: SerializedCanvasArg; +}; + +export type assetCallback = (d: assetParam) => void; + export type DeprecatedMirror = { map: { [key: number]: INode; diff --git a/yarn.lock b/yarn.lock index c4ad2ac6bb..0cf85d7bb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9860,7 +9860,7 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== -tslib@^1.8.1, tslib@^1.9.3: +tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -9870,6 +9870,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tslib@^2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" + integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" From 7acc7793f49a4a38ed469a25c0aba3908a4177f3 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 8 Jun 2023 16:57:50 +0200 Subject: [PATCH 002/183] Add test to prove player works --- packages/rrweb/test/events/assets.ts | 124 ++++++++++++++++++ ...ncorporate-assets-emitted-later-1-snap.png | Bin 0 -> 10796 bytes packages/rrweb/test/replay/asset.test.ts | 65 +++++++++ 3 files changed, 189 insertions(+) create mode 100644 packages/rrweb/test/events/assets.ts create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png create mode 100644 packages/rrweb/test/replay/asset.test.ts diff --git a/packages/rrweb/test/events/assets.ts b/packages/rrweb/test/events/assets.ts new file mode 100644 index 0000000000..3c7b36b3de --- /dev/null +++ b/packages/rrweb/test/events/assets.ts @@ -0,0 +1,124 @@ +import { EventType, type eventWithTime } from '@rrweb/types'; + +const events: eventWithTime[] = [ + { + type: 4, + data: { + href: '', + width: 1600, + height: 900, + }, + timestamp: 1636379531385, + }, + { + type: 2, + data: { + node: { + type: 0, + childNodes: [ + { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 3, textContent: '\n ', id: 5 }, + { + type: 2, + tagName: 'meta', + attributes: { charset: 'UTF-8' }, + childNodes: [], + id: 6, + }, + { type: 3, textContent: '\n ', id: 7 }, + { + type: 2, + tagName: 'meta', + attributes: { + name: 'viewport', + content: 'width=device-width, initial-scale=1.0', + }, + childNodes: [], + id: 8, + }, + { type: 3, textContent: '\n ', id: 9 }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [{ type: 3, textContent: 'assets', id: 11 }], + id: 10, + }, + { type: 3, textContent: '\n ', id: 12 }, + ], + id: 4, + }, + { type: 3, textContent: '\n ', id: 13 }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { type: 3, textContent: '\n ', id: 15 }, + { + type: 2, + tagName: 'img', + attributes: { + width: '100', + height: '100', + style: 'border: 1px solid #000000', + src: 'httpx://example.com/image.png', + }, + childNodes: [{ type: 3, textContent: '\n ', id: 17 }], + id: 16, + }, + { type: 3, textContent: '\n ', id: 18 }, + { + type: 2, + tagName: 'script', + attributes: {}, + childNodes: [ + { type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 20 }, + ], + id: 19, + }, + { type: 3, textContent: '\n \n\n', id: 21 }, + ], + id: 14, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + }, + timestamp: 1636379531389, + }, + { + type: EventType.Asset, + data: { + url: 'httpx://example.com/image.png', + payload: { + rr_type: 'Blob', + type: 'image/png', + data: [ + { + rr_type: 'ArrayBuffer', + base64: + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAWtJREFUeF7t1cEJAEAIxEDtv2gProo8xgpCwuLezI3LGFhBMi0+iCCtHoLEeggiSM1AjMcPESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4ViIIDEDMRwLESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4TwVjsedWCiXGAAAAABJRU5ErkJggg==', // base64 + }, + ], + }, + }, + timestamp: 1636379532355, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..3bbd91056eb79bc92f28acef03c14da255a83e26 GIT binary patch literal 10796 zcmeAS@N?(olHy`uVBq!ia0y~yU~gbxV6os}1B$%3e9#$4F%}28J29*~C-ahlL4m>3 z#WAE}&YL?MbD0fASRLmr%(~I^Dlk+lNcF`n@gyqn}zyo0kU{f|dUH;Dd@8NGCN-U1fDUMlxeRDcP z&O=s+0Ux$Ko0EL5@*7V>?!0{r|B80stt(~+t3GhrR<3`uIz!G=s8R+3cHwnWOqr}z zU-ETLv#9L3woso{Ow47h=rLS!Y`^Oyh@B1JABkUpxRrreFXFY9dnetkI-2nv@Vp30ti;nnFfX z$Y=@y7onrABycc{Hdw&HFj}92gMpHc%4m-P6ojJ*X*3~?_5#6aU^Fj4f?>2!g@nUs ziwhhMv}$q9e|z^JC$pgdizBzp*=O^ww<@$3ust+33C*{Cc3-DUpaD9Hpu~~@iH`;j zMhOT@!HEGh2nz%qqsld$O7|k-^a2S}vVF&Ml?6vA;keFa7I6;j` z6Q=s;qs1yPC`L;WNO~A8MZn=OFr~<7y9N~aL$6)4e`D<~1_sVs zptS%A;Pf+Eo{To+hhj<|B&qk&-?NKnR>ofc`dSx~ofu?x>}8lY^ZFT^jjYfN#+?0r z?|)k+D6jb6Xfp;F6r;@;NO~A;#(=|Nv>5{qhN0J@NPr~c(d-NfhXKyc9sG^SYMU-F R%;x|($J5o%Wt~$(696>PBJ= { + browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js'); + code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto('about:blank'); + // mouse cursor canvas is large and pushes the replayer below the fold + // lets hide it... + await page.addStyleTag({ + content: '.replayer-mouse-tail{display: none !important;}', + }); + await page.evaluate(code); + await page.evaluate(`let events = ${JSON.stringify(events)}`); + + page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + }); + + afterEach(async () => { + await page.close(); + }); + + afterAll(async () => { + await browser.close(); + }); + + describe('asset', () => { + it('should incorporate assets emitted later', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events, { + }); + replayer.pause(0); + `); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + }); +}); From 608ba0e2eda140db4a8cb968e9e1109e5510ce8a Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 9 Jun 2023 15:27:52 +0200 Subject: [PATCH 003/183] Rename `assetCaptureConfig` to `assetCapture` --- packages/rrweb/src/record/index.ts | 4 +-- .../src/record/observers/asset-manager.ts | 8 +++--- packages/rrweb/src/types.ts | 2 +- packages/rrweb/test/record/asset.test.ts | 26 +++++++++---------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 0683d0ee81..acdcefae77 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -98,7 +98,7 @@ function record( userTriggeredOnInput = false, collectFonts = false, inlineImages = false, - assetCaptureConfig = { + assetCapture = { captureObjectURLs: true, captureOrigins: false, }, @@ -343,7 +343,7 @@ function record( assetManager = new AssetManager({ mutationCb: wrappedAssetEmit, win: window, - assetCaptureConfig, + assetCapture, }); const shadowDomManager = new ShadowDomManager({ diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts index 0f5b1486aa..595aa3665f 100644 --- a/packages/rrweb/src/record/observers/asset-manager.ts +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -18,7 +18,7 @@ export default class AssetManager { private resetHandlers: listenerHandler[] = []; private mutationCb: assetCallback; public readonly config: Exclude< - recordOptions['assetCaptureConfig'], + recordOptions['assetCapture'], undefined >; @@ -33,15 +33,15 @@ export default class AssetManager { constructor(options: { mutationCb: assetCallback; win: IWindow; - assetCaptureConfig: Exclude< - recordOptions['assetCaptureConfig'], + assetCapture: Exclude< + recordOptions['assetCapture'], undefined >; }) { const { win } = options; this.mutationCb = options.mutationCb; - this.config = options.assetCaptureConfig; + this.config = options.assetCapture; const urlObjectMap = this.urlObjectMap; diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 890b39fecc..079fcf3dda 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -70,7 +70,7 @@ export type recordOptions = { userTriggeredOnInput?: boolean; collectFonts?: boolean; inlineImages?: boolean; - assetCaptureConfig?: { + assetCapture?: { /** * Captures object URLs (blobs, files, media sources). * More info: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL diff --git a/packages/rrweb/test/record/asset.test.ts b/packages/rrweb/test/record/asset.test.ts index dfe486b406..a34db4e86f 100644 --- a/packages/rrweb/test/record/asset.test.ts +++ b/packages/rrweb/test/record/asset.test.ts @@ -35,7 +35,7 @@ interface IWindow extends Window { snapshots: eventWithTime[]; } type ExtraOptions = { - assetCaptureConfig?: recordOptions['assetCaptureConfig']; + assetCapture?: recordOptions['assetCapture']; }; const BASE64_PNG_RECTANGLE = @@ -53,7 +53,7 @@ async function injectRecordScript( (window as unknown as IWindow).snapshots = []; const { record, pack } = (window as unknown as IWindow).rrweb; const config: recordOptions = { - assetCaptureConfig: options.assetCaptureConfig, + assetCapture: options.assetCapture, emit(event) { (window as unknown as IWindow).snapshots.push(event); (window as unknown as IWindow).emit(event); @@ -105,11 +105,11 @@ const setup = function ( ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); if ( - options?.assetCaptureConfig?.captureOrigins && - Array.isArray(options.assetCaptureConfig.captureOrigins) + options?.assetCapture?.captureOrigins && + Array.isArray(options.assetCapture.captureOrigins) ) { - options.assetCaptureConfig.captureOrigins = - options.assetCaptureConfig.captureOrigins.map((origin) => + options.assetCapture.captureOrigins = + options.assetCapture.captureOrigins.map((origin) => origin.replace(/\{SERVER_URL\}/g, ctx.serverURL), ); } @@ -142,7 +142,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureObjectURLs: true, captureOrigins: false, }, @@ -284,7 +284,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureObjectURLs: true, captureOrigins: false, }, @@ -329,7 +329,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureObjectURLs: false, captureOrigins: false, }, @@ -409,7 +409,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureOrigins: false, captureObjectURLs: false, }, @@ -441,7 +441,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureOrigins: [], captureObjectURLs: false, }, @@ -473,7 +473,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureOrigins: true, captureObjectURLs: false, }, @@ -509,7 +509,7 @@ describe('asset caching', function (this: ISuite) { `, { - assetCaptureConfig: { + assetCapture: { captureOrigins: ['{SERVER_URL}'], captureObjectURLs: false, }, From b20c8d5f9705d301bea9536eefbc1ab1af11c654 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 9 Jun 2023 15:28:09 +0200 Subject: [PATCH 004/183] Document `assetCapture` config --- docs/recipes/assets.md | 40 +++++++++++++++++++++++++++ guide.md | 61 +++++++++++++++++++++--------------------- 2 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 docs/recipes/assets.md diff --git a/docs/recipes/assets.md b/docs/recipes/assets.md new file mode 100644 index 0000000000..c6b08a2b01 --- /dev/null +++ b/docs/recipes/assets.md @@ -0,0 +1,40 @@ +# Asset Capture Methods & Configuration in rrweb + +[rrweb](https://rrweb.io/) is a JavaScript library that allows you to record and replay user interactions on your website. It provides various configuration options for capturing assets (such as images) during the recording process. In this document, we will explore the different asset capture methods and their configuration options in rrweb. + +## Inline Images (Deprecated) + +The `inlineImages` configuration option is deprecated and should not be used anymore. It has some issues, namely rewriting events that are already emitted which might make you miss the inlined image if the event has already been sent to the server. Instead, use the `assetCapture` option to configure asset capture. + +## Asset Capture Configuration + +The `assetCapture` configuration option allows you to customize the asset capture process. It is an object with the following properties: + +- `captureObjectURLs` (default: `true`): This property specifies whether to capture same-origin `blob:` assets using object URLs. Object URLs are created using the `URL.createObjectURL()` method. Setting `captureObjectURLs` to `true` enables the capture of object URLs. + +- `captureOrigins` (default: `false`): This property determines which origins to capture assets from. It can have the following values: + - `false` or `[]`: Disables capturing any assets apart from object URLs. + - `true`: Captures assets from all origins. + - `[origin1, origin2, ...]`: Captures assets only from the specified origins. For example, `captureOrigins: ['https://s3.example.com/']` captures all assets from the origin `https://s3.example.com/`. + +## TypeScript Type Definition + +Here is the TypeScript type definition for the `recordOptions` object, which includes the asset capture configuration options: + +```typescript +export type recordOptions = { + // Other configuration options... + inlineImages?: boolean; + assetCapture?: { + captureObjectURLs: boolean; + captureOrigins: string[] | true | false; + }; + // Other configuration options... +}; +``` + +This type definition shows that `assetCapture` is an optional property of the `recordOptions` object. It contains the `captureObjectURLs` and `captureOrigins` properties, which have the same meanings as described above. + +## Conclusion + +By configuring the `assetCapture` option in rrweb, you can control how assets like images are captured during the recording process. This allows you to customize which assets are included in the recorded interactions on your website. diff --git a/guide.md b/guide.md index 764e359fb4..4641e078ee 100644 --- a/guide.md +++ b/guide.md @@ -131,36 +131,37 @@ setInterval(save, 10 * 1000); The parameter of `rrweb.record` accepts the following options. -| key | default | description | -| ------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| emit | required | the callback function to get emitted events | -| checkoutEveryNth | - | take a full snapshot after every N events
refer to the [checkout](#checkout) chapter | -| checkoutEveryNms | - | take a full snapshot after every N ms
refer to the [checkout](#checkout) chapter | -| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter | -| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter | -| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter | -| ignoreSelector | null | Use a string to configure which selector should be ignored, refer to the [privacy](#privacy) chapter | -| ignoreCSSAttributes | null | array of CSS attributes that should be ignored | -| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter | -| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter | -| maskAllInputs | false | mask all input content as \* | -| maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | -| maskInputFn | - | customize mask input content recording logic | -| maskTextFn | - | customize mask text content recording logic | -| slimDOMOptions | {} | remove unnecessary parts of the DOM
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L97-L108) | -| dataURLOptions | {} | Canvas image format and quality ,This parameter will be passed to the OffscreenCanvas.convertToBlob(),Using this parameter effectively reduces the size of the recorded data | -| inlineStylesheet | true | whether to inline the stylesheet in the events | -| hooks | {} | hooks for events
refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | -| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | -| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | -| recordCanvas | false | Whether to record the canvas element. Available options:
`false`,
`true` | -| recordCrossOriginIframes | false | Whether to record cross origin iframes. rrweb has to be injected in each child iframe for this to work. Available options:
`false`,
`true` | -| recordAfter | 'load' | If the document is not ready, then the recorder will start recording after the specified event is fired. Available options: `DOMContentLoaded`, `load` | -| inlineImages | false | whether to record the image content | -| collectFonts | false | whether to collect fonts in the website | -| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | -| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) | -| errorHandler | - | A callback that is called if something inside of rrweb throws an error. The callback receives the error as argument. | +| key | default | description | +| ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| emit | required | the callback function to get emitted events | +| checkoutEveryNth | - | take a full snapshot after every N events
refer to the [checkout](#checkout) chapter | +| checkoutEveryNms | - | take a full snapshot after every N ms
refer to the [checkout](#checkout) chapter | +| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter | +| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter | +| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter | +| ignoreSelector | null | Use a string to configure which selector should be ignored, refer to the [privacy](#privacy) chapter | +| ignoreCSSAttributes | null | array of CSS attributes that should be ignored | +| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter | +| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter | +| maskAllInputs | false | mask all input content as \* | +| maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | +| maskInputFn | - | customize mask input content recording logic | +| maskTextFn | - | customize mask text content recording logic | +| slimDOMOptions | {} | remove unnecessary parts of the DOM
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L97-L108) | +| dataURLOptions | {} | Canvas image format and quality ,This parameter will be passed to the OffscreenCanvas.convertToBlob(),Using this parameter effectively reduces the size of the recorded data | +| inlineStylesheet | true | whether to inline the stylesheet in the events | +| hooks | {} | hooks for events
refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | +| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | +| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | +| recordCanvas | false | Whether to record the canvas element. Available options:
`false`,
`true` | +| recordCrossOriginIframes | false | Whether to record cross origin iframes. rrweb has to be injected in each child iframe for this to work. Available options:
`false`,
`true` | +| recordAfter | 'load' | If the document is not ready, then the recorder will start recording after the specified event is fired. Available options: `DOMContentLoaded`, `load` | +| inlineImages | false | whether to record the image content (deprecated, use `assetCapture` instead) | +| assetCapture | { captureObjectURLs: true, captureOrigins: false } | Configure the asset (image) capture and generates async asset events.
Refer to the [asset capture documentation](./docs/recipes/assets.md) for more info. | +| collectFonts | false | whether to collect fonts in the website | +| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | +| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) | +| errorHandler | - | A callback that is called if something inside of rrweb throws an error. The callback receives the error as argument. | #### Privacy From ad775d81166765d4d39208fc82c760a21e128d32 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 13 Jun 2023 15:39:26 +0200 Subject: [PATCH 005/183] Create asset event for assets that failed to load --- .../src/record/observers/asset-manager.ts | 18 ++++- packages/rrweb/test/integration.test.ts | 17 +++-- packages/rrweb/test/record/asset.test.ts | 72 ++++++++++++++++++- packages/rrweb/test/utils.ts | 3 +- packages/types/src/index.ts | 21 +++--- 5 files changed, 115 insertions(+), 16 deletions(-) diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts index 595aa3665f..4249ef014d 100644 --- a/packages/rrweb/src/record/observers/asset-manager.ts +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -169,8 +169,22 @@ export default class AssetManager { } } }) - .catch(() => { - // TODO: add mutationCb for failed urls + .catch((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, + }, + }); + this.failedURLs.add(url); this.capturingURLs.delete(url); }); diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index bc244620e0..3da2f41852 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -73,7 +73,7 @@ describe('record integration tests', function (this: ISuite) { x: Math.round(x + width / 2), y: Math.round(y + height / 2), }; - }, span); + }, span!); await page.touchscreen.tap(center.x, center.y); await page.click('a'); @@ -883,7 +883,10 @@ describe('record integration tests', function (this: ISuite) { page.on('console', (msg) => console.log(msg.text())); await page.goto(`${serverURL}/html`); page.setContent( - getHtml.call(this, 'image-blob-url.html', { inlineImages: true }), + getHtml.call(this, 'image-blob-url.html', { + inlineImages: true, + assetCapture: { captureObjectURLs: false, captureOrigins: false }, + }), ); await page.waitForResponse(`${serverURL}/html/assets/robot.png`); await page.waitForSelector('img'); // wait for image to get added @@ -900,7 +903,10 @@ describe('record integration tests', function (this: ISuite) { page.on('console', (msg) => console.log(msg.text())); await page.goto(`${serverURL}/html`); await page.setContent( - getHtml.call(this, 'frame-image-blob-url.html', { inlineImages: true }), + getHtml.call(this, 'frame-image-blob-url.html', { + inlineImages: true, + assetCapture: { captureObjectURLs: false, captureOrigins: false }, + }), ); await page.waitForResponse(`${serverURL}/html/assets/robot.png`); await page.waitForTimeout(50); // wait for image to get added @@ -917,7 +923,10 @@ describe('record integration tests', function (this: ISuite) { page.on('console', (msg) => console.log(msg.text())); await page.goto(`${serverURL}/html`); await page.setContent( - getHtml.call(this, 'frame2.html', { inlineImages: true }), + getHtml.call(this, 'frame2.html', { + inlineImages: true, + assetCapture: { captureObjectURLs: false, captureOrigins: false }, + }), ); await page.waitForSelector('iframe'); // wait for iframe to get added await waitForRAF(page); // wait for iframe to load diff --git a/packages/rrweb/test/record/asset.test.ts b/packages/rrweb/test/record/asset.test.ts index a34db4e86f..dafd7648d0 100644 --- a/packages/rrweb/test/record/asset.test.ts +++ b/packages/rrweb/test/record/asset.test.ts @@ -94,7 +94,6 @@ const setup = function ( ); // await ctx.page.evaluate(ctx.code); await waitForRAF(ctx.page); - await ctx.page.waitForTimeout(500); // FIXME!! ctx.events = []; await ctx.page.exposeFunction('emit', (e: eventWithTime) => { if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { @@ -496,6 +495,77 @@ describe('asset caching', function (this: ISuite) { ); }); }); + + describe('captureOrigins: true with invalid urls', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + + `, + { + assetCapture: { + captureOrigins: true, + captureObjectURLs: 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(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(events).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + data: { + url: `https://example.com/image.png`, + failed: { + message: 'Failed to fetch', + }, + }, + }), + ); + }); + }); + describe('captureOrigins: ["http://localhost:xxxxx/"]', () => { const ctx: ISuite = setup.call( this, diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 64d4ae1196..1cdd3e54e3 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -709,7 +709,8 @@ export function generateRecordSnippet(options: recordOptions) { recordCanvas: ${options.recordCanvas}, recordAfter: '${options.recordAfter || 'load'}', inlineImages: ${options.inlineImages}, - plugins: ${options.plugins} + plugins: ${options.plugins}, + assetCapture: ${JSON.stringify(options.assetCapture)}, }); `; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b88522db6a..ac6c797edc 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -69,10 +69,7 @@ export type pluginEvent = { export type assetEvent = { type: EventType.Asset; - data: { - url: string; - payload: SerializedCanvasArg; - }; + data: assetParam; }; export enum IncrementalSource { @@ -625,10 +622,18 @@ export type customElementParam = { export type customElementCallback = (c: customElementParam) => void; -export type assetParam = { - url: string; - payload: SerializedCanvasArg; -}; +export type assetParam = + | { + url: string; + payload: SerializedCanvasArg; + } + | { + url: string; + failed: { + status?: number; + message: string; + }; + }; export type assetCallback = (d: assetParam) => void; From 129454952541c66ef41b447d9a7e0a544ccde8c5 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 14 Jun 2023 00:23:42 +0200 Subject: [PATCH 006/183] Move types from rrweb-snapshot to @rrweb/types --- .changeset/yellow-vans-protect.md | 7 + packages/all/test/utils.ts | 2 +- .../src/index.ts | 9 +- packages/rrdom-nodejs/package.json | 2 +- packages/rrdom-nodejs/src/document-nodejs.ts | 2 +- .../rrdom-nodejs/test/document-nodejs.test.ts | 2 +- packages/rrdom-nodejs/tsconfig.json | 2 +- packages/rrdom/src/diff.ts | 5 +- packages/rrdom/src/document.ts | 2 +- packages/rrdom/src/index.ts | 12 +- packages/rrdom/test/diff.test.ts | 14 +- packages/rrdom/test/document.test.ts | 2 +- packages/rrdom/test/virtual-dom.test.ts | 4 +- packages/rrweb-snapshot/package.json | 3 + packages/rrweb-snapshot/src/snapshot.ts | 34 ++-- packages/rrweb-snapshot/src/types.ts | 135 +--------------- packages/rrweb-snapshot/src/utils.ts | 6 +- packages/rrweb-snapshot/test/rebuild.test.ts | 2 +- packages/rrweb-snapshot/test/snapshot.test.ts | 12 +- packages/rrweb-snapshot/test/utils.test.ts | 4 +- packages/rrweb-snapshot/tsconfig.json | 3 +- packages/rrweb/src/record/iframe-manager.ts | 7 +- .../record/observers/canvas/canvas-manager.ts | 3 +- .../rrweb/src/record/stylesheet-manager.ts | 3 +- .../workers/image-bitmap-data-url-worker.ts | 2 +- .../src/replay/canvas/deserialize-args.ts | 29 +++- packages/rrweb/src/replay/canvas/webgl.ts | 12 +- packages/rrweb/src/replay/index.ts | 20 ++- packages/rrweb/src/replay/media/index.ts | 4 +- packages/rrweb/src/types.ts | 2 +- packages/rrweb/src/utils.ts | 3 +- packages/rrweb/test/integration.test.ts | 4 +- packages/rrweb/test/record/asset.test.ts | 7 +- ...corporate-assets-emitted-later-1-snap.png} | Bin packages/rrweb/test/replay/asset.test.ts | 5 +- packages/rrweb/test/replay/video.test.ts | 2 +- packages/rrweb/test/utils.ts | 2 +- packages/types/package.json | 3 - packages/types/src/index.ts | 152 +++++++++++++++++- packages/types/tsconfig.json | 6 +- 40 files changed, 297 insertions(+), 233 deletions(-) create mode 100644 .changeset/yellow-vans-protect.md rename packages/rrweb/test/replay/__image_snapshots__/{asset-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png => asset-test-ts-test-replay-asset-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png} (100%) diff --git a/.changeset/yellow-vans-protect.md b/.changeset/yellow-vans-protect.md new file mode 100644 index 0000000000..dacf876bc3 --- /dev/null +++ b/.changeset/yellow-vans-protect.md @@ -0,0 +1,7 @@ +--- +"rrweb-snapshot": major +"@rrweb/types": patch +--- + +`NodeType` enum was moved from rrweb-snapshot to @rrweb/types +The following types where moved from rrweb-snapshot to @rrweb/types: `documentNode`, `documentTypeNode`, `attributes`, `legacyAttributes`, `elementNode`, `textNode`, `cdataNode`, `commentNode`, `serializedNode`, `serializedNodeWithId` and `DataURLOptions` diff --git a/packages/all/test/utils.ts b/packages/all/test/utils.ts index 5f8aaab932..7947be917b 100644 --- a/packages/all/test/utils.ts +++ b/packages/all/test/utils.ts @@ -1,6 +1,6 @@ -import { NodeType } from 'rrweb-snapshot'; import { expect } from 'vitest'; import { + NodeType, EventType, IncrementalSource, eventWithTime, diff --git a/packages/plugins/rrweb-plugin-canvas-webrtc-record/src/index.ts b/packages/plugins/rrweb-plugin-canvas-webrtc-record/src/index.ts index 4bb8f8f65a..6390468e08 100644 --- a/packages/plugins/rrweb-plugin-canvas-webrtc-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-canvas-webrtc-record/src/index.ts @@ -1,6 +1,9 @@ -import type { Mirror } from 'rrweb-snapshot'; import SimplePeer from 'simple-peer-light'; -import type { RecordPlugin, ICrossOriginIframeMirror } from '@rrweb/types'; +import type { + RecordPlugin, + ICrossOriginIframeMirror, + IMirror, +} from '@rrweb/types'; import type { WebRTCDataChannel } from './types'; export const PLUGIN_NAME = 'rrweb/canvas-webrtc@1'; @@ -25,7 +28,7 @@ export type CrossOriginIframeMessageEventContent = { export class RRWebPluginCanvasWebRTCRecord { private peer: SimplePeer.Instance | null = null; - private mirror: Mirror | undefined; + private mirror: IMirror | undefined; private crossOriginIframeMirror: ICrossOriginIframeMirror | undefined; private streamMap: Map = new Map(); private incomingStreams = new Set(); diff --git a/packages/rrdom-nodejs/package.json b/packages/rrdom-nodejs/package.json index 5663078dd3..4495b7bcf3 100644 --- a/packages/rrdom-nodejs/package.json +++ b/packages/rrdom-nodejs/package.json @@ -56,6 +56,6 @@ "cssstyle": "^2.3.0", "nwsapi": "2.2.0", "rrdom": "^2.0.0-alpha.17", - "rrweb-snapshot": "^2.0.0-alpha.17" + "@rrweb/types": "^2.0.0-alpha.17" } } diff --git a/packages/rrdom-nodejs/src/document-nodejs.ts b/packages/rrdom-nodejs/src/document-nodejs.ts index b9bba9846d..0115a693f4 100644 --- a/packages/rrdom-nodejs/src/document-nodejs.ts +++ b/packages/rrdom-nodejs/src/document-nodejs.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@rrweb/types'; import type { NWSAPI } from 'nwsapi'; import type { CSSStyleDeclaration as CSSStyleDeclarationType } from 'cssstyle'; import { diff --git a/packages/rrdom-nodejs/test/document-nodejs.test.ts b/packages/rrdom-nodejs/test/document-nodejs.test.ts index b6be55bc0f..9ad203dcc8 100644 --- a/packages/rrdom-nodejs/test/document-nodejs.test.ts +++ b/packages/rrdom-nodejs/test/document-nodejs.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@rrweb/types'; import { RRCanvasElement, RRCDATASection, diff --git a/packages/rrdom-nodejs/tsconfig.json b/packages/rrdom-nodejs/tsconfig.json index 4da375e447..8f1d20380a 100644 --- a/packages/rrdom-nodejs/tsconfig.json +++ b/packages/rrdom-nodejs/tsconfig.json @@ -10,7 +10,7 @@ "path": "../rrdom" }, { - "path": "../rrweb-snapshot" + "path": "../types" } ] } diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 5cff5dc724..7b787a941e 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -1,6 +1,6 @@ import { NodeType as RRNodeType, - Mirror as NodeMirror, + type Mirror as NodeMirror, type elementNode, } from 'rrweb-snapshot'; import type { @@ -572,6 +572,9 @@ export function createOrGetNode( case RRNodeType.CDATA: node = document.createCDATASection((rrNode as IRRCDATASection).data); break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown node type ${rrNode.RRNodeType}`); } if (sn) domMirror.add(node, { ...sn }); diff --git a/packages/rrdom/src/document.ts b/packages/rrdom/src/document.ts index f3f55aec1a..344dd44112 100644 --- a/packages/rrdom/src/document.ts +++ b/packages/rrdom/src/document.ts @@ -1,4 +1,4 @@ -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@rrweb/types'; import { parseCSSText, camelize, toCSSText } from './style'; export interface IRRNode { parentElement: IRRNode | null; diff --git a/packages/rrdom/src/index.ts b/packages/rrdom/src/index.ts index 577811766b..4eafcf5b07 100644 --- a/packages/rrdom/src/index.ts +++ b/packages/rrdom/src/index.ts @@ -1,13 +1,9 @@ -import { - NodeType as RRNodeType, - createMirror as createNodeMirror, -} from 'rrweb-snapshot'; +import { createMirror as createNodeMirror } from 'rrweb-snapshot'; +import type { Mirror as NodeMirror } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@rrweb/types'; import type { - Mirror as NodeMirror, IMirror, serializedNodeWithId, -} from 'rrweb-snapshot'; -import type { canvasMutationData, canvasEventWithTime, inputData, @@ -457,6 +453,8 @@ export function getDefaultSN(node: IRRNode, id: number): serializedNodeWithId { type: node.RRNodeType, textContent: '', }; + default: + throw new Error(`Unknown node type`); } } diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index e250ef8f06..5b3fb08376 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -5,12 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as puppeteer from 'puppeteer'; import { vi, MockInstance } from 'vitest'; -import { - NodeType as RRNodeType, - createMirror, - Mirror as NodeMirror, - serializedNodeWithId, -} from 'rrweb-snapshot'; +import { createMirror, Mirror as NodeMirror } from 'rrweb-snapshot'; import { buildFromDom, getDefaultSN, @@ -27,7 +22,12 @@ import { sameNodeType, } from '../src/diff'; import type { IRRElement, IRRNode } from '../src/document'; -import type { canvasMutationData, styleSheetRuleData } from '@rrweb/types'; +import type { + NodeType as RRNodeType, + serializedNodeWithId, + canvasMutationData, + styleSheetRuleData, +} from '@rrweb/types'; import { EventType, IncrementalSource } from '@rrweb/types'; const elementSn = { diff --git a/packages/rrdom/test/document.test.ts b/packages/rrdom/test/document.test.ts index fb43b0f6ab..80661b7daa 100644 --- a/packages/rrdom/test/document.test.ts +++ b/packages/rrdom/test/document.test.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@rrweb/types'; import { BaseRRDocument, BaseRRDocumentType, diff --git a/packages/rrdom/test/virtual-dom.test.ts b/packages/rrdom/test/virtual-dom.test.ts index 8896e81d41..db75d9cac0 100644 --- a/packages/rrdom/test/virtual-dom.test.ts +++ b/packages/rrdom/test/virtual-dom.test.ts @@ -13,11 +13,11 @@ import { documentNode, documentTypeNode, elementNode, - Mirror, NodeType, NodeType as RRNodeType, textNode, -} from 'rrweb-snapshot'; +} from '@rrweb/types'; +import { Mirror } from 'rrweb-snapshot'; import { buildFromDom, buildFromNode, diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 7602c9c79c..61e66467ea 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -53,6 +53,9 @@ "url": "https://github.com/rrweb-io/rrweb/issues" }, "homepage": "https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-snapshot#readme", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.14" + }, "devDependencies": { "@rrweb/utils": "^2.0.0-alpha.17", "@types/jsdom": "^20.0.0", diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 4ad8e1e62e..bc5c924c39 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -1,20 +1,22 @@ -import { - type serializedNode, - type serializedNodeWithId, - NodeType, - type attributes, - type MaskInputOptions, - type SlimDOMOptions, - type DataURLOptions, - type DialogAttributes, - type MaskTextFn, - type MaskInputFn, - type KeepIframeSrcFn, - type ICanvas, - type elementNode, - type serializedElementNodeWithId, - type mediaAttributes, +import type { + MaskInputOptions, + SlimDOMOptions, + MaskTextFn, + MaskInputFn, + KeepIframeSrcFn, + ICanvas, + serializedElementNodeWithId, } from './types'; +import { NodeType } from '@rrweb/types'; +import type { + serializedNode, + serializedNodeWithId, + elementNode, + attributes, + mediaAttributes, + DataURLOptions, + DialogAttributes, +} from '@rrweb/types'; import { Mirror, is2DCanvasBlank, diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 95a4024960..9924223c7c 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -1,90 +1,4 @@ -export enum NodeType { - Document, - DocumentType, - Element, - Text, - CDATA, - Comment, -} - -export type documentNode = { - type: NodeType.Document; - childNodes: serializedNodeWithId[]; - compatMode?: string; -}; - -export type documentTypeNode = { - type: NodeType.DocumentType; - name: string; - publicId: string; - systemId: string; -}; - -type cssTextKeyAttr = { - _cssText?: string; -}; - -export type attributes = cssTextKeyAttr & { - [key: string]: - | string - | number // properties e.g. rr_scrollLeft or rr_mediaCurrentTime - | true // e.g. checked on - | null; // an indication that an attribute was removed (during a mutation) -}; - -export type legacyAttributes = { - /** - * @deprecated old bug in rrweb was causing these to always be set - * @see https://github.com/rrweb-io/rrweb/pull/651 - */ - selected: false; -}; - -export type elementNode = { - type: NodeType.Element; - tagName: string; - attributes: attributes; - childNodes: serializedNodeWithId[]; - isSVG?: true; - needBlock?: boolean; - // This is a custom element or not. - isCustom?: true; -}; - -export type textNode = { - type: NodeType.Text; - textContent: string; - /** - * @deprecated styles are now always snapshotted against parent +
`); + + const callback = jest.fn(); + serializeNode(el, callback); + expect(callback).toBeCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + element: el.querySelector('style'), + attr: 'css_text', + styleId: 1, + value: 'http://localhost/', + }); + }); }); diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts index 7ae4927df9..c235ad0a9e 100644 --- a/packages/rrweb/src/record/observers/asset-manager.ts +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -19,6 +19,7 @@ import { shouldIgnoreAsset, stringifyStylesheet, absolutifyURLs, + findCssTextSplits, } from 'rrweb-snapshot'; export default class AssetManager { @@ -126,11 +127,16 @@ export default class AssetManager { private captureStylesheet( url: string, - linkElement: HTMLLinkElement, + el: HTMLLinkElement | HTMLStyleElement, + styleId?: number, ): assetStatus { try { - linkElement.sheet!.rules; + el.sheet!.cssRules; } catch (e) { + if (el.tagName === 'STYLE') { + // url represents the document url the style element is embedded in so can't be fetched + return { status: 'refused' }; + } // stylesheet could not be found or // is not readable due to CORS, fallback to fetch void this.getURLObject(url) @@ -152,11 +158,11 @@ export default class AssetManager { return { status: 'capturing' }; // 'processing' ? } const processStylesheet = () => { - if (!linkElement.sheet) { + if (!el.sheet) { // this `if` is to satisfy typescript; we already know sheet is accessible return; } - let cssText = stringifyStylesheet(linkElement.sheet); + let cssText = stringifyStylesheet(el.sheet); if (!cssText) { console.warn(`empty stylesheet; CORs issue? ${url}`); return; @@ -167,10 +173,20 @@ export default class AssetManager { rr_type: 'CssText', cssText, }; - this.mutationCb({ - url, - payload, - }); + if (styleId) { + if (el.childNodes.length > 1) { + payload.splits = findCssTextSplits(cssText, el as HTMLStyleElement); + } + this.mutationCb({ + url: `rr_css_text:${styleId}`, + payload, + }); + } else { + this.mutationCb({ + url, + payload, + }); + } }; if (window.requestIdleCallback !== undefined) { // try not to clog up main thread @@ -183,8 +199,12 @@ export default class AssetManager { } public capture(asset: asset): assetStatus | assetStatus[] { - if (asset.element instanceof HTMLLinkElement) { - return this.captureStylesheet(asset.value, asset.element); + if ('sheet' in asset.element) { + return this.captureStylesheet( + asset.value, + asset.element as HTMLStyleElement | HTMLLinkElement, + asset.styleId, + ); } else if (asset.attr === 'srcset') { const statuses: assetStatus[] = []; getSourcesFromSrcset(asset.value).forEach((url) => { diff --git a/packages/rrweb/src/replay/asset-manager/index.ts b/packages/rrweb/src/replay/asset-manager/index.ts index f361061853..cbf5683dd6 100644 --- a/packages/rrweb/src/replay/asset-manager/index.ts +++ b/packages/rrweb/src/replay/asset-manager/index.ts @@ -7,13 +7,18 @@ import type { SerializedCanvasArg, } from '@rrweb/types'; import { deserializeArg } from '../canvas/deserialize-args'; -import { getSourcesFromSrcset } from 'rrweb-snapshot'; +import { + getSourcesFromSrcset, + buildStyleNode, + BuildCache, +} from 'rrweb-snapshot'; import type { RRElement } from 'rrdom'; import { updateSrcset } from './update-srcset'; export default class AssetManager implements RebuildAssetManagerInterface { private originalToObjectURLMap: Map = new Map(); private urlToStylesheetMap: Map = new Map(); + private urlToStylesheetSplitsMap: Map = new Map(); private nodeIdAttributeHijackedMap: Map> = new Map(); private loadingURLs: Set = new Set(); @@ -23,10 +28,12 @@ export default class AssetManager implements RebuildAssetManagerInterface { Array<(status: RebuildAssetManagerFinalStatus) => void> > = new Map(); private liveMode: boolean; + private cache: BuildCache; public allAdded: boolean; - constructor({ liveMode }: { liveMode: boolean }) { + constructor({ liveMode, cache }: { liveMode: boolean; cache: BuildCache }) { this.liveMode = liveMode; + this.cache = cache; this.allAdded = false; } @@ -48,11 +55,15 @@ export default class AssetManager implements RebuildAssetManagerInterface { if (payload.rr_type === 'CssText') { const cssPayload = payload as SerializedCssTextArg; this.urlToStylesheetMap.set(url, cssPayload.cssText); + if (cssPayload.splits) { + this.urlToStylesheetSplitsMap.set(url, cssPayload.splits); + } this.loadingURLs.delete(url); this.executeCallbacks(url, { status: 'loaded', url, cssText: cssPayload.cssText, + cssTextSplits: cssPayload.splits, }); } else { // TODO: extract the logic only needed for assets from deserializeArg @@ -119,6 +130,7 @@ export default class AssetManager implements RebuildAssetManagerInterface { status: 'loaded', url, cssText: result, + cssTextSplits: this.urlToStylesheetSplitsMap.get(url), }; } @@ -152,14 +164,19 @@ export default class AssetManager implements RebuildAssetManagerInterface { node: RRElement | Element, nodeId: number, attribute: string, - newValue: string, + serializedValue: string | number, ): Promise { - const prevValue = node.getAttribute(attribute); + const newValue = + typeof serializedValue === 'string' + ? serializedValue + : `rr_css_text:${serializedValue}`; + let isCssTextElement = false; if (node.nodeName === 'STYLE') { // includes s (these are recreated as `); - const callback = jest.fn(); + const callback = vi.fn(); serializeNode(el, callback); expect(callback).toBeCalledTimes(1); expect(callback).toHaveBeenCalledWith({ @@ -404,4 +406,24 @@ describe('onAssetDetected callback', () => { value: 'http://localhost/', }); }); + + it("should detect style depending on if stylesheetsRuleThreshold is met", () => { + const el = render(`
+ + +
`); + + const callback = vi.fn(); + const stylesheetsRuleThreshold = 2; + const inlineImages = undefined; + serializeNode(el, callback, inlineImages, stylesheetsRuleThreshold); + expect(callback).toBeCalledTimes(1); + }); + }); diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts index ec0dd92873..d7f5652bec 100644 --- a/packages/rrweb/src/record/observers/asset-manager.ts +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -16,7 +16,7 @@ import type { recordOptions, assetStatus } from '../../types'; import { getSourcesFromSrcset, shouldCaptureAsset, - stringifyStylesheet, + stringifyCssRules, absolutifyURLs, splitCssText, } from 'rrweb-snapshot'; @@ -127,8 +127,9 @@ export default class AssetManager { el: HTMLLinkElement | HTMLStyleElement, styleId?: number, ): assetStatus { + let cssRules: CSSRuleList; try { - el.sheet!.cssRules; + cssRules = el.sheet!.cssRules; } catch (e) { if (el.tagName === 'STYLE') { // sheetBaseHref represents the document url the style element is embedded in so can't be fetched @@ -165,16 +166,11 @@ export default class AssetManager { return { status: 'capturing' }; // 'processing' ? } const processStylesheet = () => { - if (!el.sheet) { - // this `if` is to satisfy typescript; we already know sheet is accessible - return; - } - let cssText = stringifyStylesheet(el.sheet); - if (!cssText) { - console.warn(`empty stylesheet; CORs issue? ${sheetBaseHref}`); - return; - } - cssText = absolutifyURLs(cssText, sheetBaseHref); + cssRules = el.sheet!.cssRules; // update, as a mutation may have since occurred + const cssText = stringifyCssRules( + cssRules, + sheetBaseHref, + ); const payload: SerializedCssTextArg = { rr_type: 'CssText', cssTexts: [cssText], diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d1e9700643..bd7a21e1b2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -85,6 +85,11 @@ export type captureAssetsParam = Partial<{ * 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; /** * capture images irrespective of origin (populated from inlineImages setting) */ From 44542a3bbd624508695a46662669415c5f0d1467 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Mon, 13 May 2024 16:39:54 +0100 Subject: [PATCH 136/183] Properly handle a loading CORS stylesheet as a mutation + asset rather than just an asset --- packages/rrweb-snapshot/src/snapshot.ts | 15 ++++++-- packages/rrweb-snapshot/src/utils.ts | 12 +++++-- packages/rrweb-snapshot/test/snapshot.test.ts | 6 ++++ packages/rrweb-snapshot/test/utils.test.ts | 24 +++++++++++++ .../rrweb/src/record/stylesheet-manager.ts | 6 +++- .../test/__snapshots__/record.test.ts.snap | 20 ++++++++++- packages/rrweb/test/record.test.ts | 1 + packages/rrweb/test/record/asset.test.ts | 34 ++++++++++++++++++- 8 files changed, 109 insertions(+), 9 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index a59d896fd1..4bebf51e0a 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -1255,7 +1255,10 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, - captureAssets, + captureAssets: { + ...captureAssets, + _fromMutation: true, // assets captured in the iframe can be inlined (if possible) as iframe will be emitted as a mutation in rrweb/**/iframe-manager.ts + }, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1263,6 +1266,7 @@ export function serializeNodeWithId( iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, + onAssetDetected, keepIframeSrcFn, }); @@ -1288,11 +1292,12 @@ export function serializeNodeWithId( typeof serializedNode.attributes.href === 'string' && extractFileExtension(serializedNode.attributes.href) === 'css')) ) { - // this isn't executed for stylesheet assets as we've replaced `href` with `rr_captured_href` + // this isn't executed for already loaded stylesheet assets as we've replaced `href` with `rr_captured_href` onceStylesheetLoaded( n as HTMLLinkElement, () => { if (onStylesheetLoad) { + // reserialize the node to generate either _cssText or rr_captured_href now that the .sheet is available const serializedLinkNode = serializeNodeWithId(n, { doc, mirror, @@ -1309,7 +1314,10 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, - captureAssets, + captureAssets: { + ...captureAssets, + _fromMutation: true, // it is emitted as a mutation in rrweb/**/stylesheet-manager.ts + }, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1317,6 +1325,7 @@ export function serializeNodeWithId( iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, + onAssetDetected, keepIframeSrcFn, }); diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 2a2c05ab77..daaf50d4b1 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -554,18 +554,24 @@ export function shouldCaptureAsset( attribute === 'href' && lowerIfExists((n as HTMLLinkElement).rel) === 'stylesheet' ) { - if (config.stylesheets === true || !shouldIgnoreAsset(value, config)) { + const linkEl = n as HTMLLinkElement; + if (!linkEl.sheet) { + // capture with an onload mutation instead so that we get an accurate timestamp for it's appearance + return false; + } else if ( + config.stylesheets === true || + !shouldIgnoreAsset(value, config) + ) { // we'll also try to fetch if there are CORs issues return true; } else if (config.stylesheets === 'without-fetch') { // replicate legacy inlineStylesheet behaviour; // inline all stylesheets that are CORs accessible try { - (n as HTMLLinkElement)!.sheet!.cssRules; + return linkEl!.sheet!.cssRules !== undefined; } catch (e) { return false; } - return true; } return false; } else if ( diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 9e30518bef..5e7ed2e69f 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -358,6 +358,12 @@ describe('onAssetDetected callback', () => { `); + // pretend it has loaded but isn't CORS accessible + let linkEl = el.querySelector('link'); + Object.defineProperty(linkEl, 'sheet', { + value: true, + }); + const callback = vi.fn(); serializeNode(el, callback); expect(callback).toBeCalledTimes(1); diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index eafcde9cdd..7037146f86 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -332,6 +332,12 @@ describe('utils', () => { it(`should correctly identify as capturable if inlineStylesheet == 'all'`, () => { const element = document.createElement('link'); element.setAttribute('rel', 'StyleSheet'); + + // pretend it has loaded but isn't CORS accessible + Object.defineProperty(element, 'sheet', { + value: true, + }); + const ca = { objectURLs: false, origins: false, @@ -356,9 +362,27 @@ describe('utils', () => { ).toBe(true); }); + it(`should not identify as capturable if it hasn't loaded yet`, () => { + const element = document.createElement('link'); + element.setAttribute('rel', 'StyleSheet'); + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: false, + stylesheets: true, + }), + ).toBe(false); // will capture as mutation when it loads + }); + it(`should correctly identify stylesheet as capturable due to origin match, but respect a hard stylesheets=false`, () => { const element = document.createElement('link'); element.setAttribute('rel', 'StyleSheet'); + + // pretend it has loaded but isn't CORS accessible + Object.defineProperty(element, 'sheet', { + value: true, + }); + const ca = { objectURLs: false, origins: ['https://example.com'], diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 55bb04893d..4b410e0a12 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -28,7 +28,10 @@ export class StylesheetManager { childSn: serializedNodeWithId, ) { // a mutation rather than an asset event so that we record the timestamp that the stylesheet was loaded - if ('_cssText' in (childSn as elementNode).attributes) + if ( + '_cssText' in (childSn as elementNode).attributes || + 'rr_captured_href' in (childSn as elementNode).attributes + ) { this.mutationCb({ adds: [], removes: [], @@ -41,6 +44,7 @@ export class StylesheetManager { }, ], }); + } this.trackLinkElement(linkEl); } diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index fe03b8c2cb..1ca33b1c10 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -218,7 +218,7 @@ exports[`record > captures CORS stylesheets as assets 1`] = ` \\"tagName\\": \\"link\\", \\"attributes\\": { \\"rel\\": \\"stylesheet\\", - \\"rr_captured_href\\": \\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\" + \\"href\\": \\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\" }, \\"childNodes\\": [], \\"id\\": 5 @@ -266,6 +266,24 @@ exports[`record > captures CORS stylesheets as assets 1`] = ` } } }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 5, + \\"attributes\\": { + \\"rel\\": \\"stylesheet\\", + \\"rr_captured_href\\": \\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\" + } + } + ] + } + }, { \\"type\\": 7, \\"data\\": { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 591509d0fa..d4e0df6c79 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -936,6 +936,7 @@ describe('record', function (this: ISuite) { await ctx.page.waitForResponse(corsStylesheetURL); // wait for stylesheet to be loaded await waitForRAF(ctx.page); // wait for rrweb to emit events + await ctx.page.waitForTimeout(50); // a further allowance for asset event to appear as it depends on the mutation showing up await assertSnapshot(ctx.events); }); diff --git a/packages/rrweb/test/record/asset.test.ts b/packages/rrweb/test/record/asset.test.ts index 8e37a371a6..5b6d9c37bd 100644 --- a/packages/rrweb/test/record/asset.test.ts +++ b/packages/rrweb/test/record/asset.test.ts @@ -3,7 +3,7 @@ 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 } from '@rrweb/types'; +import { EventType, IncrementalSource } from '@rrweb/types'; import { getServerURL, launchPuppeteer, @@ -755,6 +755,38 @@ describe('asset capturing', function (this: ISuite) { 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[] = [ From 5ebe721e9409cafae6f16737f93a2b36e6774db3 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 14 May 2024 16:12:54 +0100 Subject: [PATCH 137/183] Rewrite of assets.md and move out of recipes into core docs --- docs/assets.md | 66 ++++++++++++++++++++++++++++++ docs/{recipes => }/assets.zh_CN.md | 0 docs/recipes/assets.md | 50 ---------------------- guide.md | 6 +-- guide.zh_CN.md | 2 +- packages/types/src/index.ts | 16 ++++---- 6 files changed, 78 insertions(+), 62 deletions(-) create mode 100644 docs/assets.md rename docs/{recipes => }/assets.zh_CN.md (100%) delete mode 100644 docs/recipes/assets.md diff --git a/docs/assets.md b/docs/assets.md new file mode 100644 index 0000000000..833668bd88 --- /dev/null +++ b/docs/assets.md @@ -0,0 +1,66 @@ +# Asset Capture Methods & Configuration in rrweb + +[rrweb](https://rrweb.io/) is a JavaScript library that allows you to record and replay user interactions on your website. It provides various configuration options for capturing assets (such as images) during the recording process. In this document, we will explore the different asset capture methods and their configuration options in rrweb. + +## Asset Events + +Assets are a new type of event that embody a serialized version of a http resource captured during snapshotting. Some examples are images, media files and stylesheets. Resources can be fetched externally (from cache) in the case of a href, or internally for blob: urls and same-origin stylesheets. Asset events are emitted subsequent to either a FullSnapshot or an IncrementalSnapshot (mutation), and although they may have a later timestamp, during replay they are rebuilt as part of the snapshot that they are associated with. In the case where e.g. a stylesheet is referenced at the time of a FullSnapshot, but hasn't been downloaded yet, there can be a subsequent mutation event with a later timestamp which, along with the asset event, can recreate the experience of a network-delayed load of the stylesheet. + +## Assets to mitigate stylesheet processing cost + +In the case of stylesheets, rrweb does some record-time processing in order to serialize the css rules which had a negative effect on the initial page loading times and how quickly the FullSnapshot was taken (see https://pagespeed.web.dev/). These are now taken out of the main thread and processed asynchronously to be emitted (up to `processStylesheetsWithin` ms) later. There is no corresponding delay on the replay side so long as the stylesheet has been successfully emitted. + +## Asset Capture Configuration + +The `captureAssets` configuration option allows you to customize the asset capture process. It is an object with the following properties: + +- `objectURLs` (default: `true`): This property specifies whether to capture same-origin `blob:` assets using object URLs. Object URLs are created using the `URL.createObjectURL()` method. Setting `objectURLs` to `true` enables the capture of object URLs. + +- `origins` (default: `false`): This property determines which origins to capture assets from. It can have the following values: + - `false` or `[]`: Disables capturing any assets apart from object URLs, stylesheets (unless set to false) and images (if that setting is turned on). + - `true`: Captures assets from all origins. + - `[origin1, origin2, ...]`: Captures assets only from the specified origins. For example, `origins: ['https://s3.example.com/']` captures all assets from the origin `https://s3.example.com/`. + +- `images` (default: `false` or `true` if `inlineImages` is true in rrweb.record config): When set, this option turns on asset capturing for all images irrespective of their origin. When this configuration option is false, images may still be captured if their src url matches the `origins` setting above. + +- `stylesheets` (default: `'without-fetch'`): When set to `true`, this turns on capturing of all stylesheets and style elements via the asset system irrespective of origin. The default of `'without-fetch'` is designed to match with the previous `inlineStylesheet` behaviour, whereas the `true` value allows capturing of stylesheets which are otherwise inaccessible due to CORS restrictions to be captured via a fetch call, which will normally use the browser cache. If a stylesheet matches via the `origins` config above, it will be captured irrespective of this config setting (either directly or via fetch). + +- `stylesheetsRuleThreshold` (default: `0`): only invoke the asset system for stylesheets with more than this number of rules. Defaults to zero (rather than say 100) as it only looks at the 'outer' rules (e.g. could have a single media rule which nests 1000s of sub rules). This default may be increased based on feedback. + +- `processStylesheetsWithin` (default: `2000`): This property defines the maximum time in milliseconds that the browser should delay before processing stylesheets. Inline `