diff --git a/docs/recipes/canvas.md b/docs/recipes/canvas.md index 8e5c75220c..934461ee12 100644 --- a/docs/recipes/canvas.md +++ b/docs/recipes/canvas.md @@ -11,6 +11,18 @@ rrweb.record({ }); ``` +Alternatively enable image snapshot recording of Canvas at a maximum of 15 frames per second: + +```js +rrweb.record({ + emit(event) {}, + recordCanvas: true, + sampling: { + canvas: 15, + }, +}); +``` + Enable replaying Canvas: ```js diff --git a/docs/recipes/canvas.zh_CN.md b/docs/recipes/canvas.zh_CN.md index c986e0a207..59ba507205 100644 --- a/docs/recipes/canvas.zh_CN.md +++ b/docs/recipes/canvas.zh_CN.md @@ -12,6 +12,18 @@ rrweb.record({ }); ``` +或者启用每秒 15 帧的 Canvas 图像快照记录: + +```js +rrweb.record({ + emit(event) {}, + recordCanvas: true, + sampling: { + canvas: 15, + }, +}); +``` + 回放时对 Canvas 进行回放: ```js diff --git a/docs/recipes/optimize-storage.md b/docs/recipes/optimize-storage.md index f93fe772c4..cd5152593a 100644 --- a/docs/recipes/optimize-storage.md +++ b/docs/recipes/optimize-storage.md @@ -17,6 +17,7 @@ Some common patterns may emit lots of events are: - long list - complex SVG - element with JS controlled animation +- canvas animations ## Sampling diff --git a/docs/recipes/optimize-storage.zh_CN.md b/docs/recipes/optimize-storage.zh_CN.md index f878ac810f..c180d57ad5 100644 --- a/docs/recipes/optimize-storage.zh_CN.md +++ b/docs/recipes/optimize-storage.zh_CN.md @@ -17,6 +17,7 @@ - 长列表 - 复杂的 SVG - 包含 JS 控制动画的元素 +- canvas 动画 ## 抽样策略 diff --git a/guide.md b/guide.md index b95b4c2da3..3b2e058df1 100644 --- a/guide.md +++ b/guide.md @@ -135,30 +135,30 @@ setInterval(save, 10 * 1000); The parameter of `rrweb.record` accepts the following options. -| key | default | description | -| -------------------- | ------------------ | ------------------------------------------------------------ | -| emit | required | the callback function to get emitted events | -| checkoutEveryNth | - | take a full snapshot after every N events
refer to the [checkout](#checkout) chapter | -| checkoutEveryNms | - | take a full snapshot after every N ms
refer to the [checkout](#checkout) chapter | -| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter | -| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter | -| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter | -| 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) | -| inlineStylesheet | true | whether to inline the stylesheet in the events | -| hooks | {} | hooks for events
refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | -| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | -| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | -| recordCanvas | false | whether to record the canvas element | -| inlineImages | false | whether to record the image content | -| collectFonts | false | whether to collect fonts in the website | +| 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 | +| 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) | +| inlineStylesheet | true | whether to inline the stylesheet in the events | +| hooks | {} | hooks for events
refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | +| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | +| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | +| recordCanvas | false | Whether to record the canvas element. Available options:
`false`,
`true` | +| inlineImages | false | whether to record the image content | +| collectFonts | false | whether to collect fonts in the website | | userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | -| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) | +| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) | #### Privacy @@ -286,23 +286,23 @@ replayer.pause(5000); The replayer accepts options as its constructor's second parameter, and it has the following options: -| key | default | description | -| ------------------- | ------------- | ------------------------------------------------------------ | -| speed | 1 | replay speed ratio | -| root | document.body | the root element of replayer | -| loadTimeout | 0 | timeout of loading remote style sheet | -| skipInactive | false | whether to skip inactive time | -| showWarning | true | whether to print warning messages during replay | -| showDebug | false | whether to print debug messages during replay | -| blockClass | 'rr-block' | element with the class name will display as a blocked area | -| liveMode | false | whether to enable live mode | -| insertStyleRules | [] | accepts multiple CSS rule string, which will be injected into the replay iframe | -| triggerFocus | true | whether to trigger focus during replay | -| UNSAFE_replayCanvas | false | whether to replay the canvas element. **Enable this will remove the sandbox, which is unsafe.** | +| key | default | description | +| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| speed | 1 | replay speed ratio | +| root | document.body | the root element of replayer | +| loadTimeout | 0 | timeout of loading remote style sheet | +| skipInactive | false | whether to skip inactive time | +| showWarning | true | whether to print warning messages during replay | +| showDebug | false | whether to print debug messages during replay | +| blockClass | 'rr-block' | element with the class name will display as a blocked area | +| liveMode | false | whether to enable live mode | +| insertStyleRules | [] | accepts multiple CSS rule string, which will be injected into the replay iframe | +| triggerFocus | true | whether to trigger focus during replay | +| UNSAFE_replayCanvas | false | whether to replay the canvas element. **Enable this will remove the sandbox, which is unsafe.** | | mouseTail | true | whether to show mouse tail during replay. Set to false to disable mouse tail. A complete config can be found in this [type](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L407) | -| unpackFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | -| logConfig | - | configuration of console output playback, refer to the [console recipe](./docs/recipes/console.md) | -| plugins | [] | load plugins to provide extended replay functions. [What is plugins?](./docs/recipes/plugin.md) | +| unpackFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | +| logConfig | - | configuration of console output playback, refer to the [console recipe](./docs/recipes/console.md) | +| plugins | [] | load plugins to provide extended replay functions. [What is plugins?](./docs/recipes/plugin.md) | #### Use rrweb-player diff --git a/guide.zh_CN.md b/guide.zh_CN.md index bdb0a93305..a987934518 100644 --- a/guide.zh_CN.md +++ b/guide.zh_CN.md @@ -150,7 +150,7 @@ setInterval(save, 10 * 1000); | 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) | -| recordCanvas | false | 是否记录 canvas 内容 | +| recordCanvas | false | 是否记录 canvas 内容, 可用选项:false, true | | inlineImages | false | 是否将图片内容记内联录制 | | collectFonts | false | 是否记录页面中的字体文件 | | userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) | diff --git a/packages/rrweb-player/package.json b/packages/rrweb-player/package.json index ec09c21361..55a830e42f 100644 --- a/packages/rrweb-player/package.json +++ b/packages/rrweb-player/package.json @@ -2,7 +2,7 @@ "name": "rrweb-player", "version": "0.7.14", "devDependencies": { - "@rollup/plugin-commonjs": "^11.0.0", + "@rollup/plugin-commonjs": "^21.0.2", "@rollup/plugin-node-resolve": "^7.0.0", "@rollup/plugin-typescript": "^4.0.0", "@typescript-eslint/eslint-plugin": "^3.7.0", diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index 145dd1b6f9..18b32fe1b4 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -41,14 +41,14 @@ }, "homepage": "https://github.com/rrweb-io/rrweb#readme", "devDependencies": { - "@rollup/plugin-node-resolve": "^7.0.0", - "@rollup/plugin-typescript": "^8.3.1", + "@rollup/plugin-node-resolve": "^13.1.3", "@types/chai": "^4.1.6", "@types/inquirer": "0.0.43", "@types/jest": "^27.4.1", "@types/jest-image-snapshot": "^4.3.1", "@types/jsdom": "^16.2.14", "@types/node": "^17.0.21", + "@types/offscreencanvas": "^2019.6.4", "@types/prettier": "^2.3.2", "@types/puppeteer": "^5.4.4", "cross-env": "^5.2.0", @@ -63,10 +63,12 @@ "jsdom-global": "^3.0.2", "prettier": "2.2.1", "puppeteer": "^9.1.1", - "rollup": "^2.45.2", + "rollup": "^2.68.0", "rollup-plugin-postcss": "^3.1.1", - "rollup-plugin-rename-node-modules": "^1.1.0", + "rollup-plugin-rename-node-modules": "^1.3.1", "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.31.2", + "rollup-plugin-web-worker-loader": "^1.6.1", "ts-jest": "^27.1.3", "ts-node": "^10.7.0", "tslib": "^2.3.1", diff --git a/packages/rrweb/rollup.config.js b/packages/rrweb/rollup.config.js index 8d6a3c6633..3c2eff47a1 100644 --- a/packages/rrweb/rollup.config.js +++ b/packages/rrweb/rollup.config.js @@ -1,8 +1,9 @@ -import typescript from '@rollup/plugin-typescript'; +import typescript from 'rollup-plugin-typescript2'; import resolve from '@rollup/plugin-node-resolve'; import { terser } from 'rollup-plugin-terser'; import postcss from 'rollup-plugin-postcss'; import renameNodeModules from 'rollup-plugin-rename-node-modules'; +import webWorkerLoader from 'rollup-plugin-web-worker-loader'; import pkg from './package.json'; function toRecordPath(path) { @@ -45,6 +46,13 @@ function toMinPath(path) { } const baseConfigs = [ + // all in one + { + input: './src/entries/all.ts', + name: 'rrweb', + pathFn: toAllPath, + esm: true, + }, // record only { input: './src/record/index.ts', @@ -75,13 +83,6 @@ const baseConfigs = [ name: 'rrweb', pathFn: (p) => p, }, - // all in one - { - input: './src/entries/all.ts', - name: 'rrweb', - pathFn: toAllPath, - esm: true, - }, // plugins { input: './src/plugins/console/record/index.ts', @@ -110,10 +111,8 @@ let configs = []; for (const c of baseConfigs) { const basePlugins = [ resolve({ browser: true }), - typescript({ - // a trick to avoid @rollup/plugin-typescript error - outDir: 'es/rrweb', - }), + webWorkerLoader(), + typescript(), ]; const plugins = basePlugins.concat( postcss({ @@ -200,6 +199,7 @@ if (process.env.BROWSER_ONLY) { for (const c of browserOnlyBaseConfigs) { const plugins = [ resolve({ browser: true }), + webWorkerLoader(), typescript({ outDir: null, }), diff --git a/packages/rrweb/scripts/repl.js b/packages/rrweb/scripts/repl.js index e4ad49210b..839b8d5ca6 100644 --- a/packages/rrweb/scripts/repl.js +++ b/packages/rrweb/scripts/repl.js @@ -169,7 +169,9 @@ function getCode() { }); await page.evaluate(`${code} const events = ${JSON.stringify(events)}; - const replayer = new rrweb.Replayer(events); + const replayer = new rrweb.Replayer(events, { + UNSAFE_replayCanvas: true + }); replayer.play(); `); } diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 3b6d910ceb..3240e2f42a 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -221,6 +221,7 @@ function record( win: window, blockClass, mirror, + sampling: sampling.canvas, }); const shadowDomManager = new ShadowDomManager({ diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index 6e7049af82..86cf0e396a 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -7,6 +7,7 @@ import { listenerHandler, } from '../../../types'; import { hookSetter, isBlocked, patch } from '../../../utils'; +import { serializeArgs } from './serialize-args'; export default function initCanvas2DMutationObserver( cb: canvasManagerMutationCallback, @@ -36,27 +37,10 @@ export default function initCanvas2DMutationObserver( ...args: Array ) { if (!isBlocked(this.canvas, blockClass)) { - // Using setTimeout as getImageData + JSON.stringify can be heavy + // Using setTimeout as toDataURL can be heavy // and we'd rather not block the main thread setTimeout(() => { - const recordArgs = [...args]; - if (prop === 'drawImage') { - if ( - recordArgs[0] && - recordArgs[0] instanceof HTMLCanvasElement - ) { - const canvas = recordArgs[0]; - const ctx = canvas.getContext('2d'); - let imgd = ctx?.getImageData( - 0, - 0, - canvas.width, - canvas.height, - ); - let pix = imgd?.data; - recordArgs[0] = JSON.stringify(pix); - } - } + const recordArgs = serializeArgs([...args], win, this); cb(this.canvas, { type: CanvasContext['2D'], property: prop, diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index 007419b37d..79859dbbc8 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -1,16 +1,20 @@ -import { Mirror } from 'rrweb-snapshot'; +import { ICanvas, Mirror } from 'rrweb-snapshot'; import { blockClass, + CanvasContext, canvasManagerMutationCallback, canvasMutationCallback, canvasMutationCommand, canvasMutationWithType, IWindow, listenerHandler, + CanvasArg, } from '../../../types'; import initCanvas2DMutationObserver from './2d'; import initCanvasContextObserver from './canvas'; import initCanvasWebGLMutationObserver from './webgl'; +import ImageBitmapDataURLWorker from 'web-worker:../../workers/image-bitmap-data-url-worker.ts'; +import { ImageBitmapDataURLRequestWorker } from '../../workers/image-bitmap-data-url-worker'; export type RafStamps = { latestId: number; invokeId: number | null }; @@ -51,17 +55,21 @@ export class CanvasManager { } constructor(options: { - recordCanvas: boolean | number; + recordCanvas: boolean; mutationCb: canvasMutationCallback; win: IWindow; blockClass: blockClass; mirror: Mirror; + sampling?: 'all' | number; }) { + const { sampling = 'all', win, blockClass, recordCanvas } = options; this.mutationCb = options.mutationCb; this.mirror = options.mirror; - if (options.recordCanvas === true) - this.initCanvasMutationObserver(options.win, options.blockClass); + if (recordCanvas && sampling === 'all') + this.initCanvasMutationObserver(win, blockClass); + if (recordCanvas && typeof sampling === 'number') + this.initCanvasFPSObserver(sampling, win, blockClass); } private processMutation: canvasManagerMutationCallback = function ( @@ -81,6 +89,111 @@ export class CanvasManager { this.pendingCanvasMutations.get(target)!.push(mutation); }; + private initCanvasFPSObserver( + fps: number, + win: IWindow, + blockClass: blockClass, + ) { + const canvasContextReset = initCanvasContextObserver(win, blockClass); + const snapshotInProgressMap: Map = new Map(); + const worker = new ImageBitmapDataURLWorker() as ImageBitmapDataURLRequestWorker; + worker.onmessage = (e) => { + const { id } = e.data; + snapshotInProgressMap.set(id, false); + + if (!('base64' in e.data)) return; + + const { base64, type, width, height } = e.data; + this.mutationCb({ + id, + type: CanvasContext['2D'], + commands: [ + { + property: 'clearRect', // wipe canvas + args: [0, 0, width, height], + }, + { + property: 'drawImage', // draws (semi-transparent) image + args: [ + { + rr_type: 'ImageBitmap', + args: [ + { + rr_type: 'Blob', + data: [{ rr_type: 'ArrayBuffer', base64 }], + type, + }, + ], + } as CanvasArg, + 0, + 0, + ], + }, + ], + }); + }; + + const timeBetweenSnapshots = 1000 / fps; + let lastSnapshotTime = 0; + let rafId: number; + + const takeCanvasSnapshots = (timestamp: DOMHighResTimeStamp) => { + if ( + lastSnapshotTime && + timestamp - lastSnapshotTime < timeBetweenSnapshots + ) { + rafId = requestAnimationFrame(takeCanvasSnapshots); + return; + } + lastSnapshotTime = timestamp; + + win.document + .querySelectorAll(`canvas:not(.${blockClass} *)`) + .forEach(async (canvas: HTMLCanvasElement) => { + const id = this.mirror.getId(canvas); + if (snapshotInProgressMap.get(id)) return; + snapshotInProgressMap.set(id, true); + if (['webgl', 'webgl2'].includes((canvas as ICanvas).__context)) { + // if the canvas hasn't been modified recently, + // its contents won't be in memory and `createImageBitmap` + // will return a transparent imageBitmap + + const context = canvas.getContext((canvas as ICanvas).__context) as + | WebGLRenderingContext + | WebGL2RenderingContext + | null; + if ( + context?.getContextAttributes()?.preserveDrawingBuffer === false + ) { + // Hack to load canvas back into memory so `createImageBitmap` can grab it's contents. + // Context: https://twitter.com/Juice10/status/1499775271758704643 + // This hack might change the background color of the canvas in the unlikely event that + // the canvas background was changed but clear was not called directly afterwards. + context?.clear(context.COLOR_BUFFER_BIT); + } + } + const bitmap = await createImageBitmap(canvas); + worker.postMessage( + { + id, + bitmap, + width: canvas.width, + height: canvas.height, + }, + [bitmap], + ); + }); + rafId = requestAnimationFrame(takeCanvasSnapshots); + }; + + rafId = requestAnimationFrame(takeCanvasSnapshots); + + this.resetObservers = () => { + canvasContextReset(); + cancelAnimationFrame(rafId); + }; + } + private initCanvasMutationObserver( win: IWindow, blockClass: blockClass, diff --git a/packages/rrweb/src/record/observers/canvas/serialize-args.ts b/packages/rrweb/src/record/observers/canvas/serialize-args.ts index e245ee4123..98095b3a61 100644 --- a/packages/rrweb/src/record/observers/canvas/serialize-args.ts +++ b/packages/rrweb/src/record/observers/canvas/serialize-args.ts @@ -1,20 +1,14 @@ import { encode } from 'base64-arraybuffer'; -import { IWindow, SerializedWebGlArg } from '../../../types'; +import { IWindow, CanvasArg } from '../../../types'; // TODO: unify with `replay/webgl.ts` -type GLVarMap = Map; -const webGLVarMap: Map< - WebGLRenderingContext | WebGL2RenderingContext, - GLVarMap -> = new Map(); -export function variableListFor( - ctx: WebGLRenderingContext | WebGL2RenderingContext, - ctor: string, -) { - let contextMap = webGLVarMap.get(ctx); +type CanvasVarMap = Map; +const canvasVarMap: Map = new Map(); +export function variableListFor(ctx: RenderingContext, ctor: string) { + let contextMap = canvasVarMap.get(ctx); if (!contextMap) { contextMap = new Map(); - webGLVarMap.set(ctx, contextMap); + canvasVarMap.set(ctx, contextMap); } if (!contextMap.has(ctor)) { contextMap.set(ctor, []); @@ -25,7 +19,7 @@ export function variableListFor( export const saveWebGLVar = ( value: any, win: IWindow, - ctx: WebGL2RenderingContext | WebGLRenderingContext, + ctx: RenderingContext, ): number | void => { if ( !value || @@ -48,8 +42,8 @@ export const saveWebGLVar = ( export function serializeArg( value: any, win: IWindow, - ctx: WebGL2RenderingContext | WebGLRenderingContext, -): SerializedWebGlArg { + ctx: RenderingContext, +): CanvasArg { if (value instanceof Array) { return value.map((arg) => serializeArg(arg, win, ctx)); } else if (value === null) { @@ -100,12 +94,27 @@ export function serializeArg( rr_type: name, src, }; + } else if (value instanceof HTMLCanvasElement) { + const name = 'HTMLImageElement'; + // TODO: move `toDataURL` to web worker if possible + const src = value.toDataURL(); // heavy on large canvas + return { + rr_type: name, + src, + }; } else if (value instanceof ImageData) { const name = value.constructor.name; return { rr_type: name, args: [serializeArg(value.data, win, ctx), value.width, value.height], }; + // } else if (value instanceof Blob) { + // const name = value.constructor.name; + // return { + // rr_type: name, + // data: [serializeArg(await value.arrayBuffer(), win, ctx)], + // type: value.type, + // }; } else if (isInstanceOfWebGLObject(value, win) || typeof value === 'object') { const name = value.constructor.name; const index = saveWebGLVar(value, win, ctx) as number; @@ -122,7 +131,7 @@ export function serializeArg( export const serializeArgs = ( args: Array, win: IWindow, - ctx: WebGLRenderingContext | WebGL2RenderingContext, + ctx: RenderingContext, ) => { return [...args].map((arg) => serializeArg(arg, win, ctx)); }; diff --git a/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts new file mode 100644 index 0000000000..28cc5c8b72 --- /dev/null +++ b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts @@ -0,0 +1,77 @@ +import { encode } from 'base64-arraybuffer'; +import { + ImageBitmapDataURLWorkerParams, + ImageBitmapDataURLWorkerResponse, +} from '../../types'; + +const lastBlobMap: Map = new Map(); +const transparentBlobMap: Map = new Map(); + +export interface ImageBitmapDataURLRequestWorker { + postMessage: ( + message: ImageBitmapDataURLWorkerParams, + transfer?: [ImageBitmap], + ) => void; + onmessage: (message: MessageEvent) => void; +} + +interface ImageBitmapDataURLResponseWorker { + onmessage: + | null + | ((message: MessageEvent) => void); + postMessage(e: ImageBitmapDataURLWorkerResponse): void; +} + +async function getTransparentBlobFor( + width: number, + height: number, +): Promise { + const id = `${width}-${height}`; + if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!; + const offscreen = new OffscreenCanvas(width, height); + offscreen.getContext('2d'); // creates rendering context for `converToBlob` + const blob = await offscreen.convertToBlob(); // takes a while + const arrayBuffer = await blob.arrayBuffer(); + const base64 = encode(arrayBuffer); // cpu intensive + transparentBlobMap.set(id, base64); + return base64; +} + +// `as any` because: https://github.com/Microsoft/TypeScript/issues/20595 +const worker: ImageBitmapDataURLResponseWorker = self; + +worker.onmessage = async function (e) { + if (!('OffscreenCanvas' in globalThis)) + return worker.postMessage({ id: e.data.id }); + + const { id, bitmap, width, height } = e.data; + + const transparentBase64 = getTransparentBlobFor(width, height); + + const offscreen = new OffscreenCanvas(width, height); + const ctx = offscreen.getContext('2d')!; + + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const blob = await offscreen.convertToBlob(); // takes a while + const type = blob.type; + const arrayBuffer = await blob.arrayBuffer(); + const base64 = encode(arrayBuffer); // cpu intensive + + // on first try we should check if canvas is transparent, + // no need to save it's contents in that case + if (!lastBlobMap.has(id) && (await transparentBase64) === base64) { + lastBlobMap.set(id, base64); + return worker.postMessage({ id }); + } + + if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged + worker.postMessage({ + id, + type, + base64, + width, + height, + }); + lastBlobMap.set(id, base64); +}; diff --git a/packages/rrweb/src/record/workers/tsconfig.json b/packages/rrweb/src/record/workers/tsconfig.json new file mode 100644 index 0000000000..cf0e05cb8e --- /dev/null +++ b/packages/rrweb/src/record/workers/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "lib": ["webworker"] + }, + "exclude": ["workers.d.ts"] +} diff --git a/packages/rrweb/src/record/workers/workers.d.ts b/packages/rrweb/src/record/workers/workers.d.ts new file mode 100644 index 0000000000..ead3d9e1f4 --- /dev/null +++ b/packages/rrweb/src/record/workers/workers.d.ts @@ -0,0 +1,4 @@ +declare module 'web-worker:*' { + const WorkerFactory: new () => Worker; + export default WorkerFactory; +} diff --git a/packages/rrweb/src/replay/canvas/2d.ts b/packages/rrweb/src/replay/canvas/2d.ts index feeb94d8c2..f535f24bac 100644 --- a/packages/rrweb/src/replay/canvas/2d.ts +++ b/packages/rrweb/src/replay/canvas/2d.ts @@ -1,7 +1,8 @@ import { Replayer } from '../'; import { canvasMutationCommand } from '../../types'; +import { deserializeArg } from './deserialize-args'; -export default function canvasMutation({ +export default async function canvasMutation({ event, mutation, target, @@ -13,7 +14,7 @@ export default function canvasMutation({ target: HTMLCanvasElement; imageMap: Replayer['imageMap']; errorHandler: Replayer['warnCanvasMutationFailed']; -}): void { +}): Promise { try { const ctx = target.getContext('2d')!; @@ -37,10 +38,12 @@ export default function canvasMutation({ typeof mutation.args[0] === 'string' ) { const image = imageMap.get(event); - mutation.args[0] = image; original.apply(ctx, mutation.args); } else { - original.apply(ctx, mutation.args); + const args = await Promise.all( + mutation.args.map(deserializeArg(imageMap, ctx)), + ); + original.apply(ctx, args); } } catch (error) { errorHandler(mutation, error); diff --git a/packages/rrweb/src/replay/canvas/deserialize-args.ts b/packages/rrweb/src/replay/canvas/deserialize-args.ts new file mode 100644 index 0000000000..d5adcbbf7d --- /dev/null +++ b/packages/rrweb/src/replay/canvas/deserialize-args.ts @@ -0,0 +1,92 @@ +import { decode } from 'base64-arraybuffer'; +import type { Replayer } from '../'; +import { CanvasArg, SerializedCanvasArg } from '../../types'; + +// TODO: add ability to wipe this list +type GLVarMap = Map; +const webGLVarMap: Map< + CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext, + GLVarMap +> = new Map(); +export function variableListFor( + ctx: + | CanvasRenderingContext2D + | WebGLRenderingContext + | WebGL2RenderingContext, + ctor: string, +) { + let contextMap = webGLVarMap.get(ctx); + if (!contextMap) { + contextMap = new Map(); + webGLVarMap.set(ctx, contextMap); + } + if (!contextMap.has(ctor)) { + contextMap.set(ctor, []); + } + return contextMap.get(ctor) as any[]; +} + +export function isSerializedArg(arg: unknown): arg is SerializedCanvasArg { + return Boolean(arg && typeof arg === 'object' && 'rr_type' in arg); +} + +export function deserializeArg( + imageMap: Replayer['imageMap'], + ctx: + | CanvasRenderingContext2D + | WebGLRenderingContext + | WebGL2RenderingContext + | null, + preload?: { + isUnchanged: boolean; + }, +): (arg: CanvasArg) => Promise { + return async (arg: CanvasArg): Promise => { + if (arg && typeof arg === 'object' && 'rr_type' in arg) { + if (preload) preload.isUnchanged = false; + if (arg.rr_type === 'ImageBitmap' && 'args' in arg) { + const args = await deserializeArg(imageMap, ctx, preload)(arg.args); + return await createImageBitmap.apply(null, args); + } else if ('index' in arg) { + if (preload || ctx === null) return arg; // we are preloading, ctx is unknown + const { rr_type: name, index } = arg; + return variableListFor(ctx, name)[index]; + } else if ('args' in arg) { + const { rr_type: name, args } = arg; + const ctor = window[name as keyof Window]; + + return new ctor( + ...(await Promise.all( + args.map(deserializeArg(imageMap, ctx, preload)), + )), + ); + } else if ('base64' in arg) { + return decode(arg.base64); + } else if ('src' in arg) { + const image = imageMap.get(arg.src); + if (image) { + return image; + } else { + const image = new Image(); + image.src = arg.src; + imageMap.set(arg.src, image); + return image; + } + } else if ('data' in arg && arg.rr_type === 'Blob') { + const blobContents = await Promise.all( + arg.data.map(deserializeArg(imageMap, ctx, preload)), + ); + const blob = new Blob(blobContents, { + type: arg.type, + }); + return blob; + } + } else if (Array.isArray(arg)) { + const result = await Promise.all( + arg.map(deserializeArg(imageMap, ctx, preload)), + ); + return result; + } + return arg; + }; +} diff --git a/packages/rrweb/src/replay/canvas/index.ts b/packages/rrweb/src/replay/canvas/index.ts index 73411a2b10..8924b9ec31 100644 --- a/packages/rrweb/src/replay/canvas/index.ts +++ b/packages/rrweb/src/replay/canvas/index.ts @@ -3,48 +3,59 @@ import { CanvasContext, canvasMutationCommand, canvasMutationData, + canvasMutationParam, } from '../../types'; import webglMutation from './webgl'; import canvas2DMutation from './2d'; -export default function canvasMutation({ +export default async function canvasMutation({ event, mutation, target, imageMap, + canvasEventMap, errorHandler, }: { event: Parameters[0]; mutation: canvasMutationData; target: HTMLCanvasElement; imageMap: Replayer['imageMap']; + canvasEventMap: Replayer['canvasEventMap']; errorHandler: Replayer['warnCanvasMutationFailed']; -}): void { +}): Promise { try { - const mutations: canvasMutationCommand[] = - 'commands' in mutation ? mutation.commands : [mutation]; + let precomputedMutation: canvasMutationParam = + canvasEventMap.get(event) || mutation; + + const commands: canvasMutationCommand[] = + 'commands' in precomputedMutation + ? precomputedMutation.commands + : [precomputedMutation]; if ([CanvasContext.WebGL, CanvasContext.WebGL2].includes(mutation.type)) { - return mutations.forEach((command) => { - webglMutation({ + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + await webglMutation({ mutation: command, type: mutation.type, target, imageMap, errorHandler, }); - }); + } + return; } // default is '2d' for backwards compatibility (rrweb below 1.1.x) - return mutations.forEach((command) => { - canvas2DMutation({ + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + await canvas2DMutation({ event, mutation: command, target, imageMap, errorHandler, }); - }); + } } catch (error) { errorHandler(mutation, error); } diff --git a/packages/rrweb/src/replay/canvas/webgl.ts b/packages/rrweb/src/replay/canvas/webgl.ts index 58d323dc3d..67bffb6ff8 100644 --- a/packages/rrweb/src/replay/canvas/webgl.ts +++ b/packages/rrweb/src/replay/canvas/webgl.ts @@ -1,31 +1,6 @@ -import { decode } from 'base64-arraybuffer'; -import { Replayer } from '../'; -import { - CanvasContext, - canvasMutationCommand, - SerializedWebGlArg, -} from '../../types'; - -// TODO: add ability to wipe this list -type GLVarMap = Map; -const webGLVarMap: Map< - WebGLRenderingContext | WebGL2RenderingContext, - GLVarMap -> = new Map(); -export function variableListFor( - ctx: WebGLRenderingContext | WebGL2RenderingContext, - ctor: string, -) { - let contextMap = webGLVarMap.get(ctx); - if (!contextMap) { - contextMap = new Map(); - webGLVarMap.set(ctx, contextMap); - } - if (!contextMap.has(ctor)) { - contextMap.set(ctor, []); - } - return contextMap.get(ctor) as any[]; -} +import type { Replayer } from '../'; +import { CanvasContext, canvasMutationCommand } from '../../types'; +import { deserializeArg, variableListFor } from './deserialize-args'; function getContext( target: HTMLCanvasElement, @@ -72,41 +47,7 @@ function saveToWebGLVarMap( if (!variables.includes(result)) variables.push(result); } -export function deserializeArg( - imageMap: Replayer['imageMap'], - ctx: WebGLRenderingContext | WebGL2RenderingContext, -): (arg: SerializedWebGlArg) => any { - return (arg: SerializedWebGlArg): any => { - if (arg && typeof arg === 'object' && 'rr_type' in arg) { - if ('index' in arg) { - const { rr_type: name, index } = arg; - return variableListFor(ctx, name)[index]; - } else if ('args' in arg) { - const { rr_type: name, args } = arg; - const ctor = window[name as keyof Window]; - - return new ctor(...args.map(deserializeArg(imageMap, ctx))); - } else if ('base64' in arg) { - return decode(arg.base64); - } else if ('src' in arg) { - const image = imageMap.get(arg.src); - if (image) { - return image; - } else { - const image = new Image(); - image.src = arg.src; - imageMap.set(arg.src, image); - return image; - } - } - } else if (Array.isArray(arg)) { - return arg.map(deserializeArg(imageMap, ctx)); - } - return arg; - }; -} - -export default function webglMutation({ +export default async function webglMutation({ mutation, target, type, @@ -118,7 +59,7 @@ export default function webglMutation({ type: CanvasContext; imageMap: Replayer['imageMap']; errorHandler: Replayer['warnCanvasMutationFailed']; -}): void { +}): Promise { try { const ctx = getContext(target, type); if (!ctx) return; @@ -137,7 +78,9 @@ export default function webglMutation({ mutation.property as Exclude ] as Function; - const args = mutation.args.map(deserializeArg(imageMap, ctx)); + const args = await Promise.all( + mutation.args.map(deserializeArg(imageMap, ctx)), + ); const result = original.apply(ctx, args); saveToWebGLVarMap(ctx, result); diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index d7027a14bf..e3724d1b14 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -40,6 +40,7 @@ import { mouseMovePos, IWindow, canvasMutationCommand, + canvasMutationParam, textMutation, } from '../types'; import { @@ -64,6 +65,7 @@ import { getPositionsAndIndex, } from './virtual-styles'; import canvasMutation from './canvas'; +import { deserializeArg } from './canvas/deserialize-args'; const SKIP_TIME_THRESHOLD = 10 * 1000; const SKIP_TIME_INTERVAL = 5 * 1000; @@ -123,6 +125,7 @@ export class Replayer { private cache: BuildCache = createCache(); private imageMap: Map = new Map(); + private canvasEventMap: Map = new Map(); private mirror: Mirror = createMirror(); @@ -854,24 +857,30 @@ export class Replayer { /** * pause when there are some canvas drawImage args need to be loaded */ - private preloadAllImages() { + private async preloadAllImages(): Promise { let beforeLoadState = this.service.state; const stateHandler = () => { beforeLoadState = this.service.state; }; this.emitter.on(ReplayerEvents.Start, stateHandler); this.emitter.on(ReplayerEvents.Pause, stateHandler); + const promises: Promise[] = []; for (const event of this.service.state.context.events) { if ( event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.CanvasMutation - ) - if ('commands' in event.data) { - event.data.commands.forEach((c) => this.preloadImages(c, event)); - } else { - this.preloadImages(event.data, event); - } + ) { + promises.push( + this.deserializeAndPreloadCanvasEvents(event.data, event), + ); + const commands = + 'commands' in event.data ? event.data.commands : [event.data]; + commands.forEach((c) => { + this.preloadImages(c, event); + }); + } } + return Promise.all(promises); } private preloadImages(data: canvasMutationCommand, event: eventWithTime) { @@ -886,12 +895,34 @@ export class Replayer { let d = imgd?.data; d = JSON.parse(data.args[0]); ctx?.putImageData(imgd!, 0, 0); - } else if (this.hasImageArg(data.args)) { - this.getImageArgs(data.args).forEach((url) => { - const image = new Image(); - image.src = url; // this preloads the image - this.imageMap.set(url, image); - }); + } + } + private async deserializeAndPreloadCanvasEvents( + data: canvasMutationData, + event: eventWithTime, + ) { + if (!this.canvasEventMap.has(event)) { + const status = { + isUnchanged: true, + }; + if ('commands' in data) { + const commands = await Promise.all( + data.commands.map(async (c) => { + const args = await Promise.all( + c.args.map(deserializeArg(this.imageMap, null, status)), + ); + return { ...c, args }; + }), + ); + if (status.isUnchanged === false) + this.canvasEventMap.set(event, { ...data, commands }); + } else { + const args = await Promise.all( + data.args.map(deserializeArg(this.imageMap, null, status)), + ); + if (status.isUnchanged === false) + this.canvasEventMap.set(event, { ...data, args }); + } } } @@ -1282,6 +1313,7 @@ export class Replayer { mutation: d, target: target as HTMLCanvasElement, imageMap: this.imageMap, + canvasEventMap: this.canvasEventMap, errorHandler: this.warnCanvasMutationFailed.bind(this), }); diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index acf02173aa..629df4baa5 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -201,6 +201,12 @@ export type SamplingStrategy = Partial<{ * 'last' will only record the last input value while input a sequence of chars */ input: 'all' | 'last'; + /** + * 'all' will record every single canvas call + * number between 1 and 60, will record an image snapshots in a web-worker a (maximum) number of times per second. + * Number only supported where [`OffscreenCanvas`](http://mdn.io/offscreencanvas) is supported. + */ + canvas: 'all' | number; }>; export type RecordPlugin = { @@ -416,28 +422,36 @@ export enum CanvasContext { WebGL2, } -export type SerializedWebGlArg = +export type SerializedCanvasArg = | { rr_type: 'ArrayBuffer'; base64: string; // base64 } + | { + rr_type: 'Blob'; + data: Array; + type?: string; + } | { rr_type: string; src: string; // url of image } | { rr_type: string; - args: SerializedWebGlArg[]; + args: Array; } | { rr_type: string; index: number; - } + }; + +export type CanvasArg = + | SerializedCanvasArg | string | number | boolean | null - | SerializedWebGlArg[]; + | CanvasArg[]; type mouseInteractionParam = { type: MouseInteractions; @@ -516,6 +530,25 @@ export type canvasManagerMutationCallback = ( p: canvasMutationWithType, ) => void; +export type ImageBitmapDataURLWorkerParams = { + id: number; + bitmap: ImageBitmap; + width: number; + height: number; +}; + +export type ImageBitmapDataURLWorkerResponse = + | { + id: number; + } + | { + id: number; + type: string; + base64: string; + width: number; + height: number; + }; + export type fontParam = { family: string; fontSource: string; diff --git a/packages/rrweb/test/e2e/webgl.test.ts b/packages/rrweb/test/e2e/webgl.test.ts index 2be0d33f0c..38c4abc880 100644 --- a/packages/rrweb/test/e2e/webgl.test.ts +++ b/packages/rrweb/test/e2e/webgl.test.ts @@ -130,7 +130,7 @@ describe('e2e webgl', () => { }); replayer.play(500); `); - await page.waitForTimeout(50); + await waitForRAF(page); const element = await page.$('iframe'); const frameImage = await element!.screenshot(); @@ -165,7 +165,7 @@ describe('e2e webgl', () => { // wait for iframe to get added and `preloadAllImages` to ge called await page.waitForSelector('iframe'); await page.evaluate(`replayer.play(500);`); - await page.waitForTimeout(50); + await waitForRAF(page); const element = await page.$('iframe'); const frameImage = await element!.screenshot(); diff --git a/packages/rrweb/test/html/canvas-webgl-square.html b/packages/rrweb/test/html/canvas-webgl-square.html index cbdc7ec62e..0388cd9459 100644 --- a/packages/rrweb/test/html/canvas-webgl-square.html +++ b/packages/rrweb/test/html/canvas-webgl-square.html @@ -32,79 +32,84 @@ } diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 5b9e82b851..8043bea6af 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -11,7 +11,7 @@ import { IncrementalSource, styleSheetRuleData, } from '../src/types'; -import { assertSnapshot, launchPuppeteer } from './utils'; +import { assertSnapshot, launchPuppeteer, waitForRAF } from './utils'; interface ISuite { code: string; @@ -368,7 +368,7 @@ describe('record iframes', function (this: ISuite) { emit: ((window as unknown) as IWindow).emit, }); }); - await ctx.page.waitForTimeout(10); + await waitForRAF(ctx.page); // console.log(JSON.stringify(ctx.events)); expect(ctx.events.length).toEqual(3); diff --git a/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap b/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap index 03a208dcd5..c7aeb17e1c 100644 --- a/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap +++ b/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap @@ -1,5 +1,164 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`record webgl recordCanvas FPS should record snapshots 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"canvas\\", + \\"attributes\\": { + \\"id\\": \\"canvas\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 9, + \\"id\\": 7, + \\"type\\": 0, + \\"commands\\": [ + { + \\"property\\": \\"clearRect\\", + \\"args\\": [ + 0, + 0, + 300, + 150 + ] + }, + { + \\"property\\": \\"drawImage\\", + \\"args\\": [ + { + \\"rr_type\\": \\"ImageBitmap\\", + \\"args\\": [ + { + \\"rr_type\\": \\"Blob\\", + \\"data\\": [ + { + \\"rr_type\\": \\"ArrayBuffer\\", + \\"base64\\": \\"base64-0\\" + } + ], + \\"type\\": \\"image/png\\" + } + ] + }, + 0, + 0 + ] + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 9, + \\"id\\": 7, + \\"type\\": 0, + \\"commands\\": [ + { + \\"property\\": \\"clearRect\\", + \\"args\\": [ + 0, + 0, + 300, + 150 + ] + }, + { + \\"property\\": \\"drawImage\\", + \\"args\\": [ + { + \\"rr_type\\": \\"ImageBitmap\\", + \\"args\\": [ + { + \\"rr_type\\": \\"Blob\\", + \\"data\\": [ + { + \\"rr_type\\": \\"ArrayBuffer\\", + \\"base64\\": \\"base64-1\\" + } + ], + \\"type\\": \\"image/png\\" + } + ] + }, + 0, + 0 + ] + } + ] + } + } +]" +`; + exports[`record webgl should batch events by RAF 1`] = ` "[ { diff --git a/packages/rrweb/test/record/serialize-args.test.ts b/packages/rrweb/test/record/serialize-args.test.ts index 8ba72e6cd0..8474c03938 100644 --- a/packages/rrweb/test/record/serialize-args.test.ts +++ b/packages/rrweb/test/record/serialize-args.test.ts @@ -149,6 +149,16 @@ describe('serializeArg', () => { }); }); + it('should support HTMLCanvasElements saved to image', async () => { + const canvas = document.createElement('canvas'); + // polyfill canvas.toDataURL as it doesn't exist in jsdom + canvas.toDataURL = () => 'data:image/png;base64,...'; + expect(serializeArg(canvas, window, context)).toMatchObject({ + rr_type: 'HTMLImageElement', + src: 'data:image/png;base64,...', + }); + }); + it('should serialize ImageData', async () => { const arr = new Uint8ClampedArray(40000); @@ -176,4 +186,19 @@ describe('serializeArg', () => { ], }); }); + + // we do not yet support async serializing which is needed to call Blob.arrayBuffer() + it.skip('should serialize a blob', async () => { + const arrayBuffer = new Uint8Array([1, 2, 0, 4]).buffer; + const blob = new Blob([arrayBuffer], { type: 'image/png' }); + const expected = { + rr_type: 'ArrayBuffer', + base64: 'AQIABA==', + }; + + expect(await serializeArg(blob, window, context)).toStrictEqual({ + rr_type: 'Blob', + args: [expected, { type: 'image/png' }], + }); + }); }); diff --git a/packages/rrweb/test/record/webgl.test.ts b/packages/rrweb/test/record/webgl.test.ts index 17442669d4..9280e7fb60 100644 --- a/packages/rrweb/test/record/webgl.test.ts +++ b/packages/rrweb/test/record/webgl.test.ts @@ -11,7 +11,12 @@ import { IncrementalSource, CanvasContext, } from '../../src/types'; -import { assertSnapshot, launchPuppeteer, waitForRAF } from '../utils'; +import { + assertSnapshot, + launchPuppeteer, + stripBase64, + waitForRAF, +} from '../utils'; import { ICanvas } from 'rrweb-snapshot'; interface ISuite { @@ -31,7 +36,11 @@ interface IWindow extends Window { emit: (e: eventWithTime) => undefined; } -const setup = function (this: ISuite, content: string): ISuite { +const setup = function ( + this: ISuite, + content: string, + canvasSample: 'all' | number = 'all', +): ISuite { const ctx = {} as ISuite; beforeAll(async () => { @@ -56,13 +65,16 @@ const setup = function (this: ISuite, content: string): ISuite { ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); - await ctx.page.evaluate(() => { + await ctx.page.evaluate((canvasSample) => { const { record } = ((window as unknown) as IWindow).rrweb; record({ recordCanvas: true, + sampling: { + canvas: canvasSample, + }, emit: ((window as unknown) as IWindow).emit, }); - }); + }, canvasSample); }); afterEach(async () => { @@ -257,4 +269,52 @@ describe('record webgl', function (this: ISuite) { assertSnapshot(ctx.events); expect(ctx.events.length).toEqual(5); }); + + describe('recordCanvas FPS', function (this: ISuite) { + jest.setTimeout(10_000); + + const maxFPS = 60; + + const ctx: ISuite = setup.call( + this, + ` + + + + + + + `, + maxFPS, + ); + + it('should record snapshots', async () => { + await ctx.page.evaluate(() => { + const canvas = document.getElementById('canvas') as HTMLCanvasElement; + const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true })!; + // Set the clear color to darkish green. + gl.clearColor(0.0, 0.5, 0.0, 1.0); + // Clear the context with the newly set color. This is + // the function call that actually does the drawing. + gl.clear(gl.COLOR_BUFFER_BIT); + }); + + await ctx.page.waitForTimeout(200); // give it some time buffer + + await ctx.page.evaluate(() => { + const canvas = document.getElementById('canvas') as HTMLCanvasElement; + const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true })!; + // Set the clear color to darkish blue. + gl.clearColor(0.0, 0.0, 0.5, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + }); + + await ctx.page.waitForTimeout(200); + + await waitForRAF(ctx.page); + + // should yield a frame for each change at a max of 60fps + assertSnapshot(stripBase64(ctx.events)); + }); + }); }); diff --git a/packages/rrweb/test/replay/deserialize-args.test.ts b/packages/rrweb/test/replay/deserialize-args.test.ts index a39c439d1d..1343bd28b0 100644 --- a/packages/rrweb/test/replay/deserialize-args.test.ts +++ b/packages/rrweb/test/replay/deserialize-args.test.ts @@ -2,11 +2,10 @@ * @jest-environment jsdom */ +import { deserializeArg } from '../../src/replay/canvas/deserialize-args'; import { polyfillWebGLGlobals } from '../utils'; polyfillWebGLGlobals(); -import { deserializeArg } from '../../src/replay/canvas/webgl'; - let context: WebGLRenderingContext | WebGL2RenderingContext; describe('deserializeArg', () => { beforeEach(() => { @@ -14,7 +13,7 @@ describe('deserializeArg', () => { }); it('should deserialize Float32Array values', async () => { expect( - deserializeArg( + await deserializeArg( new Map(), context, )({ @@ -26,7 +25,7 @@ describe('deserializeArg', () => { it('should deserialize Float64Array values', async () => { expect( - deserializeArg( + await deserializeArg( new Map(), context, )({ @@ -39,7 +38,7 @@ describe('deserializeArg', () => { it('should deserialize ArrayBuffer values', async () => { const contents = [1, 2, 0, 4]; expect( - deserializeArg( + await deserializeArg( new Map(), context, )({ @@ -51,7 +50,7 @@ describe('deserializeArg', () => { it('should deserialize DataView values', async () => { expect( - deserializeArg( + await deserializeArg( new Map(), context, )({ @@ -70,7 +69,7 @@ describe('deserializeArg', () => { it('should leave arrays intact', async () => { const array = [1, 2, 3, 4]; - expect(deserializeArg(new Map(), context)(array)).toEqual(array); + expect(await deserializeArg(new Map(), context)(array)).toEqual(array); }); it('should deserialize complex objects', async () => { @@ -89,22 +88,20 @@ describe('deserializeArg', () => { 5, 6, ]; - expect(deserializeArg(new Map(), context)(serializedArg)).toStrictEqual([ - new DataView(new ArrayBuffer(16), 0, 16), - 5, - 6, - ]); + expect( + await deserializeArg(new Map(), context)(serializedArg), + ).toStrictEqual([new DataView(new ArrayBuffer(16), 0, 16), 5, 6]); }); it('should leave null as-is', async () => { - expect(deserializeArg(new Map(), context)(null)).toStrictEqual(null); + expect(await deserializeArg(new Map(), context)(null)).toStrictEqual(null); }); it('should support HTMLImageElements', async () => { const image = new Image(); image.src = 'http://example.com/image.png'; expect( - deserializeArg( + await deserializeArg( new Map(), context, )({ @@ -121,7 +118,7 @@ describe('deserializeArg', () => { imageMap.set(image.src, image); expect( - deserializeArg( + await deserializeArg( imageMap, context, )({ @@ -130,4 +127,77 @@ describe('deserializeArg', () => { }), ).toBe(image); }); + + it('should support blobs', async () => { + const arrayBuffer = new Uint8Array([1, 2, 0, 4]).buffer; + const expected = new Blob([arrayBuffer], { type: 'image/png' }); + + const deserialized = await deserializeArg( + new Map(), + context, + )({ + rr_type: 'Blob', + data: [ + { + rr_type: 'ArrayBuffer', + base64: 'AQIABA==', + }, + ], + type: 'image/png', + }); + + // `expect(blob).toEqual(otherBlob)` doesn't really do anything yet + // jest hasn't implemented a propper way to compare blobs + // more info: https://github.com/facebook/jest/issues/7372 + // because JSDOM doesn't support most functions needed for comparison: + // more info: https://github.com/jsdom/jsdom/issues/2555 + expect(deserialized).toEqual(expected); + // thats why we test size of the blob as well + expect(deserialized.size).toEqual(expected.size); + }); + + describe('isUnchanged', () => { + it('should set isUnchanged:true when non of the args are changed', async () => { + const status = { + isUnchanged: true, + }; + + await deserializeArg(new Map(), context, status)(true); + expect(status.isUnchanged).toBeTruthy(); + }); + + it('should set isUnchanged: false when args are deserialzed', async () => { + const status = { + isUnchanged: true, + }; + + await deserializeArg( + new Map(), + context, + status, + )({ + rr_type: 'Float64Array', + args: [[-1, -1, 3, -1, -1, 3]], + }); + expect(status.isUnchanged).toBeFalsy(); + }); + + it('should set isUnchanged: false when nested args are deserialzed', async () => { + const status = { + isUnchanged: true, + }; + + await deserializeArg( + new Map(), + context, + status, + )([ + { + rr_type: 'Float64Array', + args: [[-1, -1, 3, -1, -1, 3]], + }, + ]); + expect(status.isUnchanged).toBeFalsy(); + }); + }); }); diff --git a/packages/rrweb/test/replay/preload-all-images.test.ts b/packages/rrweb/test/replay/preload-all-images.test.ts index 4b4e0a4c71..de7ab4e2ee 100644 --- a/packages/rrweb/test/replay/preload-all-images.test.ts +++ b/packages/rrweb/test/replay/preload-all-images.test.ts @@ -8,7 +8,7 @@ import { Replayer } from '../../src/replay'; import {} from '../../src/types'; import { CanvasContext, - SerializedWebGlArg, + CanvasArg, IncrementalSource, EventType, eventWithTime, @@ -16,9 +16,7 @@ import { let replayer: Replayer; -const canvasMutationEventWithArgs = ( - args: SerializedWebGlArg[], -): eventWithTime => { +const canvasMutationEventWithArgs = (args: CanvasArg[]): eventWithTime => { return { timestamp: 100, type: EventType.IncrementalSnapshot, @@ -67,11 +65,11 @@ describe('preloadAllImages', () => { ); }); - it('should preload nested image', () => { + it('should preload nested image', async () => { replayer.service.state.context.events = [ canvasMutationEventWithArgs([ { - rr_type: 'something', + rr_type: 'Array', args: [ { rr_type: 'HTMLImageElement', @@ -82,7 +80,7 @@ describe('preloadAllImages', () => { ]), ]; - (replayer as any).preloadAllImages(); + await (replayer as any).preloadAllImages(); const expectedImage = new Image(); expectedImage.src = 'http://example.com'; diff --git a/packages/rrweb/test/replay/webgl-mutation.test.ts b/packages/rrweb/test/replay/webgl-mutation.test.ts index a8d6392c13..4f4ed75235 100644 --- a/packages/rrweb/test/replay/webgl-mutation.test.ts +++ b/packages/rrweb/test/replay/webgl-mutation.test.ts @@ -5,8 +5,9 @@ import { polyfillWebGLGlobals } from '../utils'; polyfillWebGLGlobals(); -import webglMutation, { variableListFor } from '../../src/replay/canvas/webgl'; +import webglMutation from '../../src/replay/canvas/webgl'; import { CanvasContext } from '../../src/types'; +import { variableListFor } from '../../src/replay/canvas/deserialize-args'; let canvas: HTMLCanvasElement; describe('webglMutation', () => { @@ -30,7 +31,7 @@ describe('webglMutation', () => { expect(variableListFor(context, 'WebGLShader')).toHaveLength(0); - webglMutation({ + await webglMutation({ mutation: { property: 'createShader', args: [35633], diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 69e72cd998..89f62c3fa4 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -220,6 +220,39 @@ export async function assertDomSnapshot( expect(stringifyDomSnapshot(data)).toMatchSnapshot(); } +export function stripBase64(events: eventWithTime[]) { + const base64Strings: string[] = []; + function walk(obj: T): T { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return (obj.map((e) => walk(e)) as unknown) as T; + const newObj: Partial = {}; + for (let prop in obj) { + const value = obj[prop]; + if (prop === 'base64' && typeof value === 'string') { + let index = base64Strings.indexOf(value); + if (index === -1) { + index = base64Strings.push(value) - 1; + } + (newObj as any)[prop] = `base64-${index}`; + } else { + (newObj as any)[prop] = walk(value); + } + } + return newObj as T; + } + + return events.map((event) => { + if ( + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.CanvasMutation + ) { + const newData = walk(event.data); + return { ...event, data: newData }; + } + return event; + }); +} + const now = Date.now(); export const sampleEvents: eventWithTime[] = [ { diff --git a/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts b/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts index 94a913e002..46635a453a 100644 --- a/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts +++ b/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts @@ -18,13 +18,15 @@ export declare class CanvasManager { lock(): void; unlock(): void; constructor(options: { - recordCanvas: boolean | number; + recordCanvas: boolean; mutationCb: canvasMutationCallback; win: IWindow; blockClass: blockClass; mirror: Mirror; + sampling?: 'all' | number; }); private processMutation; + private initCanvasFPSObserver; private initCanvasMutationObserver; private startPendingCanvasMutationFlusher; private startRAFTimestamping; diff --git a/packages/rrweb/typings/record/observers/canvas/serialize-args.d.ts b/packages/rrweb/typings/record/observers/canvas/serialize-args.d.ts index fd1dfe4558..10337d4e2c 100644 --- a/packages/rrweb/typings/record/observers/canvas/serialize-args.d.ts +++ b/packages/rrweb/typings/record/observers/canvas/serialize-args.d.ts @@ -1,6 +1,6 @@ -import { IWindow, SerializedWebGlArg } from '../../../types'; -export declare function variableListFor(ctx: WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[]; -export declare const saveWebGLVar: (value: any, win: IWindow, ctx: WebGL2RenderingContext | WebGLRenderingContext) => number | void; -export declare function serializeArg(value: any, win: IWindow, ctx: WebGL2RenderingContext | WebGLRenderingContext): SerializedWebGlArg; -export declare const serializeArgs: (args: Array, win: IWindow, ctx: WebGLRenderingContext | WebGL2RenderingContext) => SerializedWebGlArg[]; +import { IWindow, CanvasArg } from '../../../types'; +export declare function variableListFor(ctx: RenderingContext, ctor: string): any[]; +export declare const saveWebGLVar: (value: any, win: IWindow, ctx: RenderingContext) => number | void; +export declare function serializeArg(value: any, win: IWindow, ctx: RenderingContext): CanvasArg; +export declare const serializeArgs: (args: Array, win: IWindow, ctx: RenderingContext) => CanvasArg[]; export declare const isInstanceOfWebGLObject: (value: any, win: IWindow) => value is WebGLTexture | WebGLShader | WebGLBuffer | WebGLVertexArrayObject | WebGLProgram | WebGLActiveInfo | WebGLUniformLocation | WebGLFramebuffer | WebGLRenderbuffer | WebGLShaderPrecisionFormat; diff --git a/packages/rrweb/typings/record/workers/image-bitmap-data-url-worker.d.ts b/packages/rrweb/typings/record/workers/image-bitmap-data-url-worker.d.ts new file mode 100644 index 0000000000..71ca36c5b2 --- /dev/null +++ b/packages/rrweb/typings/record/workers/image-bitmap-data-url-worker.d.ts @@ -0,0 +1,5 @@ +import { ImageBitmapDataURLWorkerParams, ImageBitmapDataURLWorkerResponse } from '../../types'; +export interface ImageBitmapDataURLRequestWorker { + postMessage: (message: ImageBitmapDataURLWorkerParams, transfer?: [ImageBitmap]) => void; + onmessage: (message: MessageEvent) => void; +} diff --git a/packages/rrweb/typings/replay/canvas/2d.d.ts b/packages/rrweb/typings/replay/canvas/2d.d.ts index 338cbf28f9..d780a7b981 100644 --- a/packages/rrweb/typings/replay/canvas/2d.d.ts +++ b/packages/rrweb/typings/replay/canvas/2d.d.ts @@ -6,4 +6,4 @@ export default function canvasMutation({ event, mutation, target, imageMap, erro target: HTMLCanvasElement; imageMap: Replayer['imageMap']; errorHandler: Replayer['warnCanvasMutationFailed']; -}): void; +}): Promise; diff --git a/packages/rrweb/typings/replay/canvas/deserialize-args.d.ts b/packages/rrweb/typings/replay/canvas/deserialize-args.d.ts new file mode 100644 index 0000000000..4d0c00e708 --- /dev/null +++ b/packages/rrweb/typings/replay/canvas/deserialize-args.d.ts @@ -0,0 +1,7 @@ +import type { Replayer } from '../'; +import { CanvasArg, SerializedCanvasArg } from '../../types'; +export declare function variableListFor(ctx: CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[]; +export declare function isSerializedArg(arg: unknown): arg is SerializedCanvasArg; +export declare function deserializeArg(imageMap: Replayer['imageMap'], ctx: CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | null, preload?: { + isUnchanged: boolean; +}): (arg: CanvasArg) => Promise; diff --git a/packages/rrweb/typings/replay/canvas/index.d.ts b/packages/rrweb/typings/replay/canvas/index.d.ts index 72c6bdfce3..95b43accae 100644 --- a/packages/rrweb/typings/replay/canvas/index.d.ts +++ b/packages/rrweb/typings/replay/canvas/index.d.ts @@ -1,9 +1,10 @@ import { Replayer } from '..'; import { canvasMutationData } from '../../types'; -export default function canvasMutation({ event, mutation, target, imageMap, errorHandler, }: { +export default function canvasMutation({ event, mutation, target, imageMap, canvasEventMap, errorHandler, }: { event: Parameters[0]; mutation: canvasMutationData; target: HTMLCanvasElement; imageMap: Replayer['imageMap']; + canvasEventMap: Replayer['canvasEventMap']; errorHandler: Replayer['warnCanvasMutationFailed']; -}): void; +}): Promise; diff --git a/packages/rrweb/typings/replay/canvas/webgl.d.ts b/packages/rrweb/typings/replay/canvas/webgl.d.ts index 6e51941047..732b35a991 100644 --- a/packages/rrweb/typings/replay/canvas/webgl.d.ts +++ b/packages/rrweb/typings/replay/canvas/webgl.d.ts @@ -1,11 +1,9 @@ -import { Replayer } from '../'; -import { CanvasContext, canvasMutationCommand, SerializedWebGlArg } from '../../types'; -export declare function variableListFor(ctx: WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[]; -export declare function deserializeArg(imageMap: Replayer['imageMap'], ctx: WebGLRenderingContext | WebGL2RenderingContext): (arg: SerializedWebGlArg) => any; +import type { Replayer } from '../'; +import { CanvasContext, canvasMutationCommand } from '../../types'; export default function webglMutation({ mutation, target, type, imageMap, errorHandler, }: { mutation: canvasMutationCommand; target: HTMLCanvasElement; type: CanvasContext; imageMap: Replayer['imageMap']; errorHandler: Replayer['warnCanvasMutationFailed']; -}): void; +}): Promise; diff --git a/packages/rrweb/typings/replay/index.d.ts b/packages/rrweb/typings/replay/index.d.ts index 3f0cc85d62..f298737c52 100644 --- a/packages/rrweb/typings/replay/index.d.ts +++ b/packages/rrweb/typings/replay/index.d.ts @@ -22,6 +22,7 @@ export declare class Replayer { private virtualStyleRulesMap; private cache; private imageMap; + private canvasEventMap; private mirror; private firstFullSnapshot; private newDocumentQueue; @@ -56,6 +57,7 @@ export declare class Replayer { private getImageArgs; private preloadAllImages; private preloadImages; + private deserializeAndPreloadCanvasEvents; private applyIncremental; private applyMutation; private applyScroll; diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index 913853afcf..03ce628bd6 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -124,6 +124,7 @@ export declare type SamplingStrategy = Partial<{ scroll: number; media: number; input: 'all' | 'last'; + canvas: 'all' | number; }>; export declare type RecordPlugin = { name: string; @@ -291,19 +292,24 @@ export declare enum CanvasContext { WebGL = 1, WebGL2 = 2 } -export declare type SerializedWebGlArg = { +export declare type SerializedCanvasArg = { rr_type: 'ArrayBuffer'; base64: string; +} | { + rr_type: 'Blob'; + data: Array; + type?: string; } | { rr_type: string; src: string; } | { rr_type: string; - args: SerializedWebGlArg[]; + args: Array; } | { rr_type: string; index: number; -} | string | number | boolean | null | SerializedWebGlArg[]; +}; +export declare type CanvasArg = SerializedCanvasArg | string | number | boolean | null | CanvasArg[]; declare type mouseInteractionParam = { type: MouseInteractions; id: number; @@ -361,6 +367,21 @@ export declare type canvasMutationWithType = { } & canvasMutationCommand; export declare type canvasMutationCallback = (p: canvasMutationParam) => void; export declare type canvasManagerMutationCallback = (target: HTMLCanvasElement, p: canvasMutationWithType) => void; +export declare type ImageBitmapDataURLWorkerParams = { + id: number; + bitmap: ImageBitmap; + width: number; + height: number; +}; +export declare type ImageBitmapDataURLWorkerResponse = { + id: number; +} | { + id: number; + type: string; + base64: string; + width: number; + height: number; +}; export declare type fontParam = { family: string; fontSource: string; diff --git a/yarn.lock b/yarn.lock index 6317759f87..19feb977df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1926,19 +1926,6 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-0.5.0.tgz#b21510597fd601e5d7c95008b76bf0d254ebfd31" integrity sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw== -"@rollup/plugin-commonjs@^11.0.0": - version "11.1.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-11.1.0.tgz#60636c7a722f54b41e419e1709df05c7234557ef" - integrity sha512-Ycr12N3ZPN96Fw2STurD21jMqzKwL9QuFhms3SD7KKRK7oaXUsBU9Zt0jL/rOPHiPYisI21/rXGO3jr9BnLHUA== - dependencies: - "@rollup/pluginutils" "^3.0.8" - commondir "^1.0.1" - estree-walker "^1.0.1" - glob "^7.1.2" - is-reference "^1.1.2" - magic-string "^0.25.2" - resolve "^1.11.0" - "@rollup/plugin-commonjs@^20.0.0": version "20.0.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-20.0.0.tgz#3246872dcbcb18a54aaa6277a8c7d7f1b155b745" @@ -1952,6 +1939,19 @@ magic-string "^0.25.7" resolve "^1.17.0" +"@rollup/plugin-commonjs@^21.0.2": + version "21.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz#0b9c539aa1837c94abfaf87945838b0fc8564891" + integrity sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + commondir "^1.0.1" + estree-walker "^2.0.1" + glob "^7.1.6" + is-reference "^1.2.1" + magic-string "^0.25.7" + resolve "^1.17.0" + "@rollup/plugin-node-resolve@^13.0.4": version "13.0.6" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.0.6.tgz#29629070bb767567be8157f575cfa8f2b8e9ef77" @@ -1964,6 +1964,18 @@ is-module "^1.0.0" resolve "^1.19.0" +"@rollup/plugin-node-resolve@^13.1.3": + version "13.1.3" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.3.tgz#2ed277fb3ad98745424c1d2ba152484508a92d79" + integrity sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ== + dependencies: + "@rollup/pluginutils" "^3.1.0" + "@types/resolve" "1.17.1" + builtin-modules "^3.1.0" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.19.0" + "@rollup/plugin-node-resolve@^7.0.0": version "7.1.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca" @@ -1991,14 +2003,6 @@ "@rollup/pluginutils" "^3.1.0" resolve "^1.17.0" -"@rollup/plugin-typescript@^8.3.1": - version "8.3.1" - resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-8.3.1.tgz#b7dc75ed6b4876e260b9e80624fab23bc98e4ac1" - integrity sha512-84rExe3ICUBXzqNX48WZV2Jp3OddjTMX97O2Py6D1KJaGSwWp0mDHXj+bCGNJqWHIEKDIT2U0sDjhP4czKi6cA== - dependencies: - "@rollup/pluginutils" "^3.1.0" - resolve "^1.17.0" - "@rollup/pluginutils@4", "@rollup/pluginutils@^4.1.0": version "4.1.1" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.1.tgz#1d4da86dd4eded15656a57d933fda2b9a08d47ec" @@ -2016,6 +2020,14 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@rollup/pluginutils@^4.1.2": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.2.tgz#ed5821c15e5e05e32816f5fb9ec607cdf5a75751" + integrity sha512-ROn4qvkxP9SyPeHaf7uQC/GPFY6L/OWy9+bd9AwcjOAWQwxRscoEyAUD8qCY5o5iL4jqQwoLk2kaTKJPb/HwzQ== + dependencies: + estree-walker "^2.0.1" + picomatch "^2.2.2" + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -2248,6 +2260,11 @@ resolved "https://registry.yarnpkg.com/@types/nwsapi/-/nwsapi-2.2.2.tgz#1b1dccfc38b2b7e1b9ea71d5285796878375e862" integrity sha512-C4G47l3cAra4729xbhL9y3PjTpO7LJwXd47Fn1mbnZ6WcTkFPo8iDJPyMGCIudxpc7aeM8K1Fmw+lZfOb5ya9g== +"@types/offscreencanvas@^2019.6.4": + version "2019.6.4" + resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz#64f6d120b53925028299c744fcdd32d2cd525963" + integrity sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -2288,9 +2305,9 @@ "@types/node" "*" "@types/puppeteer@^5.4.4": - version "5.4.4" - resolved "https://registry.yarnpkg.com/@types/puppeteer/-/puppeteer-5.4.4.tgz#e92abeccc4f46207c3e1b38934a1246be080ccd0" - integrity sha512-3Nau+qi69CN55VwZb0ATtdUAlYlqOOQ3OfQfq0Hqgc4JMFXiQT/XInlwQ9g6LbicDslE6loIFsXFklGh5XmI6Q== + version "5.4.5" + resolved "https://registry.yarnpkg.com/@types/puppeteer/-/puppeteer-5.4.5.tgz#154e3850a77bfd3967f036680de8ddc88eb3a12b" + integrity sha512-lxCjpDEY+DZ66+W3x5Af4oHnEmUXt0HuaRzkBGE2UZiZEp/V1d3StpLPlmNVu/ea091bdNmVPl44lu8Wy/0ZCA== dependencies: "@types/node" "*" @@ -2522,6 +2539,15 @@ resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.1.tgz#c92972b835540c4e3c5e14277f40dbcbdaee9571" integrity sha512-xYKDNuPR36/fUK+jmhM+oauBmbdUAfuJKnDjg3/7NbN+Pj03TX7e94LXnzkwGgAR+U/HWoMqM5UPTuGIYfIx9g== +"@yarn-tool/resolve-package@^1.0.40": + version "1.0.45" + resolved "https://registry.yarnpkg.com/@yarn-tool/resolve-package/-/resolve-package-1.0.45.tgz#4d9716a67903f46a76c8691eff546dafe55bf66f" + integrity sha512-xnfY8JceApkSTliZtr7X6yl1wZYhGbRp0beBMi1OtmvTVTm/ZSt3881Fw1M3ZwhHqr7OEfl8828LJK2q62BvoQ== + dependencies: + pkg-dir "< 6 >= 5" + tslib "^2.3.1" + upath2 "^3.1.12" + JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -5049,7 +5075,7 @@ finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -find-cache-dir@^3.3.1: +find-cache-dir@^3.3.1, find-cache-dir@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== @@ -5073,6 +5099,14 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + findup-sync@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.3.0.tgz#37930aa5d816b777c03445e1966cc6790a4c0b16" @@ -5168,6 +5202,15 @@ fs-extra@8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.1.tgz#27de43b4320e833f6867cc044bfce29fdf0ef3b8" + integrity sha512-NbdoVMZso2Lsrn/QwLXOy6rm0ufY2zEOKCDzJR/0kBsb0E6qed0P3iYK+Ath3BfvXEeu4JhEtXLgILx5psUfag== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -6197,7 +6240,7 @@ is-redirect@^1.0.0: resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= -is-reference@^1.1.2, is-reference@^1.2.1: +is-reference@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== @@ -8047,6 +8090,13 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -8127,7 +8177,7 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -magic-string@^0.25.2, magic-string@^0.25.7: +magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== @@ -9001,6 +9051,13 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -9015,6 +9072,13 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + p-map-series@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-map-series/-/p-map-series-2.1.0.tgz#7560d4c452d9da0c07e692fdbfe6e2c81a2a91f2" @@ -9196,6 +9260,13 @@ path-is-inside@^1.0.1: resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= +path-is-network-drive@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/path-is-network-drive/-/path-is-network-drive-1.0.13.tgz#c9aa0183eb72c328aa83f43def93ddcb9d7ec4d4" + integrity sha512-Hg74mRN6mmXV+gTm3INjFK40ncAmC/Lo4qoQaSZ+GT3hZzlKdWQSqAjqyPeW0SvObP2W073WyYEBWY9d3wOm3A== + dependencies: + tslib "^2.3.1" + path-key@^2.0.0, path-key@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" @@ -9211,6 +9282,13 @@ path-parse@^1.0.6, path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-strip-sep@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/path-strip-sep/-/path-strip-sep-1.0.10.tgz#2be4e789406b298af8709ff79af716134b733b98" + integrity sha512-JpCy+8LAJQQTO1bQsb/84s1g+/Stm3h39aOpPRBQ/paMUGVPPZChLTOTKHoaCkc/6sKuF7yVsnq5Pe1S6xQGcA== + dependencies: + tslib "^2.3.1" + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -9304,6 +9382,13 @@ pixelmatch@^5.1.0: dependencies: pngjs "^4.0.1" +"pkg-dir@< 6 >= 5": + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -10231,7 +10316,7 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@1.20.0, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.14.1, resolve@^1.14.2, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0: +resolve@1.20.0, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.14.1, resolve@^1.14.2, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0: version "1.20.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -10332,10 +10417,10 @@ rollup-plugin-postcss@^3.1.1: safe-identifier "^0.4.1" style-inject "^0.3.0" -rollup-plugin-rename-node-modules@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-rename-node-modules/-/rollup-plugin-rename-node-modules-1.2.0.tgz#f1f1bb2192d1bbec258569bf6bda097002d7dbdf" - integrity sha512-IKsS3eJXHLAMXIndzNso9ijWJw1V3mqubRc2gb67v7VuLX9t41LObXqciM0JC3j7/WrHeptG47cejFU0qxXUJA== +rollup-plugin-rename-node-modules@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-rename-node-modules/-/rollup-plugin-rename-node-modules-1.3.1.tgz#d80091fc817ce726e7cdfc388ec2f4da286e280f" + integrity sha512-46TUPqO94GXuACYqVZjdbzNXTQAp+wTdZg/vUx2gaINb0da/ZPdaOtno2RGUOKBF4sbVM9v2ZqV98r4TQbp1UA== dependencies: estree-walker "^2.0.1" magic-string "^0.25.7" @@ -10369,6 +10454,23 @@ rollup-plugin-typescript2@^0.30.0: resolve "1.20.0" tslib "2.1.0" +rollup-plugin-typescript2@^0.31.2: + version "0.31.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.31.2.tgz#463aa713a7e2bf85b92860094b9f7fb274c5a4d8" + integrity sha512-hRwEYR1C8xDGVVMFJQdEVnNAeWRvpaY97g5mp3IeLnzhNXzSVq78Ye/BJ9PAaUfN4DXa/uDnqerifMOaMFY54Q== + dependencies: + "@rollup/pluginutils" "^4.1.2" + "@yarn-tool/resolve-package" "^1.0.40" + find-cache-dir "^3.3.2" + fs-extra "^10.0.0" + resolve "^1.20.0" + tslib "^2.3.1" + +rollup-plugin-web-worker-loader@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-web-worker-loader/-/rollup-plugin-web-worker-loader-1.6.1.tgz#9d7a27575b64b0780fe4e8b3bc87470d217e485f" + integrity sha512-4QywQSz1NXFHKdyiou16mH3ijpcfLtLGOrAqvAqu1Gx+P8+zj+3gwC2BSL/VW1d+LW4nIHC8F7d7OXhs9UdR2A== + rollup-pluginutils@^2.8.2: version "2.8.2" resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" @@ -10390,6 +10492,13 @@ rollup@^2.56.3: optionalDependencies: fsevents "~2.3.2" +rollup@^2.68.0: + version "2.68.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.68.0.tgz#6ccabfd649447f8f21d62bf41662e5caece3bd66" + integrity sha512-XrMKOYK7oQcTio4wyTz466mucnd8LzkiZLozZ4Rz0zQD+HeX4nUK4B8GrTX/2EvN2/vBF/i2WnaXboPxo0JylA== + optionalDependencies: + fsevents "~2.3.2" + run-async@^2.2.0, run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -11620,6 +11729,15 @@ unzip-response@^2.0.1: resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= +upath2@^3.1.12: + version "3.1.12" + resolved "https://registry.yarnpkg.com/upath2/-/upath2-3.1.12.tgz#441b3dfbadde21731017bd1b7beb169498efd0a9" + integrity sha512-yC3eZeCyCXFWjy7Nu4pgjLhXNYjuzuUmJiRgSSw6TJp8Emc+E4951HGPJf+bldFC5SL7oBLeNbtm1fGzXn2gxw== + dependencies: + path-is-network-drive "^1.0.13" + path-strip-sep "^1.0.10" + tslib "^2.3.1" + upath@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" @@ -11959,9 +12077,9 @@ ws@^6.1.0: async-limiter "~1.0.0" ws@^7.2.3: - version "7.5.6" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" - integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA== + version "7.5.7" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.7.tgz#9e0ac77ee50af70d58326ecff7e85eb3fa375e67" + integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== ws@^7.4.3, ws@^7.4.5: version "7.5.3" @@ -12071,3 +12189,8 @@ yn@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" integrity sha1-5a2ryKz0CPY4X8dklWhMiOavaJo= + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==