diff --git a/.changeset/brave-browsers-remember.md b/.changeset/brave-browsers-remember.md new file mode 100644 index 0000000000..666aceddfe --- /dev/null +++ b/.changeset/brave-browsers-remember.md @@ -0,0 +1,8 @@ +--- +"@rrweb/browser-client": patch +"rrweb": patch +"rrweb-snapshot": patch +"@rrweb/types": patch +--- + +Add asset capture support across rrweb recording, snapshotting, replay, and shared types, and prepare `@rrweb/browser-client` for release with capture asset defaults, diagnostics coverage, and commit-aware build metadata. diff --git a/docs/recipes/assets.md b/docs/recipes/assets.md new file mode 100644 index 0000000000..4c6ed911db --- /dev/null +++ b/docs/recipes/assets.md @@ -0,0 +1,53 @@ +# Asset Capture + +Asset capture records external resources as `Asset` events that are associated with the snapshot or mutation where rrweb found the resource. During replay, rrweb applies those assets when rebuilding the matching snapshot, so images, media, and stylesheets can be replayed even when the original URL is unavailable or has changed. + +Use `captureAssets` with `record`: + +```js +import { record } from '@rrweb/record'; + +record({ + emit(event) {}, + captureAssets: { + objectURLs: true, + origins: ['https://static.example.com'], + images: true, + video: false, + audio: false, + stylesheets: 'without-fetch', + processStylesheetsWithin: 2000, + stylesheetsRuleThreshold: 0, + }, +}); +``` + +## Asset events + +Assets are emitted after the `FullSnapshot` or `IncrementalSnapshot` that detected them. Their event timestamp can be later than the related snapshot, but replay still uses the asset with the snapshot it belongs to. + +For stylesheets, rrweb can process CSS rules asynchronously and emit them as asset events. This keeps expensive stylesheet serialization out of the initial snapshot path while still letting replay apply the captured stylesheet before visual replay when the asset is available. + +## Options + +`captureAssets` is an object with these fields: + +- `objectURLs` (default: `true`): capture same-origin `blob:` assets created with `URL.createObjectURL()`. +- `origins` (default: `false`): choose which URL origins rrweb captures. Use `false` or `[]` to disable origin-based capture, `true` to capture from any origin, or an array such as `['https://static.example.com']` to allow specific origins. +- `images`: capture images even when their origin does not match `origins`. If unset, images are captured only when `origins` matches. `inlineImages: true` maps to `captureAssets.images: true`. +- `video`: capture video assets even when their origin does not match `origins`. If unset, videos are captured only when `origins` matches. +- `audio`: capture audio assets even when their origin does not match `origins`. If unset, audio files are captured only when `origins` matches. +- `stylesheets`: controls stylesheet asset capture. Use `false` to disable it, `'without-fetch'` to capture stylesheets whose CSS rules are already browser-accessible, or `true` to also fetch stylesheet URLs when needed. When stylesheet capture is enabled, including `'without-fetch'`, configured `origins` can allow fetch capture for matching stylesheet URLs. +- `processStylesheetsWithin` (default: `2000`): maximum delay, in milliseconds, for asynchronous stylesheet processing. Lower values reduce the chance that short visits unload before stylesheet assets are emitted. Set `0` or a negative value to process synchronously, which can block the main thread. +- `stylesheetsRuleThreshold` (default: `0`): stylesheets with fewer rules than this threshold are processed immediately and included in the snapshot instead of emitted as separate assets. + +## Legacy inline options + +`inlineImages` and `inlineStylesheet` are still accepted for compatibility, but new integrations should use `captureAssets`. + +- `inlineImages: true` maps to `captureAssets.images: true` when `captureAssets.images` is not set. +- `inlineStylesheet: 'all'` maps to `captureAssets.stylesheets: true`. +- `inlineStylesheet: true` maps to `captureAssets.stylesheets: 'without-fetch'`. +- `inlineStylesheet: false` maps to `captureAssets.stylesheets: false`. + +When calling `rrweb-snapshot` directly, the historical inline behavior is preserved. The mapping above applies to `record`. diff --git a/docs/recipes/assets.zh_CN.md b/docs/recipes/assets.zh_CN.md new file mode 100644 index 0000000000..74f0a32243 --- /dev/null +++ b/docs/recipes/assets.zh_CN.md @@ -0,0 +1,53 @@ +# 静态资源录制 + +静态资源录制会把外部资源记录为 `Asset` 事件,并关联到发现该资源的快照或 mutation。回放时,rrweb 会在重建对应快照时应用这些资源,因此即使原始 URL 不可访问或内容已变化,也可以回放图像、媒体和样式表。 + +在 `record` 中使用 `captureAssets`: + +```js +import { record } from '@rrweb/record'; + +record({ + emit(event) {}, + captureAssets: { + objectURLs: true, + origins: ['https://static.example.com'], + images: true, + video: false, + audio: false, + stylesheets: 'without-fetch', + processStylesheetsWithin: 2000, + stylesheetsRuleThreshold: 0, + }, +}); +``` + +## Asset 事件 + +Asset 事件会在发现它的 `FullSnapshot` 或 `IncrementalSnapshot` 之后发出。事件时间戳可能晚于关联的快照,但回放时仍会把它应用到对应的快照上。 + +对于样式表,rrweb 可以异步处理 CSS 规则并将结果发为 Asset 事件。这样可以避免在初始快照路径上同步执行较重的样式表序列化,同时在资源可用时仍能让回放先应用捕获到的样式表。 + +## 配置项 + +`captureAssets` 是一个对象,包含以下字段: + +- `objectURLs`(默认值:`true`):录制通过 `URL.createObjectURL()` 创建的同源 `blob:` 资源。 +- `origins`(默认值:`false`):选择 rrweb 录制哪些 URL origin。使用 `false` 或 `[]` 关闭基于 origin 的录制,使用 `true` 录制任意 origin,或使用 `['https://static.example.com']` 这样的数组指定允许的 origin。 +- `images`:即使图片 origin 不匹配 `origins`,也录制图片资源。未设置时,只有匹配 `origins` 的图片会被录制。`inlineImages: true` 会映射为 `captureAssets.images: true`。 +- `video`:即使视频 origin 不匹配 `origins`,也录制视频资源。未设置时,只有匹配 `origins` 的视频会被录制。 +- `audio`:即使音频 origin 不匹配 `origins`,也录制音频资源。未设置时,只有匹配 `origins` 的音频会被录制。 +- `stylesheets`:控制样式表资源录制。使用 `false` 关闭,使用 `'without-fetch'` 录制浏览器已经可访问 CSS 规则的样式表,使用 `true` 时在需要时也会 fetch 样式表 URL。启用样式表录制时,包括 `'without-fetch'` 模式,配置的 `origins` 可以允许对匹配的样式表 URL 进行 fetch 录制。 +- `processStylesheetsWithin`(默认值:`2000`):异步处理样式表的最长延迟,单位为毫秒。较低的值可以降低短访问在样式表 Asset 发出前卸载页面的概率。设置为 `0` 或负数会同步处理,但可能阻塞主线程。 +- `stylesheetsRuleThreshold`(默认值:`0`):规则数少于该阈值的样式表会立即处理并放入快照,而不是作为单独的 Asset 事件发出。 + +## 旧的 inline 配置 + +`inlineImages` 和 `inlineStylesheet` 仍然可以作为兼容配置使用,但新的集成应使用 `captureAssets`。 + +- `inlineImages: true` 会在 `captureAssets.images` 未设置时映射为 `captureAssets.images: true`。 +- `inlineStylesheet: 'all'` 映射为 `captureAssets.stylesheets: true`。 +- `inlineStylesheet: true` 映射为 `captureAssets.stylesheets: 'without-fetch'`。 +- `inlineStylesheet: false` 映射为 `captureAssets.stylesheets: false`。 + +直接调用 `rrweb-snapshot` 时,历史 inline 行为仍会保留。以上映射适用于 `record`。 diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 6f5c71d5d2..72c37d273e 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -10,6 +10,12 @@ Record and Replay is the most common use case, which is suitable for any scenari [link](./record-and-replay.md) +### Asset Capture + +Asset capture records external resources as asset events so replay can apply images, media, object URLs, and stylesheets even when the original URL is unavailable or has changed. + +[link](./assets.md) + ### Dive Into Events The events recorded by rrweb are a set of strictly-typed JSON data. You may discover some flexible ways to use them when you are familiar with the details. diff --git a/docs/recipes/index.zh_CN.md b/docs/recipes/index.zh_CN.md index 1aa735c29f..ce9c9dd057 100644 --- a/docs/recipes/index.zh_CN.md +++ b/docs/recipes/index.zh_CN.md @@ -10,6 +10,12 @@ [链接](./record-and-replay.zh_CN.md) +### 静态资源录制 + +静态资源录制会把外部资源记录为 Asset 事件,让回放在原始 URL 不可访问或内容变化时仍能应用图像、媒体、对象 URL 和样式表。 + +[链接](./assets.zh_CN.md) + ### 深入录制数据 录制数据是一组类型严格的 JSON 数据,通过熟悉其格式,可以更灵活的使用录制数据。 diff --git a/guide.md b/guide.md index 59c85c825e..cf16885264 100644 --- a/guide.md +++ b/guide.md @@ -203,36 +203,37 @@ setInterval(save, 10 * 1000); The `record` function 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 | Deprecated since 2.0.0. Still supported, but planned to be superseded by future `captureAssets` asset recording APIs. | -| 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 | Deprecated since 2.0.0. Still supported, but planned to be superseded by future `captureAssets` asset recording APIs. | -| 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 | Deprecated since 2.0.0. Still supported as compatibility input for `captureAssets.stylesheets`. See the [asset capture recipe](./docs/recipes/assets.md). | +| 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 | Deprecated since 2.0.0. Still supported as compatibility input for `captureAssets.images`. See the [asset capture recipe](./docs/recipes/assets.md). | +| captureAssets | { objectURLs: true, origins: false, stylesheets: 'without-fetch' } | Configure asset event capture for object URLs, allowed origins, images, video, audio, and stylesheets. The effective stylesheet default comes from legacy `inlineStylesheet: true` mapping to `captureAssets.stylesheets: 'without-fetch'`. See the [asset capture recipe](./docs/recipes/assets.md). | +| 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 diff --git a/guide.zh_CN.md b/guide.zh_CN.md index 570b8d7e7e..cfb7f2d7cf 100644 --- a/guide.zh_CN.md +++ b/guide.zh_CN.md @@ -201,35 +201,36 @@ setInterval(save, 10 * 1000); `record(config)` 的 config 部分接受以下参数 -| key | 默认值 | 功能 | -| ------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| emit | 必填 | 获取当前录制的数据 | -| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照
详见[“重新制作快照”](#重新制作快照)章节 | -| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照
详见[“重新制作快照”](#重新制作快照)章节 | -| blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 | -| blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 | -| ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 | -| ignoreCSSAttributes | null | 应该被忽略的 CSS 属性数组 | -| maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 | -| maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 | -| maskAllInputs | false | 将所有输入内容记录为 \* | -| maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | -| maskInputFn | - | 自定义特定类型的输入框内容记录逻辑 | -| maskTextFn | - | 自定义文字内容的记录逻辑 | -| slimDOMOptions | {} | 去除 DOM 中不必要的部分
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L97-L108) | -| inlineStylesheet | true | 自 2.0.0 起弃用。2.0 中仍受支持,但计划由未来的 `captureAssets` 资源录制 API 取代。 | -| hooks | {} | 各类事件的回调
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | -| packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) | -| sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) | -| dataURLOptions | {} | Canvas 图像快照的格式和质量,这个参数将传递给 OffscreenCanvas.convertToBlob(),使用这个参数能有效减小录制数据的大小 | -| recordCanvas | false | 是否记录 canvas 内容, 可用选项:`false`, `true` | -| recordCrossOriginIframes | false | 是否记录 cross origin iframes。 必须在每个子 iframe 中注入 rrweb 才能使其工作。 可用选项:`false`, `true` | -| recordAfter | 'load' | 如果 document 还没有加载完成,recorder 将会在指定的事件触发后开始录制。可用选项: `DOMContentLoaded`, `load` | -| inlineImages | false | 自 2.0.0 起弃用。2.0 中仍受支持,但计划由未来的 `captureAssets` 资源录制 API 取代。 | -| collectFonts | false | 是否记录页面中的字体文件 | -| userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) | -| plugins | [] | 加载插件以获得额外的录制功能. [什么是插件?](./docs/recipes/plugin.zh_CN.md) | -| errorHandler | - | 一个可以定制化处理错误的回调函数,它的参数是错误对象。如果 rrweb recorder 内部的某些内容抛出错误,则会调用该回调。 | +| key | 默认值 | 功能 | +| ------------------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| emit | 必填 | 获取当前录制的数据 | +| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照
详见[“重新制作快照”](#重新制作快照)章节 | +| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照
详见[“重新制作快照”](#重新制作快照)章节 | +| blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 | +| blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 | +| ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 | +| ignoreCSSAttributes | null | 应该被忽略的 CSS 属性数组 | +| maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 | +| maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 | +| maskAllInputs | false | 将所有输入内容记录为 \* | +| maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | +| maskInputFn | - | 自定义特定类型的输入框内容记录逻辑 | +| maskTextFn | - | 自定义文字内容的记录逻辑 | +| slimDOMOptions | {} | 去除 DOM 中不必要的部分
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L97-L108) | +| inlineStylesheet | true | 自 2.0.0 起弃用。仍作为 `captureAssets.stylesheets` 的兼容输入受支持。详见[静态资源录制示例](./docs/recipes/assets.zh_CN.md)。 | +| hooks | {} | 各类事件的回调
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | +| packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) | +| sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) | +| dataURLOptions | {} | Canvas 图像快照的格式和质量,这个参数将传递给 OffscreenCanvas.convertToBlob(),使用这个参数能有效减小录制数据的大小 | +| recordCanvas | false | 是否记录 canvas 内容, 可用选项:`false`, `true` | +| recordCrossOriginIframes | false | 是否记录 cross origin iframes。 必须在每个子 iframe 中注入 rrweb 才能使其工作。 可用选项:`false`, `true` | +| recordAfter | 'load' | 如果 document 还没有加载完成,recorder 将会在指定的事件触发后开始录制。可用选项: `DOMContentLoaded`, `load` | +| inlineImages | false | 自 2.0.0 起弃用。仍作为 `captureAssets.images` 的兼容输入受支持。详见[静态资源录制示例](./docs/recipes/assets.zh_CN.md)。 | +| captureAssets | { objectURLs: true, origins: false, stylesheets: 'without-fetch' } | 配置对象 URL、允许的 origin、图片、视频、音频和样式表的 Asset 事件录制。有效的样式表默认值来自旧的 `inlineStylesheet: true` 映射到 `captureAssets.stylesheets: 'without-fetch'`。详见[静态资源录制示例](./docs/recipes/assets.zh_CN.md)。 | +| collectFonts | false | 是否记录页面中的字体文件 | +| userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) | +| plugins | [] | 加载插件以获得额外的录制功能. [什么是插件?](./docs/recipes/plugin.zh_CN.md) | +| errorHandler | - | 一个可以定制化处理错误的回调函数,它的参数是错误对象。如果 rrweb recorder 内部的某些内容抛出错误,则会调用该回调。 | #### 隐私 diff --git a/packages/browser-client/.env.example b/packages/browser-client/.env.example index 65a95c873f..4d18666e0e 100644 --- a/packages/browser-client/.env.example +++ b/packages/browser-client/.env.example @@ -1,3 +1,7 @@ VITE_RRWEB_BROWSER_CLIENT_SERVER_URL=http://localhost:8787/recordings/{recordingId}/events/ws VITE_RRWEB_BROWSER_CLIENT_API_BASE_URL=http://localhost:8787 +VITE_TEST_PUBLIC_API_KEY=public_key_rr_XXXX +VITE_TEST_READ_API_KEY=ak_XXXX + +# Backward-compatible fallback for tests that still read VITE_TEST_API_KEY. VITE_TEST_API_KEY=public_key_rr_XXXX diff --git a/packages/browser-client/README.md b/packages/browser-client/README.md index 65144a55bd..3940fb4fa0 100644 --- a/packages/browser-client/README.md +++ b/packages/browser-client/README.md @@ -51,11 +51,14 @@ rrwebBrowserClient.start({ - `meta`: custom recording metadata sent before recorded events. Built-in diagnostics such as `recordVersion`, `recordCommitHash`, `jsSource`, and `jsEntrypoint` are added automatically after custom metadata. See [Application Metadata](https://rrweb.com/docs/cloud/application-meta). - `jsSource`: optional source identifier for programmatic loaders. URL values are recorded without query strings or hashes. - `jsEntrypoint`: optional entrypoint label. Defaults to `programmatic` for direct `start()` calls and `script-tag` for script-tag autostart. +- `captureAssets`: optional rrweb asset capture configuration. Stylesheet capture defaults to `captureAssets.stylesheets: 'without-fetch'`, so replay can include the CSS needed for the recorded page without fetching stylesheets during recording. Pass your own `captureAssets` object to customize supported rrweb asset capture options. - rrweb record options: other options are passed through to `record()` from rrweb, such as masking, blocking, sampling, and DOM capture options. See the [rrweb recording docs](https://rrweb.com/docs/packages/record/readme). ### Stylesheet Capture -`inlineStylesheet` is currently used for stylesheet capture compatibility. Once the `captureAssets` recording API lands from the assets branch, `captureAssets.stylesheets` should replace that compatibility path. +The browser client enables stylesheet capture by default through `captureAssets.stylesheets`. This records linked stylesheet assets for replay without requiring each caller to configure asset capture manually. + +`inlineStylesheet` remains available as a legacy rrweb compatibility option, but new ESM/npm integrations should prefer `captureAssets`. ## Recording Helpers @@ -79,5 +82,9 @@ Copy `.env.example` to `.env` in this package when running local integration tes ```bash VITE_RRWEB_BROWSER_CLIENT_SERVER_URL=http://localhost:8787/recordings/{recordingId}/events/ws VITE_RRWEB_BROWSER_CLIENT_API_BASE_URL=http://localhost:8787 +VITE_TEST_PUBLIC_API_KEY=public_key_rr_XXXX +VITE_TEST_READ_API_KEY=ak_XXXX + +# Backward-compatible fallback for tests that still read VITE_TEST_API_KEY. VITE_TEST_API_KEY=public_key_rr_XXXX ``` diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index dc953fa1bc..49386c1c69 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -287,10 +287,12 @@ export function start( if (recordOptions.maskAllInputs === undefined) { recordOptions.maskAllInputs = true; // default to more privacy } - // TODO: switch this back to captureAssets.stylesheets once rrweb pulls in - // the captureAssets recording API from the assets branch. - if (recordOptions.inlineStylesheet === undefined) { - recordOptions.inlineStylesheet = true; + recordOptions.captureAssets = { ...recordOptions.captureAssets }; + if ( + recordOptions.captureAssets.stylesheets === undefined && + recordOptions.inlineStylesheet === undefined + ) { + recordOptions.captureAssets.stylesheets = 'without-fetch'; } const configEmit = recordOptions.emit; diff --git a/packages/browser-client/test/autostart.html b/packages/browser-client/test/autostart.html index e395d2b888..dc39ab6b82 100644 --- a/packages/browser-client/test/autostart.html +++ b/packages/browser-client/test/autostart.html @@ -7,7 +7,7 @@ @rrweb/browser-client diff --git a/packages/browser-client/test/integration.test.ts b/packages/browser-client/test/integration.test.ts index 403fc86f35..9fedca0628 100644 --- a/packages/browser-client/test/integration.test.ts +++ b/packages/browser-client/test/integration.test.ts @@ -25,7 +25,14 @@ const TEST_API_BASE_URL = ( import.meta.env.VITE_RRWEB_BROWSER_CLIENT_API_BASE_URL || 'http://localhost:8787' ).replace(/\/$/, ''); -const TEST_API_KEY = import.meta.env.VITE_TEST_API_KEY || ''; +const TEST_PUBLIC_API_KEY = + import.meta.env.VITE_TEST_PUBLIC_API_KEY || + import.meta.env.VITE_TEST_API_KEY || + ''; +const TEST_READ_API_KEY = + import.meta.env.VITE_TEST_READ_API_KEY || + import.meta.env.VITE_TEST_API_KEY || + ''; function apiUrl(path: string): string { return `${TEST_API_BASE_URL}${path}`; @@ -42,7 +49,7 @@ function defaultOptions( if (!options.serverUrl) { options.serverUrl = TEST_SERVER_URL; } - options.publicApiKey = TEST_API_KEY; + options.publicApiKey = TEST_PUBLIC_API_KEY; if (!options.meta) { options.meta = { @@ -58,8 +65,15 @@ type RoundtripOptions = Partial & { }; const roundtripOptions: RoundtripOptions[] = [ - {}, { + captureAssets: { + stylesheets: 'without-fetch', + }, + }, + { + captureAssets: { + stylesheets: 'without-fetch', + }, disableWebsockets: true, // dummy serverUrl: postEventsUrl(TEST_SERVER_URL), // actually disable websockets }, @@ -91,7 +105,8 @@ async function pollUntil( throw new Error(`Timed out after ${timeout}ms`); } -const describeWithApi = TEST_API_KEY ? describe : describe.skip; +const describeWithApi = + TEST_PUBLIC_API_KEY && TEST_READ_API_KEY ? describe : describe.skip; describeWithApi( '@rrweb/browser-client integration tests', @@ -114,6 +129,7 @@ function emitFnName(event) { window.snapshots.push(event); } + @@ -223,7 +239,7 @@ ${JSON.stringify(defaultOptions(options))} apiUrl(`/recordings/${recordingId}/events`), { headers: { - Authorization: 'Bearer ' + TEST_API_KEY, + Authorization: 'Bearer ' + TEST_READ_API_KEY, }, }, ); @@ -232,11 +248,16 @@ ${JSON.stringify(defaultOptions(options))} } return res.json(); }, - (events) => events.length > 0, + (events) => events.some((event) => event.type === EventType.Asset), { timeout: 7000, interval: 200 }, ); expect(serverEvents.length).toBeGreaterThan(1); + expect(serverEvents).toContainEqual( + expect.objectContaining({ + type: EventType.Asset, + }), + ); serverEvents.forEach((e) => { // TODO: these should probably not be returned in the first place @@ -254,7 +275,7 @@ ${JSON.stringify(defaultOptions(options))} async () => { const res = await fetch(apiUrl(`/recordings/${recordingId}`), { headers: { - Authorization: 'Bearer ' + TEST_API_KEY, + Authorization: 'Bearer ' + TEST_READ_API_KEY, }, }); if (!res.ok) { @@ -364,7 +385,7 @@ ${JSON.stringify(defaultOptions(options))} apiUrl(`/replay?meta[sessionId]=${options.meta.sessionId}`), { headers: { - Authorization: 'Bearer ' + TEST_API_KEY, + Authorization: 'Bearer ' + TEST_READ_API_KEY, }, }, ); diff --git a/packages/browser-client/test/load-diagnostics.test.ts b/packages/browser-client/test/load-diagnostics.test.ts index 1cc0deb1ec..20eeb37ed7 100644 --- a/packages/browser-client/test/load-diagnostics.test.ts +++ b/packages/browser-client/test/load-diagnostics.test.ts @@ -219,6 +219,62 @@ describe('@rrweb/browser-client load diagnostics', () => { ); }); + it('defaults stylesheet asset capture without legacy inlineStylesheet', async () => { + const client = await importFreshClient(); + + client.start({ + serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws', + publicApiKey: 'public_key_rr_test', + includePii: false, + autostart: false, + emit: () => undefined, + }); + + expect(mockState.lastRecordOptions?.captureAssets).toMatchObject({ + stylesheets: 'without-fetch', + }); + expect(mockState.lastRecordOptions).not.toHaveProperty('inlineStylesheet'); + }); + + it('preserves caller-provided asset capture options', async () => { + const client = await importFreshClient(); + const captureAssets = { + stylesheets: true, + video: false, + }; + + client.start({ + serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws', + publicApiKey: 'public_key_rr_test', + includePii: false, + autostart: false, + emit: () => undefined, + captureAssets, + }); + + expect(mockState.lastRecordOptions?.captureAssets).toMatchObject({ + stylesheets: true, + video: false, + }); + expect(mockState.lastRecordOptions?.captureAssets).not.toBe(captureAssets); + }); + + it('lets legacy inlineStylesheet options map inside rrweb', async () => { + const client = await importFreshClient(); + + client.start({ + serverUrl: 'http://localhost:8787/recordings/{recordingId}/events/ws', + publicApiKey: 'public_key_rr_test', + includePii: false, + autostart: false, + emit: () => undefined, + inlineStylesheet: false, + }); + + expect(mockState.lastRecordOptions?.captureAssets).toEqual({}); + expect(mockState.lastRecordOptions?.inlineStylesheet).toBe(false); + }); + it('sanitizes explicit programmatic jsSource and strips diagnostics before record()', async () => { const client = await importFreshClient(); diff --git a/packages/browser-client/test/record.html b/packages/browser-client/test/record.html index f1af1fc7dc..f77082c79c 100644 --- a/packages/browser-client/test/record.html +++ b/packages/browser-client/test/record.html @@ -11,15 +11,14 @@ } diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index 0218c36bb7..6094a142cb 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -1,4 +1,5 @@ import snapshot, { + getHref, serializeNodeWithId, transformAttribute, ignoreAttribute, @@ -9,6 +10,7 @@ import snapshot, { classMatchesRegex, IGNORED_NODE, genId, + getSourcesFromSrcset, } from './snapshot'; import rebuild, { buildNodeWithSN, @@ -23,6 +25,7 @@ export * from './types'; export * from './utils'; export { + getHref, snapshot, serializeNodeWithId, rebuild, @@ -40,4 +43,5 @@ export { classMatchesRegex, IGNORED_NODE, genId, + getSourcesFromSrcset, }; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 3d32a88a71..085c776cdf 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -16,6 +16,8 @@ import type { attributes, mediaAttributes, DataURLOptions, + asset, + captureAssetsParam, } from '@rrweb/types'; import { Mirror, @@ -31,6 +33,7 @@ import { absolutifyURLs, markCssSplits, } from './snapshot-utils'; +import { lowerIfExists, shouldCaptureAsset, stringifyCssRules } from './utils'; import dom from '@rrweb/utils'; let _id = 1; @@ -42,6 +45,11 @@ export function genId(): number { return _id++; } +let _styleId = 1; +export function genStyleId(): number { + return _styleId++; +} + function getValidTagName(element: HTMLElement): Lowercase { if (element instanceof HTMLFormElement) { return 'form'; @@ -66,7 +74,11 @@ let canvasCtx: CanvasRenderingContext2D | null; const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space // eslint-disable-next-line no-control-regex const SRCSET_COMMAS_OR_SPACES = /^[, \t\n\r\u000c]+/; -function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { +function parseSrcsetString( + doc: Document, + attributeValue: string, + urlCallback: (doc: Document, url: string) => string, +) { /* run absoluteToDoc over every url in the srcset @@ -103,13 +115,13 @@ function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { let url = collectCharacters(SRCSET_NOT_SPACES); if (url.slice(-1) === ',') { // aside: according to spec more than one comma at the end is a parse error, but we ignore that - url = absoluteToDoc(doc, url.substring(0, url.length - 1)); + url = urlCallback(doc, url.substring(0, url.length - 1)); // the trailing comma splits the srcset, so the interpretion is that // another url will follow, and the descriptor is empty output.push(url); } else { let descriptorsStr = ''; - url = absoluteToDoc(doc, url); + url = urlCallback(doc, url); let inParens = false; // eslint-disable-next-line no-constant-condition while (true) { @@ -140,6 +152,21 @@ function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { return output.join(', '); } +function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { + return parseSrcsetString(doc, attributeValue, (doc, url) => + absoluteToDoc(doc, url), + ); +} + +export function getSourcesFromSrcset(attributeValue: string): string[] { + const urls = new Set(); + parseSrcsetString(document, attributeValue, (_, url) => { + urls.add(url); + return url; + }); + return Array.from(urls); +} + const cachedDocument = new WeakMap(); export function absoluteToDoc(doc: Document, attributeValue: string): string { @@ -154,7 +181,7 @@ function isSVGElement(el: Element): boolean { return Boolean(el.tagName === 'svg' || (el as SVGElement).ownerSVGElement); } -function getHref(doc: Document, customHref?: string) { +export function getHref(doc: Document, customHref?: string) { let a = cachedDocument.get(doc); if (!a) { a = doc.createElement('a'); @@ -391,12 +418,13 @@ function serializeNode( blockClass: string | RegExp; blockSelector: string | null; needsMask: boolean; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; dataURLOptions?: DataURLOptions; inlineImages: boolean; + captureAssets?: captureAssetsParam; recordCanvas: boolean; keepIframeSrcFn: KeepIframeSrcFn; /** @@ -404,6 +432,7 @@ function serializeNode( */ newlyAddedElement?: boolean; cssCaptured?: boolean; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNode | false { const { @@ -418,10 +447,12 @@ function serializeNode( maskInputFn, dataURLOptions = {}, inlineImages, + captureAssets = {}, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, cssCaptured = false, + onAssetDetected, } = options; // Only record root id when document object is not the base document const rootId = getRootId(doc, mirror); @@ -457,10 +488,12 @@ function serializeNode( maskInputFn, dataURLOptions, inlineImages, + captureAssets, recordCanvas, keepIframeSrcFn, newlyAddedElement, rootId, + onAssetDetected, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -542,11 +575,12 @@ function serializeElementNode( doc: Document; blockClass: string | RegExp; blockSelector: string | null; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; maskInputOptions: MaskInputOptions; maskInputFn: MaskInputFn | undefined; dataURLOptions?: DataURLOptions; inlineImages: boolean; + captureAssets?: captureAssetsParam; recordCanvas: boolean; keepIframeSrcFn: KeepIframeSrcFn; /** @@ -554,6 +588,7 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNode | false { const { @@ -565,28 +600,77 @@ function serializeElementNode( maskInputFn, dataURLOptions = {}, inlineImages, + captureAssets = {}, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, rootId, + onAssetDetected, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); let attributes: attributes = {}; const len = n.attributes.length; + if (tagName === 'link' && inlineStylesheet && !needBlock) { + const link = n as HTMLLinkElement; + if (link.href && lowerIfExists(link.rel) === 'stylesheet' && link.sheet) { + let sheetRules: CSSRuleList | undefined; + try { + sheetRules = link.sheet.cssRules; + } catch (e) { + // Cross-origin stylesheets are handled by asset capture when enabled. + } + if ( + sheetRules && + (!onAssetDetected || + captureAssets._fromMutation || + (captureAssets.stylesheetsRuleThreshold !== undefined && + sheetRules.length < captureAssets.stylesheetsRuleThreshold)) + ) { + attributes._cssText = stringifyCssRules(sheetRules, link.href); + } + } + } for (let i = 0; i < len; i++) { const attr = n.attributes[i]; + if (attr.name === '' || attr.name === "''") { + continue; + } + if (attributes._cssText && ['href', 'rel'].includes(attr.name)) { + continue; + } if (!ignoreAttribute(tagName, attr.name, attr.value)) { - attributes[attr.name] = transformAttribute( + const value = transformAttribute( doc, tagName, toLowerCase(attr.name), attr.value, ); + let { name } = attr; + if ( + value && + typeof value === 'string' && + onAssetDetected && + !needBlock && + shouldCaptureAsset(n, attr.name, value, captureAssets) + ) { + onAssetDetected({ + element: n, + attr: attr.name, + value, + }); + name = `rr_captured_${name}`; + } + attributes[name] = value; } } // remote css - if (tagName === 'link' && inlineStylesheet) { + if ( + tagName === 'link' && + inlineStylesheet && + !attributes._cssText && + !onAssetDetected + ) { //TODO: maybe replace this `.styleSheets` with original one const stylesheet = Array.from(doc.styleSheets).find((s) => { return s.href === (n as HTMLLinkElement).href; @@ -601,15 +685,47 @@ function serializeElementNode( attributes._cssText = cssText; } } - if (tagName === 'style' && (n as HTMLStyleElement).sheet) { - let cssText = stringifyStylesheet( - (n as HTMLStyleElement).sheet as CSSStyleSheet, - ); - if (cssText) { - if (n.childNodes.length > 1) { - cssText = markCssSplits(cssText, n as HTMLStyleElement); + if ( + tagName === 'style' && + inlineStylesheet && + captureAssets.stylesheets !== false && + (n as HTMLStyleElement).sheet + ) { + const styleEl = n as HTMLStyleElement; + const sheet = styleEl.sheet as CSSStyleSheet; + let sheetBaseHref = getHref(doc); + if (sheetBaseHref === '') { + sheetBaseHref = document.location.href; + } + let styleRules: CSSRuleList | undefined; + try { + styleRules = sheet.cssRules; + } catch (e) { + // Inaccessible sheets are handled by asset capture when enabled. + } + if ( + styleRules && + (!onAssetDetected || + captureAssets._fromMutation || + (captureAssets.stylesheetsRuleThreshold !== undefined && + styleRules.length < captureAssets.stylesheetsRuleThreshold)) + ) { + let cssText = stringifyStylesheet(sheet); + if (cssText) { + if (n.childNodes.length > 1) { + cssText = markCssSplits(cssText, styleEl); + } + attributes._cssText = cssText; } - attributes._cssText = cssText; + } else if (onAssetDetected && !captureAssets._fromMutation) { + const styleId = genStyleId(); + onAssetDetected({ + element: n, + attr: 'css_text', + value: sheetBaseHref, + styleId, + }); + attributes.rr_css_text = `${sheetBaseHref}#rr_style_el:${styleId}`; } } // form fields @@ -792,16 +908,6 @@ function serializeElementNode( }; } -function lowerIfExists( - maybeAttr: string | number | boolean | undefined | null, -): string { - if (maybeAttr === undefined || maybeAttr === null) { - return ''; - } else { - return (maybeAttr as string).toLowerCase(); - } -} - export function slimDOMDefaults( _slimDOMOptions: SlimDOMOptions | 'all' | true | false | undefined, ) { @@ -929,7 +1035,7 @@ export function serializeNodeWithId( maskTextClass: string | RegExp; maskTextSelector: string | null; skipChild: boolean; - inlineStylesheet: boolean; + inlineStylesheet: boolean | 'all'; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; needsMask?: boolean; @@ -939,6 +1045,7 @@ export function serializeNodeWithId( dataURLOptions?: DataURLOptions; keepIframeSrcFn?: KeepIframeSrcFn; inlineImages?: boolean; + captureAssets?: captureAssetsParam; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; @@ -953,6 +1060,7 @@ export function serializeNodeWithId( ) => unknown; stylesheetLoadTimeout?: number; cssCaptured?: boolean; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNodeWithId | null { const { @@ -970,6 +1078,7 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions = {}, inlineImages = false, + captureAssets = {}, recordCanvas = false, onSerialize, onIframeLoad, @@ -979,10 +1088,24 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, cssCaptured = false, + onAssetDetected, } = options; let { needsMask } = options; let { preserveWhiteSpace = true } = options; + if (onAssetDetected) { + if (captureAssets.images === undefined && inlineImages) { + captureAssets.images = true; + } + if (captureAssets.stylesheets === undefined) { + if (inlineStylesheet) { + captureAssets.stylesheets = 'without-fetch'; + } else { + captureAssets.stylesheets = false; + } + } + } + if (!needsMask) { // perf: if needsMask = true, children won't also need to check const checkAncestors = needsMask === undefined; // if false, we've already checked ancestors @@ -1006,10 +1129,12 @@ export function serializeNodeWithId( maskInputFn, dataURLOptions, inlineImages, + captureAssets, recordCanvas, keepIframeSrcFn, newlyAddedElement, cssCaptured, + onAssetDetected, }); if (!_serializedNode) { // TODO: dev only @@ -1081,6 +1206,7 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1090,6 +1216,7 @@ export function serializeNodeWithId( stylesheetLoadTimeout, keepIframeSrcFn, cssCaptured: false, + onAssetDetected, }; if ( @@ -1098,11 +1225,17 @@ export function serializeNodeWithId( (serializedNode as elementNode).attributes.value !== undefined ) { // value parameter in DOM reflects the correct value, so ignore childNode + } else if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'iframe' && + (serializedNode as elementNode).attributes.rr_captured_src !== undefined + ) { + // Captured iframe-like assets should not also recurse into rendered fallback contents. } else { if ( serializedNode.type === NodeType.Element && - (serializedNode as elementNode).attributes._cssText !== undefined && - typeof serializedNode.attributes._cssText === 'string' + (serializedNode.attributes.rr_css_text || + serializedNode.attributes._cssText) ) { bypassOptions.cssCaptured = true; } @@ -1134,7 +1267,8 @@ export function serializeNodeWithId( if ( serializedNode.type === NodeType.Element && - serializedNode.tagName === 'iframe' + serializedNode.tagName === 'iframe' && + (serializedNode as elementNode).attributes.rr_captured_src === undefined ) { onceIframeLoaded( n as HTMLIFrameElement, @@ -1157,6 +1291,10 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets: { + ...captureAssets, + _fromMutation: true, + }, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1165,6 +1303,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onAssetDetected, }); if (serializedIframeNode) { @@ -1209,6 +1348,10 @@ export function serializeNodeWithId( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets: { + ...captureAssets, + _fromMutation: true, + }, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1217,6 +1360,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onAssetDetected, }); if (serializedLinkNode) { @@ -1242,13 +1386,14 @@ function snapshot( blockSelector?: string | null; maskTextClass?: string | RegExp; maskTextSelector?: string | null; - inlineStylesheet?: boolean; + inlineStylesheet?: boolean | 'all'; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; maskInputFn?: MaskInputFn; slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; + captureAssets?: captureAssetsParam; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; @@ -1263,6 +1408,7 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + onAssetDetected?: (asset: asset) => unknown; }, ): serializedNodeWithId | null { const { @@ -1273,6 +1419,7 @@ function snapshot( maskTextSelector = null, inlineStylesheet = true, inlineImages = false, + captureAssets = {}, recordCanvas = false, maskAllInputs = false, maskTextFn, @@ -1285,6 +1432,7 @@ function snapshot( iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, + onAssetDetected, keepIframeSrcFn = () => false, } = options || {}; const maskInputOptions: MaskInputOptions = @@ -1329,6 +1477,7 @@ function snapshot( slimDOMOptions, dataURLOptions, inlineImages, + captureAssets, recordCanvas, preserveWhiteSpace, onSerialize, @@ -1338,6 +1487,7 @@ function snapshot( stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, + onAssetDetected, }); } @@ -1361,6 +1511,7 @@ export function visitSnapshot( export function cleanupSnapshot() { // allow a new recording to start numbering nodes from scratch _id = 1; + _styleId = 1; } export default snapshot; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 260ba6e47a..0bb52cba75 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -26,6 +26,7 @@ import type { documentTypeNode, textNode, elementNode, + captureAssetsParam, } from '@rrweb/types'; import dom from '@rrweb/utils'; @@ -134,15 +135,22 @@ export function stringifyStylesheet(s: CSSStyleSheet): string | null { // an inline + `); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected) as elementNode; + const style = findElement(serialized, 'style')!; + + expect(onAssetDetected).toHaveBeenCalledWith({ + element: el.querySelector('style'), + attr: 'css_text', + styleId: expect.any(Number), + value: 'http://localhost:3000/', + }); + expect(style.attributes.rr_css_text).toContain('#rr_style_el:'); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('does not detect style element assets when stylesheet capture is disabled', () => { + const el = render(`
+ +
`); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected, { + stylesheets: false, + origins: ['https://example.com'], + }) as elementNode; + const style = findElement(serialized, 'style')!; + + expect(onAssetDetected).not.toHaveBeenCalled(); + expect(style.attributes.rr_css_text).toBeUndefined(); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('does not detect style element assets when inlineStylesheet is disabled', () => { + const el = render(`
+ +
`); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode( + el, + onAssetDetected, + { + origins: ['https://example.com'], + }, + false, + false, + ) as elementNode; + const style = findElement(serialized, 'style')!; + + expect(onAssetDetected).not.toHaveBeenCalled(); + expect(style.attributes.rr_css_text).toBeUndefined(); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('detects inaccessible style elements so record can report refused asset status', () => { + const el = render(`
+ +
`); + const styleEl = el.querySelector('style')!; + Object.defineProperty(styleEl, 'sheet', { + value: { + get cssRules() { + throw new DOMException('cssRules inaccessible', 'SecurityError'); + }, + }, + }); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected) as elementNode; + const style = findElement(serialized, 'style')!; + + // Snapshot's contract is detection only: it marks css_text for capture and + // preserves the original element. Refused/error status is owned by record's + // asset manager through capturedAssetStatuses. + expect(onAssetDetected).toHaveBeenCalledWith({ + element: styleEl, + attr: 'css_text', + styleId: expect.any(Number), + value: 'http://localhost:3000/', + }); + expect(onAssetDetected.mock.calls[0][0]).not.toHaveProperty('status'); + expect(style.attributes.rr_css_text).toContain('#rr_style_el:'); + expect(style.attributes._cssText).toBeUndefined(); + }); + + it('detects media source assets when enabled', () => { + const el = render(`
+ + +
`); + + const onAssetDetected = vi.fn(); + const serialized = serializeAssetNode(el, onAssetDetected, { + video: true, + audio: true, + }) as elementNode; + const video = findElement(serialized, 'video')!; + const audio = findElement(serialized, 'audio')!; + + expect(onAssetDetected).toHaveBeenCalledWith({ + element: el.querySelector('video source'), + attr: 'src', + value: 'https://example.com/show.mp4', + }); + expect(onAssetDetected).toHaveBeenCalledWith({ + element: el.querySelector('audio source'), + attr: 'src', + value: 'https://example.com/sound.mp3', + }); + expect((video.childNodes[0] as elementNode).attributes).toMatchObject({ + rr_captured_src: 'https://example.com/show.mp4', + }); + expect((audio.childNodes[0] as elementNode).attributes).toMatchObject({ + rr_captured_src: 'https://example.com/sound.mp3', + }); + }); +}); diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index f4b68245cc..fa9fe5c258 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -6,6 +6,7 @@ import { escapeImportStatement, extractFileExtension, fixSafariColons, + shouldCaptureAsset, isNodeMetaEqual, stringifyStylesheet, } from '../src/utils'; @@ -326,4 +327,97 @@ describe('utils', () => { ); }); }); + + describe('shouldCaptureAsset', () => { + it('identifies picture srcset image sources when image capture is enabled', () => { + const picture = document.createElement('picture'); + const source = document.createElement('source'); + source.srcset = 'https://example.com/img1.png'; + source.src = 'https://example.com/img2.png'; + const fallbackImg = document.createElement('img'); + fallbackImg.src = 'https://example.com/img3.png'; + + picture.append(source); + picture.append(fallbackImg); + + expect( + shouldCaptureAsset(source, 'srcset', source.srcset, { images: true }), + ).toBe(true); + expect( + shouldCaptureAsset(source, 'src', source.src, { images: true }), + ).toBe(false); + expect( + shouldCaptureAsset(fallbackImg, 'src', fallbackImg.src, { + images: true, + }), + ).toBe(true); + expect( + shouldCaptureAsset(source, 'srcset', source.srcset, { images: false }), + ).toBe(false); + }); + + it('identifies media source assets only for enabled media types', () => { + const video = document.createElement('video'); + const videoSource = document.createElement('source'); + videoSource.src = 'https://example.com/show.mp4'; + videoSource.srcset = 'https://example.com/show@2x.mp4'; + video.append(videoSource); + + const audio = document.createElement('audio'); + const audioSource = document.createElement('source'); + audioSource.src = 'https://example.com/sound.mp3'; + audio.append(audioSource); + + expect( + shouldCaptureAsset(videoSource, 'src', videoSource.src, { + video: true, + }), + ).toBe(true); + expect( + shouldCaptureAsset(videoSource, 'srcset', videoSource.srcset, { + video: true, + }), + ).toBe(false); + expect( + shouldCaptureAsset(videoSource, 'src', videoSource.src, { + video: false, + }), + ).toBe(false); + expect( + shouldCaptureAsset(audioSource, 'src', audioSource.src, { + audio: true, + }), + ).toBe(true); + }); + + it('identifies loaded stylesheet links according to stylesheet config', () => { + const element = document.createElement('link'); + element.setAttribute('rel', 'StyleSheet'); + Object.defineProperty(element, 'sheet', { + value: true, + }); + + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: false, + stylesheets: false, + }), + ).toBe(false); + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: false, + stylesheets: true, + }), + ).toBe(true); + expect( + shouldCaptureAsset(element, 'href', 'https://example.com/style.css', { + objectURLs: false, + origins: ['https://example.com'], + stylesheets: 'without-fetch', + }), + ).toBe(true); + }); + }); }); diff --git a/packages/rrweb/README.md b/packages/rrweb/README.md index 3e738a1992..dbe279c7fb 100644 --- a/packages/rrweb/README.md +++ b/packages/rrweb/README.md @@ -19,6 +19,8 @@ rrweb refers to 'record and replay the web', which is a tool for recording and r [**🍳 Recipes 🍳**](../../docs/recipes/index.md) +[**Asset capture**](../../docs/recipes/assets.md) documents `captureAssets` for object URLs, origins, images, video, audio, and stylesheet asset events. The legacy `inlineImages` and `inlineStylesheet` record options remain available as compatibility inputs. + ## Installation `rrweb` is kept mainly for backward compatibility. For new integrations, prefer package-specific entrypoints (`@rrweb/record` and `@rrweb/replay`) first, or use `@rrweb/all` as a convenience package. diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index fac5359790..91ef900605 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -27,6 +27,14 @@ import { type scrollCallback, type canvasMutationParam, type adoptedStyleSheetParam, + type assetParam, + type asset, + type assetStatus, + type fullSnapshotEvent, + type fullSnapshotEventWithTime, + type assetEventWithTime, + type attributeMutation, + type serializedElementNodeWithId, } from '@rrweb/types'; import type { CrossOriginIframeMessageEventContent } from '../types'; import { IframeManager } from './iframe-manager'; @@ -40,11 +48,16 @@ import { unregisterErrorHandler, } from './error-handler'; import dom from '@rrweb/utils'; +import AssetManager from './observers/asset-manager'; -let wrappedEmit!: (e: eventWithoutTime, isCheckout?: boolean) => void; +let wrappedEmit!: ( + e: eventWithoutTime | eventWithTime, + isCheckout?: boolean, +) => void; let takeFullSnapshot!: (isCheckout?: boolean) => void; let canvasManager!: CanvasManager; +let assetManager!: AssetManager; let recording = false; // Multiple tools (i.e. MooTools, Prototype.js) override Array.from and drop support for the 2nd parameter @@ -95,12 +108,35 @@ function record( userTriggeredOnInput = false, collectFonts = false, inlineImages = false, + captureAssets: _captureAssets, plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), errorHandler, } = options; + const captureAssets: Exclude< + recordOptions['captureAssets'], + undefined + > = { + objectURLs: true, + origins: false, + ..._captureAssets, + }; + + if (inlineImages && captureAssets.images === undefined) { + captureAssets.images = true; + } + if (captureAssets.stylesheets === undefined) { + if (inlineStylesheet === 'all') { + captureAssets.stylesheets = true; + } else if (inlineStylesheet === true) { + captureAssets.stylesheets = 'without-fetch'; + } else if (inlineStylesheet === false) { + captureAssets.stylesheets = false; + } + } + registerErrorHandler(errorHandler); const inEmittingFrame = recordCrossOriginIframes @@ -182,9 +218,11 @@ function record( } return e as unknown as T; }; - wrappedEmit = (r: eventWithoutTime, isCheckout?: boolean) => { + wrappedEmit = (r: eventWithoutTime | eventWithTime, isCheckout?: boolean) => { const e = r as eventWithTime; - e.timestamp = nowTimestamp(); + if (!('timestamp' in e) || e.timestamp === undefined) { + e.timestamp = nowTimestamp(); + } if ( mutationBuffers[0]?.isFrozen() && e.type !== EventType.FullSnapshot && @@ -260,6 +298,16 @@ function record( }, }); + const wrappedAssetEmit = (p: assetParam, snapshotTimestamp?: number | true) => + wrappedEmit({ + type: EventType.Asset, + data: p, + timestamp: + snapshotTimestamp === true + ? assetManager.lastFullSnapshotTimestamp + : snapshotTimestamp, + } as assetEventWithTime); + const wrappedAdoptedStyleSheetEmit = (a: adoptedStyleSheetParam) => wrappedEmit({ type: EventType.IncrementalSnapshot, @@ -273,6 +321,29 @@ function record( mutationCb: wrappedMutationEmit, adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, }); + const emitCapturedStylesheetAttributes = ( + childSn: serializedElementNodeWithId, + ) => { + const capturedAttributes: attributeMutation['attributes'] = + Object.fromEntries( + Object.entries(childSn.attributes).filter(([name]) => + name.startsWith('rr_captured_'), + ), + ) as attributeMutation['attributes']; + if (Object.keys(capturedAttributes).length) { + wrappedMutationEmit({ + adds: [], + removes: [], + texts: [], + attributes: [ + { + id: childSn.id, + attributes: capturedAttributes, + }, + ], + }); + } + }; const iframeManager = new IframeManager({ mirror, @@ -308,6 +379,12 @@ function record( dataURLOptions, }); + assetManager = new AssetManager({ + mutationCb: wrappedAssetEmit, + win: window, + captureAssets, + }); + const shadowDomManager = new ShadowDomManager({ mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, @@ -323,11 +400,13 @@ function record( maskInputFn, recordCanvas, inlineImages, + captureAssets, sampling, slimDOMOptions, iframeManager, stylesheetManager, canvasManager, + assetManager, keepIframeSrcFn, processedNodeManager, }, @@ -356,13 +435,17 @@ function record( shadowDomManager.init(); mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting + const capturedAssetStatuses: assetStatus[] = []; + const fullSnapshotTimestamp = nowTimestamp(); + assetManager.lastFullSnapshotTimestamp = fullSnapshotTimestamp; + const node = snapshot(document, { mirror, blockClass, blockSelector, maskTextClass, maskTextSelector, - inlineStylesheet, + inlineStylesheet: Boolean(inlineStylesheet), maskAllInputs: maskInputOptions, maskTextFn, maskInputFn, @@ -370,6 +453,7 @@ function record( dataURLOptions, recordCanvas, inlineImages, + captureAssets, onSerialize: (n) => { if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); @@ -388,6 +472,15 @@ function record( }, onStylesheetLoad: (linkEl, childSn) => { stylesheetManager.attachLinkElement(linkEl, childSn); + emitCapturedStylesheetAttributes(childSn); + }, + onAssetDetected: (asset: asset) => { + const assetStatus = assetManager.capture(asset, true); + if (Array.isArray(assetStatus)) { + capturedAssetStatuses.push(...assetStatus); + } else { + capturedAssetStatuses.push(assetStatus); + } }, keepIframeSrcFn, }); @@ -396,14 +489,19 @@ function record( return console.warn('Failed to snapshot the document'); } + const data: fullSnapshotEvent['data'] = { + node, + initialOffset: getWindowScroll(window), + }; + if (capturedAssetStatuses.length) { + data.capturedAssetStatuses = capturedAssetStatuses; + } wrappedEmit( { type: EventType.FullSnapshot, - data: { - node, - initialOffset: getWindowScroll(window), - }, - }, + timestamp: fullSnapshotTimestamp, + data, + } as fullSnapshotEventWithTime, isCheckout, ); mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror @@ -518,6 +616,7 @@ function record( recordDOM, recordCanvas, inlineImages, + captureAssets, userTriggeredOnInput, collectFonts, doc, @@ -533,6 +632,7 @@ function record( shadowDomManager, processedNodeManager, canvasManager, + assetManager, ignoreCSSAttributes, plugins: plugins @@ -615,6 +715,7 @@ function record( } }); processedNodeManager.destroy(); + assetManager.reset(); recording = false; unregisterErrorHandler(); }; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 08e927a98f..67c9713de7 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -1,4 +1,5 @@ import { + absolutifyURLs, serializeNodeWithId, transformAttribute, IGNORED_NODE, @@ -19,6 +20,9 @@ import type { removedNodeMutation, addedNodeMutation, Optional, + asset, + attributeMutation, + serializedElementNodeWithId, } from '@rrweb/types'; import { isBlocked, @@ -31,8 +35,10 @@ import { inDom, getShadowHost, closestElementOfNode, + nowTimestamp, } from '../utils'; import dom from '@rrweb/utils'; +import { isProcessingStyleElement } from './observers/asset-manager'; type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; @@ -183,6 +189,7 @@ export default class MutationBuffer { private keepIframeSrcFn: observerParam['keepIframeSrcFn']; private recordCanvas: observerParam['recordCanvas']; private inlineImages: observerParam['inlineImages']; + private captureAssets: observerParam['captureAssets']; private slimDOMOptions: observerParam['slimDOMOptions']; private dataURLOptions: observerParam['dataURLOptions']; private doc: observerParam['doc']; @@ -192,6 +199,7 @@ export default class MutationBuffer { private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; private processedNodeManager: observerParam['processedNodeManager']; + private assetManager: observerParam['assetManager']; private unattachedDoc: HTMLDocument; public init(options: MutationBufferParam) { @@ -207,6 +215,7 @@ export default class MutationBuffer { 'maskTextFn', 'maskInputFn', 'keepIframeSrcFn', + 'captureAssets', 'recordCanvas', 'inlineImages', 'slimDOMOptions', @@ -218,6 +227,7 @@ export default class MutationBuffer { 'shadowDomManager', 'canvasManager', 'processedNodeManager', + 'assetManager', ] as const ).forEach((key) => { // just a type trick, the runtime result is correct @@ -266,6 +276,8 @@ export default class MutationBuffer { return; } + const now = nowTimestamp(); + // delay any modification of the mirror until this function // so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed @@ -327,6 +339,10 @@ export default class MutationBuffer { maskInputFn: this.maskInputFn, slimDOMOptions: this.slimDOMOptions, dataURLOptions: this.dataURLOptions, + captureAssets: { + ...this.captureAssets, + _fromMutation: true, + }, recordCanvas: this.recordCanvas, inlineImages: this.inlineImages, onSerialize: (currentN) => { @@ -349,8 +365,12 @@ export default class MutationBuffer { }, onStylesheetLoad: (link, childSn) => { this.stylesheetManager.attachLinkElement(link, childSn); + this.emitCapturedStylesheetAttributes(childSn); }, cssCaptured, + onAssetDetected: (asset: asset) => { + this.assetManager.capture(asset, now); + }, }); if (sn) { adds.push({ @@ -453,13 +473,22 @@ export default class MutationBuffer { .map((text) => { const n = text.node; const parent = dom.parentNode(n); - if (parent && (parent as Element).tagName === 'TEXTAREA') { - // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea - this.genTextAreaValueMutation(parent as HTMLTextAreaElement); + let value = text.value; + if (parent) { + const parentEl = parent as Element; + if (parentEl.tagName === 'TEXTAREA') { + // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea + this.genTextAreaValueMutation(parent as HTMLTextAreaElement); + } else if (parentEl.tagName === 'STYLE') { + if (isProcessingStyleElement(parentEl)) { + return { id: -1, value: null }; + } + value = absolutifyURLs(value, this.doc.baseURI); + } } return { id: this.mirror.getId(n), - value: text.value, + value, }; }) // no need to include them on added elements, as they have just been serialized with up to date attribubtes @@ -547,6 +576,30 @@ export default class MutationBuffer { }); }; + private emitCapturedStylesheetAttributes = ( + childSn: serializedElementNodeWithId, + ) => { + const capturedAttributes: attributeMutation['attributes'] = + Object.fromEntries( + Object.entries(childSn.attributes).filter(([name]) => + name.startsWith('rr_captured_'), + ), + ) as attributeMutation['attributes']; + if (Object.keys(capturedAttributes).length) { + this.mutationCb({ + adds: [], + removes: [], + texts: [], + attributes: [ + { + id: childSn.id, + attributes: capturedAttributes, + }, + ], + }); + } + }; + private processMutation = (m: mutationRecord) => { if (isIgnored(m.target, this.mirror, this.slimDOMOptions)) { return; @@ -636,13 +689,30 @@ export default class MutationBuffer { } if (!ignoreAttribute(target.tagName, attributeName, value)) { - // overwrite attribute if the mutations was triggered in same time - item.attributes[attributeName] = transformAttribute( + let transformedValue = transformAttribute( this.doc, toLowerCase(target.tagName), toLowerCase(attributeName), value, ); + if ( + transformedValue && + this.assetManager.shouldCapture( + target, + attributeName, + transformedValue, + this.captureAssets, + ) + ) { + this.assetManager.capture({ + element: target, + attr: attributeName, + value: transformedValue, + }); + attributeName = `rr_captured_${attributeName}`; + } + // overwrite attribute if the mutations was triggered in same time + item.attributes[attributeName] = transformedValue; if (attributeName === 'style') { if (!this.unattachedDoc) { try { @@ -703,6 +773,9 @@ export default class MutationBuffer { this.genTextAreaValueMutation(m.target as HTMLTextAreaElement); return; // any removedNodes won't have been in mirror either } + if (isProcessingStyleElement(m.target as HTMLElement)) { + return; + } m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts new file mode 100644 index 0000000000..8cf1d7f231 --- /dev/null +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -0,0 +1,407 @@ +import type { + IWindow, + SerializedCanvasArg, + SerializedCssTextArg, + asset, + assetCallback, + assetStatus, + captureAssetsParam, + eventWithTime, + listenerHandler, +} from '@rrweb/types'; +import { encode } from 'base64-arraybuffer'; +import { patch } from '@rrweb/utils'; +import { + absolutifyURLs, + getSourcesFromSrcset, + shouldCaptureAsset, + splitCssText, + stringifyCssRules, +} from 'rrweb-snapshot'; + +import type { ProcessingStyleElement, recordOptions } from '../../types'; + +export function isProcessingStyleElement( + el: Element, +): el is ProcessingStyleElement { + return '__rrProcessingStylesheet' in el; +} + +export default class AssetManager { + private urlObjectMap = new Map(); + private urlTextMap = new Map(); + private capturedURLs = new Set(); + private capturingURLs = new Set(); + private failedURLs = new Set(); + private resetHandlers: listenerHandler[] = []; + private mutationCb: assetCallback; + public readonly config: Exclude< + recordOptions['captureAssets'], + undefined + >; + + public lastFullSnapshotTimestamp = 0; + + public reset() { + this.urlObjectMap.clear(); + this.urlTextMap.clear(); + this.capturedURLs.clear(); + this.capturingURLs.clear(); + this.failedURLs.clear(); + this.resetHandlers.forEach((h) => h()); + this.resetHandlers = []; + } + + constructor(options: { + mutationCb: assetCallback; + win: IWindow; + captureAssets: Exclude< + recordOptions['captureAssets'], + undefined + >; + }) { + const { win } = options; + + this.mutationCb = options.mutationCb; + this.config = options.captureAssets; + + const urlObjectMap = this.urlObjectMap; + + if (this.config.objectURLs || this.config.images) { + try { + const restoreHandler = patch( + win.URL, + 'createObjectURL', + function (original: (obj: File | Blob | MediaSource) => string) { + return function (obj: File | Blob | MediaSource) { + const url = original.apply(this, [obj]); + urlObjectMap.set(url, obj); + return url; + }; + }, + ); + this.resetHandlers.push(restoreHandler); + } catch { + console.error('failed to patch URL.createObjectURL'); + } + + try { + const restoreHandler = patch( + win.URL, + 'revokeObjectURL', + function (original: (objectURL: string) => void) { + return function (objectURL: string) { + urlObjectMap.delete(objectURL); + return original.apply(this, [objectURL]); + }; + }, + ); + this.resetHandlers.push(restoreHandler); + } catch { + console.error('failed to patch URL.revokeObjectURL'); + } + } + } + + public async getURLObject( + url: string, + ): Promise { + const object = this.urlObjectMap.get(url); + if (object) { + return object; + } + const text = this.urlTextMap.get(url); + if (text) { + return text; + } + + try { + const response = await fetch(url); + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('text/css')) { + return await response.text(); + } + return await response.blob(); + } catch (e) { + console.warn(`getURLObject failed for ${url}`); + throw e; + } + } + + private captureStylesheet( + sheetBaseHref: string, + el: HTMLLinkElement | HTMLStyleElement, + styleId?: number, + snapshotTimestamp?: number | true, + ): assetStatus { + let cssRules: CSSRuleList; + let url = sheetBaseHref; + if (styleId) { + url += `#rr_style_el:${styleId}`; + } else if (el.getAttribute('media') !== null) { + const linkAppliedQuery = matchMedia(el.getAttribute('media') as string); + if (!linkAppliedQuery.matches) { + try { + try { + linkAppliedQuery.addEventListener('change', () => + this.captureStylesheet(sheetBaseHref, el, styleId), + ); + } catch { + linkAppliedQuery.addListener(() => + this.captureStylesheet(sheetBaseHref, el, styleId), + ); + } + return { + url, + status: 'media-mismatch', + }; + } catch { + // Cannot listen for media changes, so capture now. + } + } + } + const eventTimestamp = this.getEventTimestamp(snapshotTimestamp); + + try { + cssRules = el.sheet!.cssRules; + } catch (e) { + if (el.tagName === 'STYLE') { + return { + url, + status: 'refused', + }; + } + if (this.capturedURLs.has(url)) { + return { + url, + status: 'captured', + }; + } + if (this.capturingURLs.has(url)) { + return { + url, + status: 'capturing', + }; + } + if (this.failedURLs.has(url)) { + return { + url, + status: 'error', + }; + } + this.capturingURLs.add(url); + void this.getURLObject(url) + .then((cssText) => { + this.capturedURLs.add(url); + this.capturingURLs.delete(url); + + if (cssText && typeof cssText === 'string') { + const payload: SerializedCssTextArg = { + rr_type: 'CssText', + cssTexts: [absolutifyURLs(cssText, sheetBaseHref)], + }; + this.mutationCb( + { + url, + payload, + }, + eventTimestamp, + ); + } + }) + .catch(this.fetchCatcher(url, eventTimestamp)); + return { + url, + status: 'capturing', + }; + } + + const processStylesheet = () => { + cssRules = el.sheet!.cssRules; + const cssText = stringifyCssRules(cssRules, sheetBaseHref); + const payload: SerializedCssTextArg = { + rr_type: 'CssText', + cssTexts: [cssText], + }; + if (styleId) { + if (el.childNodes.length > 1) { + payload.cssTexts = splitCssText(cssText, el as HTMLStyleElement); + } + this.mutationCb( + { + url, + payload, + }, + eventTimestamp, + ); + } else { + this.mutationCb( + { + url: sheetBaseHref, + payload, + }, + eventTimestamp, + ); + } + if (isProcessingStyleElement(el)) { + delete el.__rrProcessingStylesheet; + } + }; + + let timeout = this.config.processStylesheetsWithin; + if (!timeout && timeout !== 0) { + timeout = 2000; + } + if (timeout <= 0) { + processStylesheet(); + return { + url, + status: 'captured', + }; + } + if (window.requestIdleCallback !== undefined) { + if (el.tagName === 'STYLE') { + (el as ProcessingStyleElement).__rrProcessingStylesheet = true; + timeout = Math.floor(timeout / 2); + } + requestIdleCallback(processStylesheet, { + timeout, + }); + return { + url, + status: 'capturing', + timeout, + }; + } + + setTimeout(processStylesheet, 0); + return { + url, + status: 'capturing', + timeout: 100, + }; + } + + public capture( + asset: asset, + snapshotTimestamp?: number | true, + ): assetStatus | assetStatus[] { + if ('sheet' in asset.element) { + return this.captureStylesheet( + asset.value, + asset.element as HTMLStyleElement | HTMLLinkElement, + asset.styleId, + snapshotTimestamp, + ); + } + if (asset.attr === 'srcset') { + const statuses: assetStatus[] = []; + getSourcesFromSrcset(asset.value).forEach((url) => { + statuses.push(this.captureUrl(url, snapshotTimestamp)); + }); + return statuses; + } + return this.captureUrl(asset.value, snapshotTimestamp); + } + + private captureUrl( + url: string, + snapshotTimestamp?: number | true, + ): assetStatus { + const eventTimestamp = this.getEventTimestamp(snapshotTimestamp); + if (this.capturedURLs.has(url)) { + return { + url, + status: 'captured', + }; + } + if (this.capturingURLs.has(url)) { + return { + url, + status: 'capturing', + }; + } + if (this.failedURLs.has(url)) { + return { + url, + status: 'error', + }; + } + this.capturingURLs.add(url); + void this.getURLObject(url) + .then(async (object) => { + if (object && (object instanceof File || object instanceof Blob)) { + const arrayBuffer = await object.arrayBuffer(); + const base64 = encode(arrayBuffer); + + const payload: SerializedCanvasArg = { + rr_type: 'Blob', + type: object.type, + data: [ + { + rr_type: 'ArrayBuffer', + base64, + }, + ], + }; + + this.capturedURLs.add(url); + this.capturingURLs.delete(url); + + this.mutationCb( + { + url, + payload, + }, + eventTimestamp, + ); + } + }) + .catch(this.fetchCatcher(url, eventTimestamp)); + + return { + url, + status: 'capturing', + }; + } + + private getEventTimestamp(snapshotTimestamp?: number | true) { + return snapshotTimestamp === true + ? this.lastFullSnapshotTimestamp + : snapshotTimestamp; + } + + private fetchCatcher(url: string, snapshotTimestamp?: number) { + return (e: unknown) => { + let message = ''; + if (e instanceof Error) { + message = e.message; + } else if (typeof e === 'string') { + message = e; + } else if (e && typeof e === 'object' && 'toString' in e) { + message = (e as { toString(): string }).toString(); + } + this.mutationCb( + { + url, + failed: { + message, + }, + }, + snapshotTimestamp, + ); + + this.failedURLs.add(url); + this.capturingURLs.delete(url); + }; + } + + public shouldCapture( + n: Element, + attribute: string, + value: string, + config: captureAssetsParam, + ): boolean { + return shouldCaptureAsset(n, attribute, value, config); + } +} diff --git a/packages/rrweb/src/replay/asset-manager/index.ts b/packages/rrweb/src/replay/asset-manager/index.ts new file mode 100644 index 0000000000..41df77e59c --- /dev/null +++ b/packages/rrweb/src/replay/asset-manager/index.ts @@ -0,0 +1,357 @@ +import type { + RebuildAssetManagerFinalStatus, + RebuildAssetManagerInterface, + RebuildAssetManagerStatus, + assetEvent, + SerializedCssTextArg, + SerializedCanvasArg, + serializedElementNodeWithId, +} from '@rrweb/types'; +import { deserializeArg } from '../canvas/deserialize-args'; +import { + getSourcesFromSrcset, + adaptCssForReplay, + type BuildCache, +} from 'rrweb-snapshot'; +import type { RRElement } from 'rrdom'; +import { updateSrcset } from './update-srcset'; + +function buildStyleNode( + _n: serializedElementNodeWithId | HTMLStyleElement, + styleEl: HTMLStyleElement, + cssText: string, + options: { + hackCss: boolean; + cache: BuildCache; + }, +) { + const { hackCss, cache } = options; + while (styleEl.firstChild) { + styleEl.removeChild(styleEl.firstChild); + } + if (hackCss) { + cssText = adaptCssForReplay(cssText, cache); + } + styleEl.appendChild(styleEl.ownerDocument.createTextNode(cssText)); +} + +export default class AssetManager implements RebuildAssetManagerInterface { + private originalToObjectURLMap: Map> = new Map(); + private urlToStylesheetMap: Map> = new Map(); + private nodeIdAttributeHijackedMap: Map> = + new Map(); + private loadingURLs: Set = new Set(); + private failedURLs: Set = new Set(); + private callbackMap: Map< + string, + Array<(status: RebuildAssetManagerFinalStatus) => void> + > = new Map(); + private liveMode: boolean; + private cache: BuildCache; + public expectedAssets: Set | null = null; + public replayerApproxTs = 0; + + constructor({ liveMode, cache }: { liveMode: boolean; cache: BuildCache }) { + this.liveMode = liveMode; + this.cache = cache; + } + + public async add(event: assetEvent & { timestamp: number }) { + const { data } = event; + const { url, payload, failed } = { payload: false, failed: false, ...data }; + if (failed) { + this.failedURLs.add(url); + this.executeCallbacks(url, { status: 'failed' }); + return; + } + if (this.loadingURLs.has(url)) { + return; + } + this.loadingURLs.add(url); + if (this.expectedAssets !== null) { + this.expectedAssets.delete(url); + } + + // tracks if deserializing did anything, not really needed for AssetManager + const status = { + isUnchanged: true, + }; + + if (payload.rr_type === 'CssText') { + const cssPayload = payload as SerializedCssTextArg; + let assets = this.urlToStylesheetMap.get(url); + if (!assets) { + assets = new Map(); + this.urlToStylesheetMap.set(url, assets); + } + assets.set(event.timestamp, cssPayload.cssTexts); + this.loadingURLs.delete(url); + this.failedURLs.delete(url); + this.executeCallbacks(url, { + status: 'loaded', + url, + cssTexts: cssPayload.cssTexts, + }); + } else { + // TODO: extract the logic only needed for assets from deserializeArg + const result = (await deserializeArg( + new Map(), + null, + status, + )(payload as SerializedCanvasArg)) as Blob | MediaSource; + const objectURL = URL.createObjectURL(result); + let assets = this.originalToObjectURLMap.get(url); + if (!assets) { + assets = new Map(); + this.originalToObjectURLMap.set(url, assets); + } + assets.set(event.timestamp, objectURL); + this.loadingURLs.delete(url); + this.failedURLs.delete(url); + this.executeCallbacks(url, { status: 'loaded', url: objectURL }); + } + } + + private executeCallbacks( + url: string, + status: RebuildAssetManagerFinalStatus, + ) { + const callbacks = this.callbackMap.get(url); + while (callbacks && callbacks.length > 0) { + const callback = callbacks.pop(); + if (!callback) { + break; + } + callback(status); + } + } + + // TODO: turn this into a true promise that throws if the asset fails to load + public async whenReady(url: string): Promise { + const currentStatus = this.get(url); + if ( + currentStatus.status === 'loaded' || + currentStatus.status === 'failed' + ) { + return currentStatus; + } else if ( + currentStatus.status === 'unknown' && + this.expectedAssets !== null && + this.expectedAssets.size === 0 && + !this.liveMode + ) { + // we don't expect assets to arrive later + return { + status: 'failed', + }; + } + let resolve: (status: RebuildAssetManagerFinalStatus) => void; + const promise = new Promise((r) => { + resolve = r; + }); + if (!this.callbackMap.has(url)) { + this.callbackMap.set(url, []); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.callbackMap.get(url)!.push(resolve!); + + return promise; + } + + public get(url: string): RebuildAssetManagerStatus { + let tsResult: Map | Map | undefined; + tsResult = this.urlToStylesheetMap.get(url); + if (!tsResult) { + tsResult = this.originalToObjectURLMap.get(url); + } + if (tsResult) { + let result; + let bestTs: number | null = null; + // pick the asset with a timestamp closest to the current replayer value + // preferring ones that loaded after (assuming these are the ones that + // were triggered by the most recently played snapshot) + tsResult.forEach((value, ts) => { + if (bestTs === null) { + result = value; + bestTs = ts; + } else if (this.replayerApproxTs <= ts) { + if (bestTs < this.replayerApproxTs || ts < bestTs) { + result = value; + bestTs = ts; + } + } else if (bestTs < ts) { + result = value; + bestTs = ts; + } + }); + if (result === undefined) { + // satisfy typings + } else if (this.urlToStylesheetMap.has(url)) { + return { + status: 'loaded', + url, + cssTexts: result, + }; + } else { + return { + status: 'loaded', + url: result, + }; + } + } + + if (this.loadingURLs.has(url)) { + return { + status: 'loading', + }; + } + + if (this.failedURLs.has(url)) { + return { + status: 'failed', + }; + } + + return { + status: 'unknown', + }; + } + + public async manageAttribute( + node: RRElement | Element, + nodeId: number, + attribute: string, + serializedValue: string, + serializedNode?: serializedElementNodeWithId, + ): Promise { + const preloadedStatus = this.get(serializedValue); + + let isCssTextElement = false; + if (node.nodeName === 'STYLE') { + // includes s (these are recreated as + + +`, + { + captureAssets: { + origins: false, + objectURLs: false, + stylesheets: true, + processStylesheetsWithin: 0, + }, + }, + ); + + it('uses the full snapshot timestamp for synchronous stylesheet assets', async () => { + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const fullSnapshotEvent = events.find( + (e) => e.type === EventType.FullSnapshot, + ); + const assetEvent = events.find( + (e) => + e.type === EventType.Asset && + e.data.payload?.rr_type === 'CssText' && + e.data.payload.cssTexts.some((cssText) => + cssText.includes('.sync-capture'), + ), + ); + + expect(fullSnapshotEvent).toBeDefined(); + expect(assetEvent).toMatchObject({ + timestamp: fullSnapshotEvent?.timestamp, + }); + expect(assetEvent?.timestamp).not.toBe(0); + }); + }); + + describe('full snapshot asset failure timestamps', () => { + const ctx: ISuite = setup.call( + this, + ` + + + + + + + + + +`, + { + captureAssets: { + origins: true, + objectURLs: false, + }, + }, + ); + + it('uses the full snapshot timestamp for full snapshot asset failures', async () => { + await ctx.page.waitForTimeout(500); + await waitForRAF(ctx.page); + + const events = await ctx.page?.evaluate( + () => (window as unknown as IWindow).snapshots, + ); + const url = 'failprotocol://example.com/full-snapshot-image.png'; + const fullSnapshotEvent = events.find( + (e) => e.type === EventType.FullSnapshot, + ); + const failureEvent = events.find( + (e) => e.type === EventType.Asset && e.data.url === url, + ); + + expect(fullSnapshotEvent).toMatchObject({ + data: { + capturedAssetStatuses: expect.arrayContaining([ + { + url, + status: 'capturing', + }, + ]), + }, + }); + expect(failureEvent).toMatchObject({ + timestamp: fullSnapshotEvent?.timestamp, + data: { + url, + failed: { + message: 'Failed to fetch', + }, + }, + }); + }); + }); +}); diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-loading.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-loading.png new file mode 100644 index 0000000000..8c55d13d1f Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-loading.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-on-mutation-should-add-bogus-src-attribute-until-the-asset-is-loaded-so-chrome-doesnt-display-broken-image-icon-2-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-on-mutation-should-add-bogus-src-attribute-until-the-asset-is-loaded-so-chrome-doesnt-display-broken-image-icon-2-snap.png new file mode 100644 index 0000000000..6ffa781491 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-on-mutation-should-add-bogus-src-attribute-until-the-asset-is-loaded-so-chrome-doesnt-display-broken-image-icon-2-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png new file mode 100644 index 0000000000..845a9c87e4 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-emitted-later-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-streamed-later-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-streamed-later-1-snap.png new file mode 100644 index 0000000000..6ffa781491 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-incorporate-assets-streamed-later-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-fails-to-load-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-fails-to-load-1-snap.png new file mode 100644 index 0000000000..7e75027f1b Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-fails-to-load-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-never-gets-loaded-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-never-gets-loaded-1-snap.png new file mode 100644 index 0000000000..7e75027f1b Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-list-original-url-in-non-live-mode-when-asset-never-gets-loaded-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-correct-asset-when-assets-are-loading-while-src-is-changed-in-live-mode-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-correct-asset-when-assets-are-loading-while-src-is-changed-in-live-mode-1-snap.png new file mode 100644 index 0000000000..39cc14019e Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-correct-asset-when-assets-are-loading-while-src-is-changed-in-live-mode-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-red-square-in-non-live-mode-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-red-square-in-non-live-mode-1-snap.png new file mode 100644 index 0000000000..6ffa781491 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-red-square-in-non-live-mode-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-robot-in-non-live-mode-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-robot-in-non-live-mode-1-snap.png new file mode 100644 index 0000000000..39cc14019e Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-show-the-loaded-asset-robot-in-non-live-mode-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-support-urls-src-modified-via-incremental-mutation-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-support-urls-src-modified-via-incremental-mutation-1-snap.png new file mode 100644 index 0000000000..6ffa781491 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-support-urls-src-modified-via-incremental-mutation-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png new file mode 100644 index 0000000000..848d60f091 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-with-adding-src-attribute-until-the-asset-is-loaded-2-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-with-adding-src-attribute-until-the-asset-is-loaded-2-1-snap.png new file mode 100644 index 0000000000..987349ba09 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-with-adding-src-attribute-until-the-asset-is-loaded-2-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-should-correctly-rebuild-style-elements-within-the-body-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-should-correctly-rebuild-style-elements-within-the-body-1-snap.png new file mode 100644 index 0000000000..4ac0b41fa7 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-should-correctly-rebuild-style-elements-within-the-body-1-snap.png differ diff --git a/packages/rrweb/test/replay/asset-integration.test.ts b/packages/rrweb/test/replay/asset-integration.test.ts new file mode 100644 index 0000000000..6e530ad5fa --- /dev/null +++ b/packages/rrweb/test/replay/asset-integration.test.ts @@ -0,0 +1,348 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { launchPuppeteer, waitForRAF } from '../utils'; +import { toMatchImageSnapshot } from 'jest-image-snapshot'; +import type * as puppeteer from 'puppeteer'; +import events from './fixtures/assets'; +import mutationEvents from './fixtures/assets-mutation'; +import assetsChangedEvents from './fixtures/assets-src-changed-before-asset-loaded'; +import assetsBodyInlineStyleEvents from './fixtures/assets-body-inline-style'; +import { EventType, type assetEvent } from '@rrweb/types'; +import { vi } from 'vitest'; + +interface ISuite { + code: string; + browser: puppeteer.Browser; + page: puppeteer.Page; +} + +expect.extend({ toMatchImageSnapshot }); + +describe('replayer', function () { + vi.setConfig({ testTimeout: 10_000 }); + + let code: ISuite['code']; + let browser: ISuite['browser']; + let page: ISuite['page']; + + beforeAll(async () => { + browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs'); + 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)}`); + await page.evaluate( + `let mutationEvents = ${JSON.stringify(mutationEvents)}`, + ); + await page.evaluate( + `let assetsChangedEvents = ${JSON.stringify(assetsChangedEvents)}`, + ); + await page.evaluate( + `let assetsBodyInlineStyleEvents = ${JSON.stringify( + assetsBodyInlineStyleEvents, + )}`, + ); + + 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 () => { + // incorprates a red square populated from an image asset + // a navy background populated from a stylesheet asset + // and a left green border from a style element asset + await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events, { + }); + replayer.pause(0); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + + it('should incorporate assets streamed later', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(); + window.replayer.addEvent(events[0]); + const fullSnapshot = events[1]; + + // filtering: avoid the bit where we pause/wait for the css assets when building a full snapshot + fullSnapshot.data.capturedAssetStatuses = fullSnapshot.data.capturedAssetStatuses.filter(s => !s.url.includes('css') && !s.url.includes('style')); + window.replayer.addEvent(fullSnapshot); + `); + + await waitForRAF(page); + + await page.evaluate(` + window.replayer.addEvent(events[2]); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + + it('should wait for stylesheet assets to avoid fouc', async () => { + // fouc = flash of unstyled content + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(); + window.replayer.addEvent(events[0]); + window.replayer.addEvent(events[1]); + window.replayer.addEvent(events[2]); + `); + + await waitForRAF(page); + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); // should be blank white and not have image rendered yet + }); + + it('should support urls src modified via incremental mutation', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(mutationEvents[0].timestamp); + window.replayer.addEvent(mutationEvents[0]); + window.replayer.addEvent(mutationEvents[1]); + window.replayer.addEvent(mutationEvents[2]); + `); + + await waitForRAF(page); + + await page.evaluate(` + window.replayer.addEvent(mutationEvents[3]); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + + it("on mutation should add bogus src attribute until the asset is loaded so chrome doesn't display broken image icon", async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(mutationEvents[0].timestamp); + window.replayer.addEvent(mutationEvents[0]); + window.replayer.addEvent(mutationEvents[1]); + window.replayer.addEvent(mutationEvents[2]); + `); + + await waitForRAF(page); + + const loadingImage = await page.screenshot(); + expect(loadingImage).toMatchImageSnapshot({ + customSnapshotIdentifier: 'asset-integration-test-ts-loading', + failureThreshold: 0.02, + failureThresholdType: 'percent', + }); + + expect( + await page.evaluate( + `document.querySelector('iframe').contentDocument.querySelector('img').getAttribute('src')`, + ), + ).toBe('//:0'); + + await page.evaluate(` + window.replayer.addEvent(mutationEvents[3]); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + + it('should wait with adding src attribute until the asset is loaded 2', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(events[0].timestamp); + window.replayer.addEvent(events[0]); + const fullSnapshot = events[1]; + + // filtering: avoid the bit where we pause/wait for the css assets when building a full snapshot + fullSnapshot.data.capturedAssetStatuses = fullSnapshot.data.capturedAssetStatuses.filter(s => !s.url.includes('css') && !s.url.includes('style')); + window.replayer.addEvent(fullSnapshot); + `); + + await waitForRAF(page); + + expect( + await page.evaluate( + `document.querySelector('iframe').contentDocument.querySelector('img').getAttribute('src')`, + ), + ).toBe('//:0'); + + await page.evaluate(` + window.replayer.addEvent(events[2]); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + failureThreshold: 0.02, + failureThresholdType: 'percent', + }); + }); + + it('should show the correct asset when assets are loading while src is changed in live mode', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(assetsChangedEvents[0].timestamp); + window.replayer.addEvent(assetsChangedEvents[0]); + window.replayer.addEvent(assetsChangedEvents[1]); + window.replayer.addEvent(assetsChangedEvents[2]); + window.replayer.addEvent(assetsChangedEvents[3]); + window.replayer.addEvent(assetsChangedEvents[4]); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + failureThreshold: 0.04, + failureThresholdType: 'percent', + }); + }); + + it('should show the loaded asset (robot) in non-live mode', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer(assetsChangedEvents); + replayer.pause((assetsChangedEvents[2].timestamp - assetsChangedEvents[0].timestamp) + 1); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + failureThreshold: 0.04, + failureThresholdType: 'percent', + }); + }); + + it('should show the loaded asset (red square) in non-live mode', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer(assetsChangedEvents); + replayer.pause((assetsChangedEvents[1].timestamp - assetsChangedEvents[0].timestamp) + 1); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + }); + + it('should list original url in non-live mode when asset never gets loaded', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([assetsChangedEvents[0], assetsChangedEvents[1]]); + replayer.pause(assetsChangedEvents[1].timestamp); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + failureThreshold: 30, + }); + }); + + it('should list original url in non-live mode when asset fails to load', async () => { + const failedEvent: assetEvent & { timestamp: number } = { + type: EventType.Asset, + data: { + url: 'ftp://example.com/red.png', + failed: { + status: 404, + message: 'Not Found', + }, + }, + timestamp: assetsChangedEvents[2].timestamp, + }; + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([assetsChangedEvents[0], assetsChangedEvents[1], ${JSON.stringify( + failedEvent, + )}]); + replayer.pause(assetsChangedEvents[1].timestamp); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + failureThreshold: 30, + }); + + expect( + await page.evaluate( + `document.querySelector('iframe').contentDocument.querySelector('img').getAttribute('src')`, + ), + ).toMatchInlineSnapshot(`"ftp://example.com/red.png"`); + }); + }); + + it('should correctly rebuild style elements within the body', async () => { + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer(assetsBodyInlineStyleEvents.filter(e=>e.type!==7)); + replayer.pause((assetsBodyInlineStyleEvents[2].timestamp - assetsBodyInlineStyleEvents[0].timestamp) + 1); + // make asset events available after rebuild so preloadedStatus.status in asset manager is not 'loaded' + assetsBodyInlineStyleEvents.filter(e=>e.type===7).forEach(assetEvent=>replayer.addEvent(assetEvent)); + replayer.pause((assetsBodyInlineStyleEvents[assetsBodyInlineStyleEvents.length - 1].timestamp - assetsBodyInlineStyleEvents[0].timestamp) + 1); + `); + + await waitForRAF(page); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + failureThreshold: 0.02, + failureThresholdType: 'percent', + }); + }); +}); diff --git a/packages/rrweb/test/replay/asset-unit.test.ts b/packages/rrweb/test/replay/asset-unit.test.ts new file mode 100644 index 0000000000..5ae2a5764a --- /dev/null +++ b/packages/rrweb/test/replay/asset-unit.test.ts @@ -0,0 +1,454 @@ +/** + * @vitest-environment jsdom + */ + +import AssetManager from '../../src/replay/asset-manager'; +import { + EventType, + SerializedBlobArg, + SerializedCssTextArg, + assetEvent, + captureAssetsParam, +} from '@rrweb/types'; +import { createCache } from 'rrweb-snapshot'; +import { updateSrcset } from '../../src/replay/asset-manager/update-srcset'; +import { vi } from 'vitest'; + +describe('AssetManager', () => { + let assetManager: AssetManager; + let useURLPolyfill = false; + const examplePayload: SerializedBlobArg = { + rr_type: 'Blob', + type: 'image/png', + data: [ + { + rr_type: 'ArrayBuffer', + base64: 'fake-base64-abcd', + }, + ], + }; + + const exampleCssPayload: SerializedCssTextArg = { + rr_type: 'CssText', + cssTexts: ['body { background: red; }'], + }; + + beforeAll(() => { + // https://github.com/jsdom/jsdom/issues/1721 + if (typeof window.URL.createObjectURL === 'undefined') { + useURLPolyfill = true; + window.URL.createObjectURL = () => ''; + } + }); + + beforeEach(() => { + assetManager = new AssetManager({ liveMode: false, cache: createCache() }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + afterAll(() => { + if (useURLPolyfill) { + delete (window.URL as any).createObjectURL; + } + }); + + it('should add an asset to the manager', async () => { + const url = 'https://example.com/image.png'; + + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + const createObjectURLSpy = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValue('objectURL'); + + await assetManager.add(event); + + expect(createObjectURLSpy).toHaveBeenCalledWith(expect.any(Blob)); + expect(assetManager.get(url)).toEqual({ + status: 'loaded', + url: 'objectURL', + }); + }); + + it('should not add a failed asset to the manager', async () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { url, failed: { message: 'failed to load file' } }, + }; + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL'); + + await assetManager.add(event); + + expect(createObjectURLSpy).not.toHaveBeenCalled(); + expect(assetManager.get(url)).toEqual({ status: 'failed' }); + }); + + it('should return the correct status for a loading asset', () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + void assetManager.add(event); + + expect(assetManager.get(url)).toEqual({ status: 'loading' }); + }); + + it('should return the correct status for an unknown asset', () => { + const url = 'https://example.com/image.png'; + + expect(assetManager.get(url)).toEqual({ status: 'unknown' }); + }); + + it('should execute hook when an asset is added', async () => { + vi.useFakeTimers(); + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + void assetManager.add(event); + const promise = assetManager.whenReady(url); + + vi.spyOn(URL, 'createObjectURL').mockReturnValue('objectURL'); + + vi.runAllTimers(); + + await expect(promise).resolves.toEqual({ + status: 'loaded', + url: 'objectURL', + }); + }); + + it("should be able to modify a node's attribute once asset is loaded", async () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('objectURL'); + + const element = document.createElement('img'); + + const promise = assetManager.manageAttribute(element, 1, 'src', url); + + await assetManager.add(event); + await promise; + + expect(element.getAttribute('src')).toBe('objectURL'); + }); + + it("should be able to modify a node's attribute for previously loaded assets", async () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('objectURL'); + await assetManager.add(event); + + const element = document.createElement('img'); + + await assetManager.manageAttribute(element, 1, 'src', url); + + expect(element.getAttribute('src')).toBe('objectURL'); + }); + + it('should be support srcset for previously loaded assets', async () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('objectURL'); + await assetManager.add(event); + + const element = document.createElement('img'); + + await assetManager.manageAttribute(element, 1, 'srcset', url); + + expect(element.getAttribute('srcset')).toBe('objectURL'); + }); + + it('should be support partial srcset updates for previously loaded assets', async () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('objectURL'); + await assetManager.add(event); + + const element = document.createElement('img'); + const value = `${url} x2, ${url}?x3 x3`; + + void assetManager.manageAttribute(element, 1, 'srcset', value); + await assetManager.whenReady(url); + + expect(element.getAttribute('srcset')).toBe(`objectURL x2, ${url}?x3 x3`); + }); + + it('should support updating srcset in chunks for every time an asset is loaded', async () => { + const url = 'https://example.com/image.png'; + const url2 = `${url}?x3`; + const element = document.createElement('img'); + + vi.spyOn(URL, 'createObjectURL') + .mockReturnValueOnce('objectURL1') + .mockReturnValueOnce('objectURL2'); + await assetManager.add({ + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }); + + void assetManager.manageAttribute( + element, + 1, + 'srcset', + `${url} x2, ${url2} x3`, + ); + await assetManager.whenReady(url); + + expect(element.getAttribute('srcset')).toBe(`objectURL1 x2, ${url2} x3`); + + await assetManager.add({ + type: EventType.Asset, + data: { + url: url2, + payload: examplePayload, + }, + }); + + await assetManager.whenReady(url2); + + expect(element.getAttribute('srcset')).toBe(`objectURL1 x2, objectURL2 x3`); + }); + + it('should support svg elements', async () => { + const url = 'https://example.com/image.png'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: examplePayload, + }, + }; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('objectURL'); + await assetManager.add(event); + + // create svg element `feImage` + const feImage = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'feImage', + ); + + await assetManager.manageAttribute(feImage, 1, 'href', url); + + expect(feImage.getAttribute('href')).toBe('objectURL'); + }); + + describe('live mode', () => { + beforeEach(() => { + assetManager = new AssetManager({ liveMode: true, cache: createCache() }); + }); + + it("should remove a node's attribute while asset is being loaded", async () => { + const url = 'https://example.com/image.png'; + const element = document.createElement('embed'); + + void assetManager.manageAttribute(element, 1, 'src', url); + + expect(element.getAttribute('src')).toBeNull(); + }); + + it("should set an image's src attribute to //:0 to prevent a broken image icon while asset is being loaded", async () => { + const url = 'https://example.com/image.png'; + const element = document.createElement('img'); + + void assetManager.manageAttribute(element, 1, 'src', url); + + expect(element.getAttribute('src')).toBe('//:0'); + }); + + it("should be able to modify a node's attribute multiple times (assets arrive in reverse order)", async () => { + const originalUrl = 'https://example.com/original-image.png'; + const newUrl = 'https://example.com/new-image.png'; + const originalAsset: assetEvent = { + type: EventType.Asset, + data: { + url: originalUrl, + payload: examplePayload, + }, + }; + const newAsset: assetEvent = { + type: EventType.Asset, + data: { + url: newUrl, + payload: examplePayload, + }, + }; + let i = 0; + vi.spyOn(URL, 'createObjectURL').mockImplementation( + () => `objectURL${(i += 1)}`, + ); + const promises: Promise[] = []; + + const element = document.createElement('img'); + promises.push( + assetManager.manageAttribute(element, 1, 'src', originalUrl), + ); + + promises.push(assetManager.manageAttribute(element, 1, 'src', newUrl)); + + await assetManager.add(newAsset); + await assetManager.add(originalAsset); + + await Promise.all(promises); + expect(element.getAttribute('src')).toBe('objectURL1'); + }); + + it("should be able to modify a node's attribute multiple times (assets arrive in correct order)", async () => { + const originalUrl = 'https://example.com/original-image.png'; + const newUrl = 'https://example.com/new-image.png'; + const originalAsset: assetEvent = { + type: EventType.Asset, + data: { + url: originalUrl, + payload: examplePayload, + }, + }; + const newAsset: assetEvent = { + type: EventType.Asset, + data: { + url: newUrl, + payload: examplePayload, + }, + }; + let i = 0; + vi.spyOn(URL, 'createObjectURL').mockImplementation( + () => `objectURL${(i += 1)}`, + ); + const promises: Promise[] = []; + + const element = document.createElement('img'); + promises.push( + assetManager.manageAttribute(element, 1, 'src', originalUrl), + ); + + promises.push(assetManager.manageAttribute(element, 1, 'src', newUrl)); + + await assetManager.add(originalAsset); + await assetManager.add(newAsset); + + await Promise.all(promises); + expect(element.getAttribute('src')).toBe('objectURL2'); + }); + }); + + describe('updateSrcset()', () => { + it('should update srcset attribute', () => { + const element = document.createElement('img'); + element.setAttribute( + 'srcset', + 'https://example.com/image.png x2, https://example.com/image2.png x3', + ); + const oldURL = 'https://example.com/image.png'; + const newURL = 'https://other-url.com/image.png'; + updateSrcset(element, oldURL, newURL); + expect(element.getAttribute('srcset')).toBe( + 'https://other-url.com/image.png x2, https://example.com/image2.png x3', + ); + }); + + it('should update singular srcset attribute', () => { + const element = document.createElement('img'); + element.setAttribute('srcset', 'https://example.com/image.png'); + const oldURL = 'https://example.com/image.png'; + const newURL = 'https://other-url.com/image.png'; + updateSrcset(element, oldURL, newURL); + expect(element.getAttribute('srcset')).toBe( + 'https://other-url.com/image.png', + ); + }); + + it('should update srcset attribute with similar urls', () => { + const element = document.createElement('img'); + element.setAttribute( + 'srcset', + 'https://example.com/image.png x2, https://example.com/image.png?x=3 x3', + ); + const oldURL = 'https://example.com/image.png'; + const newURL = 'https://other-url.com/image.png'; + updateSrcset(element, oldURL, newURL); + expect(element.getAttribute('srcset')).toBe( + 'https://other-url.com/image.png x2, https://example.com/image.png?x=3 x3', + ); + }); + + it('should update srcset attribute with similar urls - second url', () => { + const element = document.createElement('img'); + element.setAttribute( + 'srcset', + 'https://example.com/image.png?x=2 x2, https://example.com/image.png x3', + ); + const oldURL = 'https://example.com/image.png'; + const newURL = 'https://other-url.com/image.png'; + updateSrcset(element, oldURL, newURL); + expect(element.getAttribute('srcset')).toBe( + 'https://example.com/image.png?x=2 x2, https://other-url.com/image.png x3', + ); + }); + }); + + describe('stylesheets', () => { + it('should rebuild stylesheets from assets', () => { + const url = 'https://example.com/index.css'; + const event: assetEvent = { + type: EventType.Asset, + data: { + url, + payload: exampleCssPayload, + }, + }; + void assetManager.add(event); + + // no need for deserializeArg so should be loaded immediately + expect(assetManager.get(url)).toEqual({ + cssTexts: ['body { background: red; }'], + status: 'loaded', + url, + }); + }); + }); +}); diff --git a/packages/rrweb/test/replay/fixtures/assets-body-inline-style.ts b/packages/rrweb/test/replay/fixtures/assets-body-inline-style.ts new file mode 100644 index 0000000000..e232ccb30c --- /dev/null +++ b/packages/rrweb/test/replay/fixtures/assets-body-inline-style.ts @@ -0,0 +1,117 @@ +import { EventType, type eventWithTime } from '@rrweb/types'; + +const events: eventWithTime[] = [ + { + type: EventType.Meta, + data: { + href: '', + width: 90, + height: 90, + }, + timestamp: 123, + }, + { + type: EventType.FullSnapshot, + 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: [], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + tagName: 'style', + attributes: { + rr_css_text: 'https://example.com/#rr_style_el:1', + }, + childNodes: [ + { + type: 3, + textContent: '', + id: 6, + }, + ], + id: 5, + type: 2, + }, + { + tagName: 'div', + attributes: { + class: 'back-btn__wrapper', + }, + childNodes: [ + { + type: 3, + textContent: '\n ', + id: 9, + }, + { + tagName: 'a', + attributes: { + class: 'back-btn', + href: 'https://example.com/#back', + }, + childNodes: [ + { + type: 3, + textContent: 'Back', + id: 11, + }, + ], + id: 10, + type: 2, + }, + ], + id: 8, + type: 2, + }, + ], + id: 7, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + capturedAssetStatuses: [ + { + url: 'https://example.com/#rr_style_el:1', + status: 'captured', + }, + ], + }, + timestamp: 125, + }, + { + type: EventType.Asset, + data: { + url: 'https://example.com/#rr_style_el:1', + payload: { + rr_type: 'CssText', + cssTexts: [ + '.back-btn { background: rgb(0, 255, 0); padding: 10px 7px 4px 6px; }}', + ], + }, + }, + timestamp: 127, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replay/fixtures/assets-mutation.ts b/packages/rrweb/test/replay/fixtures/assets-mutation.ts new file mode 100644 index 0000000000..db9eabafee --- /dev/null +++ b/packages/rrweb/test/replay/fixtures/assets-mutation.ts @@ -0,0 +1,141 @@ +import { EventType, IncrementalSource, 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', + }, + 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.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [ + { + id: 16, + attributes: { + rr_captured_src: 'ftp://example.com/image.png', + }, + }, + ], + removes: [], + adds: [], + }, + timestamp: 1636379531390, + }, + { + type: EventType.Asset, + data: { + url: 'ftp://example.com/image.png', + payload: { + rr_type: 'Blob', + type: 'image/png', + data: [ + { + rr_type: 'ArrayBuffer', + base64: + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAWtJREFUeF7t1cEJAEAIxEDtv2gProo8xgpCwuLezI3LGFhBMi0+iCCtHoLEeggiSM1AjMcPESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4ViIIDEDMRwLESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4TwVjsedWCiXGAAAAABJRU5ErkJggg==', // base64 + }, + ], + }, + }, + timestamp: 1636379531391, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replay/fixtures/assets-src-changed-before-asset-loaded.ts b/packages/rrweb/test/replay/fixtures/assets-src-changed-before-asset-loaded.ts new file mode 100644 index 0000000000..f4e7c90796 --- /dev/null +++ b/packages/rrweb/test/replay/fixtures/assets-src-changed-before-asset-loaded.ts @@ -0,0 +1,164 @@ +import { EventType, IncrementalSource, type eventWithTime } from '@rrweb/types'; +import { readFileSync } from 'fs'; + +const events: eventWithTime[] = [ + { + type: 4, + data: { + href: '', + width: 1600, + height: 900, + }, + timestamp: 100000000, + }, + { + 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', + rr_captured_src: 'ftp://example.com/red.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: 100000010, + }, + // 2 change to robot.png + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [ + { + id: 16, + attributes: { + rr_captured_src: 'ftp://example.com/robot.png', + }, + }, + ], + removes: [], + adds: [], + }, + timestamp: 100000020, + }, + // 3 + { + type: EventType.Asset, + data: { + url: 'ftp://example.com/red.png', + payload: { + rr_type: 'Blob', + type: 'image/png', + data: [ + { + rr_type: 'ArrayBuffer', + base64: + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAWtJREFUeF7t1cEJAEAIxEDtv2gProo8xgpCwuLezI3LGFhBMi0+iCCtHoLEeggiSM1AjMcPESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4ViIIDEDMRwLESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4TwVjsedWCiXGAAAAABJRU5ErkJggg==', // base64 + }, + ], + }, + }, + timestamp: 100000030, + }, + { + type: EventType.Asset, + data: { + url: 'ftp://example.com/robot.png', + payload: { + rr_type: 'Blob', + type: 'image/png', + data: [ + { + rr_type: 'ArrayBuffer', + base64: readFileSync('test/html/assets/robot.png').toString( + 'base64', + ), + }, + ], + }, + }, + timestamp: 100000040, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replay/fixtures/assets.ts b/packages/rrweb/test/replay/fixtures/assets.ts new file mode 100644 index 0000000000..f47588246b --- /dev/null +++ b/packages/rrweb/test/replay/fixtures/assets.ts @@ -0,0 +1,183 @@ +import { EventType, type eventWithTime } from '@rrweb/types'; + +const events: eventWithTime[] = [ + { + type: EventType.Meta, + data: { + href: '', + width: 1600, + height: 900, + }, + timestamp: 1636379531385, + }, + { + type: EventType.FullSnapshot, + 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 }, + { + type: 2, + tagName: 'link', + attributes: { + rel: 'stylesheet', + rr_captured_href: 'https://example.com/style.css', + }, + childNodes: [], + id: 22, + }, + { type: 3, textContent: '\n ', id: 23 }, + { + type: 2, + tagName: 'style', + attributes: { + rr_css_text: '#rr_style_el:1', + }, + childNodes: [], + id: 24, + }, + { type: 3, textContent: '\n ', id: 25 }, + ], + 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', + rr_captured_src: 'ftp://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 }, + capturedAssetStatuses: [ + { + url: 'ftp://example.com/image.png', + status: 'capturing', + }, + { + url: 'https://example.com/style.css', + status: 'capturing', + timeout: 50, + }, + { + url: '#rr_style_el:1', + status: 'capturing', + timeout: 50, + }, + ], + }, + timestamp: 1636379531389, + }, + { + type: EventType.Asset, + data: { + url: 'ftp://example.com/image.png', + payload: { + rr_type: 'Blob', + type: 'image/png', + data: [ + { + rr_type: 'ArrayBuffer', + base64: + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAWtJREFUeF7t1cEJAEAIxEDtv2gProo8xgpCwuLezI3LGFhBMi0+iCCtHoLEeggiSM1AjMcPESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4ViIIDEDMRwLESRmIIZjIYLEDMRwLESQmIEYjoUIEjMQw7EQQWIGYjgWIkjMQAzHQgSJGYjhWIggMQMxHAsRJGYghmMhgsQMxHAsRJCYgRiOhQgSMxDDsRBBYgZiOBYiSMxADMdCBIkZiOFYiCAxAzEcCxEkZiCGYyGCxAzEcCxEkJiBGI6FCBIzEMOxEEFiBmI4FiJIzEAMx0IEiRmI4TwVjsedWCiXGAAAAABJRU5ErkJggg==', // base64 + }, + ], + }, + }, + timestamp: 1636379532355, + }, + { + type: EventType.Asset, + data: { + url: 'https://example.com/style.css', + payload: { + rr_type: 'CssText', + cssTexts: ['body { background-color: indigo; }'], + }, + }, + timestamp: 1636379532355, + }, + { + type: EventType.Asset, + data: { + url: '#rr_style_el:1', + payload: { + rr_type: 'CssText', + cssTexts: ['body { margin: 0; border-top: 10px solid darkgreen; }'], + }, + }, + timestamp: 1636379532355, + }, +]; + +export default events; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9ed8a7344e..69e92743f8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -19,6 +19,12 @@ export type loadedEvent = { data: unknown; }; +export type assetStatus = { + url: string; + status: 'capturing' | 'captured' | 'media-mismatch' | 'error' | 'refused'; + timeout?: number; +}; + export type fullSnapshotEvent = { type: EventType.FullSnapshot; data: { @@ -27,9 +33,21 @@ export type fullSnapshotEvent = { top: number; left: number; }; + /* + * the assets associated with this snapshot + * info is used to delay first FullSnapshot render until e.g. stylesheet + * assets have been received by the replayer + * could also be useful for server-side processing of the event stream + * without having to delve into the structure of this full snapshot + */ + capturedAssetStatuses?: assetStatus[]; }; }; +export type fullSnapshotEventWithTime = fullSnapshotEvent & { + timestamp: number; +}; + export type incrementalSnapshotEvent = { type: EventType.IncrementalSnapshot; data: incrementalData; @@ -60,6 +78,54 @@ export type pluginEvent = { }; }; +export type captureAssetsParam = Partial<{ + /** + * Captures object URLs (blobs, files, media sources). + * More info: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL + */ + objectURLs: boolean; + /** + * Allowlist of origins to capture object URLs from. + * [origin, origin, ...] to capture from specific origins. + * e.g. ['https://example.com', 'https://www.example.com'] + * Set to `true` to capture from all origins. + * Set to `false` or `[]` to disable capturing from any origin (apart from object URLs or when inlineStylesheet=='all') + */ + origins: string[] | true | false; + /** + * capture images irrespective of origin (populated from inlineImages setting) + */ + images: boolean; + /** + * capture videos irrespective of origin + */ + video: boolean; + /** + * capture audio irrespective of origin + */ + audio: boolean; + /** + * capture stylesheets irrespective of origin (populated from inlineStylesheets setting) + */ + stylesheets: boolean | 'without-fetch'; + /* + * in milliseconds, default 2000 + * stylesheets are captured as assets in order to take their processing off the main thread + * this number may need to be reduced to ensure that stylesheet assets are emitted + * in time + */ + processStylesheetsWithin: number; + /* + * if set, process stylesheets with less than this number of css rules immediately/synchronously, + * and include directly in the snapshot without a separate asset event + */ + stylesheetsRuleThreshold: number; + /** + * In a mutation context, we are already deferred, so performance related capturing can happen immediately (without a separate asset event) + */ + _fromMutation: true; +}>; + export type assetEvent = { type: EventType.Asset; data: assetParam; @@ -69,6 +135,13 @@ export type assetEventWithTime = assetEvent & { timestamp: number; }; +export type asset = { + element: HTMLElement; + attr: string; + value: string; + styleId?: number; +}; + export enum IncrementalSource { Mutation, MouseMove, @@ -739,6 +812,35 @@ export type TakeTypedKeyValues = Pick< TakeTypeHelper[keyof TakeTypeHelper] >; +export type RebuildAssetManagerUnknownStatus = { status: 'unknown' }; +export type RebuildAssetManagerLoadingStatus = { status: 'loading' }; +export type RebuildAssetManagerLoadedStatus = { + status: 'loaded'; + url: string; + cssTexts?: string[]; +}; +export type RebuildAssetManagerFailedStatus = { status: 'failed' }; +export type RebuildAssetManagerFinalStatus = + | RebuildAssetManagerLoadedStatus + | RebuildAssetManagerFailedStatus; +export type RebuildAssetManagerStatus = + | RebuildAssetManagerUnknownStatus + | RebuildAssetManagerLoadingStatus + | RebuildAssetManagerFinalStatus; + +export interface RebuildAssetManagerInterface { + add(event: assetEvent): Promise; + get(url: string): RebuildAssetManagerStatus; + whenReady(url: string): Promise; + manageAttribute( + n: Element, + id: number, + attribute: string, + originalValue: string, + serializedNode?: serializedElementNodeWithId, + ): void; +} + export enum NodeType { Document, DocumentType, diff --git a/tsconfig.json b/tsconfig.json index e738720dda..77fbed3548 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,9 @@ { "path": "packages/rrweb-snapshot" }, + { + "path": "packages/browser-client" + }, { "path": "packages/types" }, diff --git a/turbo.json b/turbo.json index c6dc3417ff..baba2e93c2 100644 --- a/turbo.json +++ b/turbo.json @@ -7,6 +7,7 @@ "vite.config.default.ts", "tsconfig.json" ], + "globalEnv": ["RRWEB_COMMIT_HASH", "GITHUB_SHA"], "globalPassThroughEnv": [ "PUPPETEER_EXECUTABLE_PATH", "PUPPETEER_HEADLESS", @@ -15,6 +16,7 @@ "tasks": { "prepublish": { "dependsOn": ["^prepublish", "//#references:update"], + "env": ["RRWEB_COMMIT_HASH", "GITHUB_SHA"], "outputs": [ "lib/**", "es/**",