12 | Select2 is a jQuery replacement for select boxes.
13 |
14 | In the 3.5 version it use a quite complicated DOM generation strategy which is a good battle-test for rrweb's recorder.
15 |
16 |
20 |
21 |
22 |
25 |
26 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v1.0.0
4 |
5 | ### Featrues & Improvements
6 |
7 | - Support record same-origin non-sandboxed iframe.
8 | - Support record open-mode shadow DOM.
9 | - Implement the plugin API.
10 | - Export `record.takeFullSnapshot` as a public API
11 | - Record and replay drag events.
12 | - Add options to mask texts (#540).
13 |
14 | ### Fixes
15 |
16 | - Get the original MutationObserver when Angular patched it.
17 | - Fix RangeError: Maximum call stack size exceeded (#479).
18 | - Fix the linked-list implementation in the recorder.
19 | - Don't perform newly added actions if the player is paused (#539).
20 | - Fix inaccurate mouse position (#522)
21 |
22 | ### Breaking Changes
23 |
24 | - Deprecated the usage of `rrweb.mirror`. Please use `record.mirror` and `replayer.getMirror()` instead.
25 | - Deprecated the record option `recordLog `. See the new plugin API [here](./docs/recipes/console.md).
26 | - Deprecated the replay option ` `. See the new plugin API [here](./docs/recipes/console.md).
27 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to rrweb
2 |
3 | We want to make contributing to this project as easy and transparent as
4 | possible.
5 |
6 | ## Our Development Process
7 |
8 | The majority of development on rrweb will occur through GitHub. Accordingly,
9 | the process for contributing will follow standard GitHub protocol.
10 |
11 | ## Pull Requests
12 |
13 | We actively welcome your pull requests.
14 |
15 | 1. Fork the repo and create your branch from `master`.
16 | 2. If you've added code that should be tested, add tests
17 | 3. If you've changed APIs, update the documentation.
18 | 4. Ensure the test suite passes.
19 | 5. Make sure your code lints and typechecks.
20 |
21 | ## Issues
22 |
23 | We use GitHub issues to track public bugs. Please ensure your description is
24 | clear and has sufficient instructions to be able to reproduce the issue.
25 |
26 | ## License
27 |
28 | rrweb is [MIT licensed](https://github.com/rrweb-io/rrweb/blob/master/LICENSE).
29 |
30 | By contributing to rrweb, you agree that your contributions will be licensed
31 | under its MIT license.
32 |
--------------------------------------------------------------------------------
/packages/rrweb-player/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Svelte app
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
36 |
37 |
--------------------------------------------------------------------------------
/.github/config.yml:
--------------------------------------------------------------------------------
1 | # Comment to be posted to on PRs from first time contributors in your repository
2 | newPRWelcomeComment: |
3 | 💖 Thanks for opening this pull request! 💖
4 |
5 | Things that will help get your PR across the finish line:
6 |
7 | - Follow the TypeScript [coding style](https://github.com/rrweb-io/rrweb/blob/master/docs/development/coding-style.md).
8 | - Run `yarn lint` locally to catch formatting errors earlier.
9 | - Document any user-facing changes you've made following the [documentation styleguide](https://github.com/rrweb-io/rrweb/blob/master/blob/main/docs/styleguide.md).
10 | - Include tests when adding/changing behavior.
11 | - Include screenshots and animated GIFs whenever possible.
12 |
13 | We get a lot of pull requests on this repo, so please be patient and we will get back to you as soon as we can.
14 |
15 | # Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge
16 |
17 | # Comment to be posted to on pull requests merged by a first time user
18 | firstPRMergeComment: >
19 | Congrats on merging your first pull request! 🎉🎉🎉Hallo
20 |
--------------------------------------------------------------------------------
/docs/recipes/custom-event.zh_CN.md:
--------------------------------------------------------------------------------
1 | # 自定义事件
2 |
3 | 录制时可能需要在特定的时间点记录一些特定含义的数据,如果希望这部分数据作为回放时的一部分,则可以通过自定义事件的方式实现。
4 |
5 | 开始录制后,我们就可以通过 `record.addCustomEvent` API 添加自定义事件:
6 |
7 | ```js
8 | // 开始录制
9 | rrweb.record({
10 | emit(event) {
11 | ...
12 | }
13 | })
14 |
15 | // 在开始录制后的任意时间点记录自定义事件,例如:
16 | rrweb.record.addCustomEvent('submit-form', {
17 | name: '姓名',
18 | age: 18
19 | })
20 | rrweb.record.addCustomEvent('some-error', {
21 | error
22 | })
23 | ```
24 |
25 | `addCustomEvent` 接收两个参数,第一个是字符串类型的 `tag`,第二个是任意类型的 `payload`。
26 |
27 | 在回放时我们可以通过监听事件获取对应的事件,也可以通过配置 rrweb-player 在回放器 UI 的时间轴中展示对应事件。
28 |
29 | **获取对应事件**
30 |
31 | ```js
32 | const replayer = new rrweb.Replayer(events);
33 |
34 | replayer.on('custom-event', (event) => {
35 | console.log(event.tag, event.payload);
36 | });
37 | ```
38 |
39 | **在 rrweb-player 中展示**
40 |
41 | ```js
42 | new rrwebPlayer({
43 | target: document.body,
44 | props: {
45 | events,
46 | // 自定义各个 tag 在时间轴上的色值
47 | tags: {
48 | 'submit-form': '#21e676',
49 | 'some-error': 'red',
50 | },
51 | },
52 | });
53 | ```
54 |
--------------------------------------------------------------------------------
/packages/rrweb/test/html/form.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | form fields
8 |
9 |
10 |
11 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/docs/recipes/canvas.md:
--------------------------------------------------------------------------------
1 | # Canvas
2 |
3 | Canvas is a special HTML element, and will not be recorded by rrweb by default.
4 | There are some options for recording and replaying Canvas.
5 |
6 | Enable recording Canvas:
7 |
8 | ```js
9 | rrweb.record({
10 | emit(event) {},
11 | recordCanvas: true,
12 | });
13 | ```
14 |
15 | Alternatively enable image snapshot recording of Canvas at a maximum of 15 frames per second:
16 |
17 | ```js
18 | rrweb.record({
19 | emit(event) {},
20 | recordCanvas: true,
21 | sampling: {
22 | canvas: 15,
23 | },
24 | // optional image format settings
25 | dataURLOptions: {
26 | type: 'image/webp',
27 | quality: 0.6,
28 | },
29 | });
30 | ```
31 |
32 | Enable replaying Canvas:
33 |
34 | ```js
35 | const replayer = new rrweb.Replayer(events, {
36 | UNSAFE_replayCanvas: true,
37 | });
38 | replayer.play();
39 | ```
40 |
41 | **Enable replaying Canvas will remove the sandbox, which may cause a potential security issue.**
42 |
43 | Alternatively you can stream canvas elements via webrtc with the canvas-webrtc plugin.
44 | For more information see [canvas-webrtc documentation](../../packages/rrweb/src/plugins/canvas-webrtc/Readme.md)
45 |
--------------------------------------------------------------------------------
/packages/rrweb-player/typings/index.d.ts:
--------------------------------------------------------------------------------
1 | import { eventWithTime, playerConfig } from 'rrweb/typings/types';
2 | import { Replayer, mirror } from 'rrweb';
3 | import { SvelteComponent } from 'svelte';
4 |
5 | export type RRwebPlayerOptions = {
6 | target: HTMLElement;
7 | props: {
8 | events: eventWithTime[];
9 | width?: number;
10 | height?: number;
11 | autoPlay?: boolean;
12 | speed?: number;
13 | speedOption?: number[];
14 | showController?: boolean;
15 | tags?: Record;
16 | } & Partial;
17 | };
18 |
19 | export default class rrwebPlayer extends SvelteComponent {
20 | constructor(options: RRwebPlayerOptions);
21 |
22 | addEventListener(event: string, handler: (params: any) => unknown): void;
23 |
24 | addEvent(event: eventWithTime): void;
25 | getMetaData: Replayer['getMetaData'];
26 | getReplayer: () => Replayer;
27 | getMirror: () => typeof mirror;
28 |
29 | toggle: () => void;
30 | setSpeed: (speed: number) => void;
31 | toggleSkipInactive: () => void;
32 | triggerResize: () => void;
33 | play: () => void;
34 | pause: () => void;
35 | goto: (timeOffset: number, play?: boolean) => void;
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb/graphs/contributors) and SmartX Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/rrweb/src/record/iframe-manager.ts:
--------------------------------------------------------------------------------
1 | import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
2 | import type { mutationCallBack } from '../types';
3 |
4 | export class IframeManager {
5 | private iframes: WeakMap = new WeakMap();
6 | private mutationCb: mutationCallBack;
7 | private loadListener?: (iframeEl: HTMLIFrameElement) => unknown;
8 |
9 | constructor(options: { mutationCb: mutationCallBack }) {
10 | this.mutationCb = options.mutationCb;
11 | }
12 |
13 | public addIframe(iframeEl: HTMLIFrameElement) {
14 | this.iframes.set(iframeEl, true);
15 | }
16 |
17 | public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) {
18 | this.loadListener = cb;
19 | }
20 |
21 | public attachIframe(
22 | iframeEl: HTMLIFrameElement,
23 | childSn: serializedNodeWithId,
24 | mirror: Mirror,
25 | ) {
26 | this.mutationCb({
27 | adds: [
28 | {
29 | parentId: mirror.getId(iframeEl),
30 | nextId: null,
31 | node: childSn,
32 | },
33 | ],
34 | removes: [],
35 | texts: [],
36 | attributes: [],
37 | isAttachIframe: true,
38 | });
39 | this.loadListener?.(iframeEl);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/rrdom/test/html/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Main
7 |
8 |
24 |
25 |
26 |
This is a h1 heading
27 |
This is a h1 heading with styles
28 |
29 |
30 | Text 1
31 |
32 |
This is a paragraph
33 |
34 |
35 | Text 2
36 |
37 |
38 |
39 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/packages/rrweb/src/plugins/sequential-id/replay/index.ts:
--------------------------------------------------------------------------------
1 | import type { SequentialIdOptions } from '../record';
2 | import type { ReplayPlugin, eventWithTime } from '../../../types';
3 |
4 | type Options = SequentialIdOptions & {
5 | warnOnMissingId: boolean;
6 | };
7 |
8 | const defaultOptions: Options = {
9 | key: '_sid',
10 | warnOnMissingId: true,
11 | };
12 |
13 | export const getReplaySequentialIdPlugin: (
14 | options?: Partial,
15 | ) => ReplayPlugin = (options) => {
16 | const { key, warnOnMissingId } = options
17 | ? Object.assign({}, defaultOptions, options)
18 | : defaultOptions;
19 | let currentId = 1;
20 |
21 | return {
22 | handler(event: eventWithTime) {
23 | if (key in event) {
24 | const id = ((event as unknown) as Record)[key];
25 | if (id !== currentId) {
26 | console.error(
27 | `[sequential-id-plugin]: expect to get an id with value "${currentId}", but got "${id}"`,
28 | );
29 | } else {
30 | currentId++;
31 | }
32 | } else if (warnOnMissingId) {
33 | console.warn(
34 | `[sequential-id-plugin]: failed to get id in key: "${key}"`,
35 | );
36 | }
37 | },
38 | };
39 | };
40 |
--------------------------------------------------------------------------------
/packages/rrweb/src/record/observers/canvas/canvas.ts:
--------------------------------------------------------------------------------
1 | import type { ICanvas } from 'rrweb-snapshot';
2 | import type { blockClass, IWindow, listenerHandler } from '../../../types';
3 | import { isBlocked, patch } from '../../../utils';
4 |
5 | export default function initCanvasContextObserver(
6 | win: IWindow,
7 | blockClass: blockClass,
8 | blockSelector: string | null,
9 | ): listenerHandler {
10 | const handlers: listenerHandler[] = [];
11 | try {
12 | const restoreHandler = patch(
13 | win.HTMLCanvasElement.prototype,
14 | 'getContext',
15 | function (
16 | original: (
17 | this: ICanvas,
18 | contextType: string,
19 | ...args: Array
20 | ) => void,
21 | ) {
22 | return function (
23 | this: ICanvas,
24 | contextType: string,
25 | ...args: Array
26 | ) {
27 | if (!isBlocked(this, blockClass, blockSelector, true)) {
28 | if (!('__context' in this)) this.__context = contextType;
29 | }
30 | return original.apply(this, [contextType, ...args]);
31 | };
32 | },
33 | );
34 | handlers.push(restoreHandler);
35 | } catch {
36 | console.error('failed to patch HTMLCanvasElement.prototype.getContext');
37 | }
38 | return () => {
39 | handlers.forEach((h) => h());
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/packages/rrweb-snapshot/test/html/form-fields.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | form fields
8 |
9 |
10 |
11 |
34 |
35 |
42 |
43 |
--------------------------------------------------------------------------------
/packages/rrweb/src/record/stylesheet-manager.ts:
--------------------------------------------------------------------------------
1 | import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
2 | import type { mutationCallBack } from '../types';
3 |
4 | export class StylesheetManager {
5 | private trackedStylesheets: WeakSet = new WeakSet();
6 | private mutationCb: mutationCallBack;
7 |
8 | constructor(options: { mutationCb: mutationCallBack }) {
9 | this.mutationCb = options.mutationCb;
10 | }
11 |
12 | public addStylesheet(linkEl: HTMLLinkElement) {
13 | if (this.trackedStylesheets.has(linkEl)) return;
14 |
15 | this.trackedStylesheets.add(linkEl);
16 | this.trackStylesheet(linkEl);
17 | }
18 |
19 | // TODO: take snapshot on stylesheet reload by applying event listener
20 | private trackStylesheet(linkEl: HTMLLinkElement) {
21 | // linkEl.addEventListener('load', () => {
22 | // // re-loaded, maybe take another snapshot?
23 | // });
24 | }
25 |
26 | public attachStylesheet(
27 | linkEl: HTMLLinkElement,
28 | childSn: serializedNodeWithId,
29 | mirror: Mirror,
30 | ) {
31 | this.mutationCb({
32 | adds: [
33 | {
34 | parentId: mirror.getId(linkEl),
35 | nextId: null,
36 | node: childSn,
37 | },
38 | ],
39 | removes: [],
40 | texts: [],
41 | attributes: [],
42 | });
43 | this.addStylesheet(linkEl);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/rrweb/test/packer.test.ts:
--------------------------------------------------------------------------------
1 | import { pack, unpack } from '../src/packer';
2 | import { eventWithTime, EventType } from '../src/types';
3 | import { MARK } from '../src/packer/base';
4 |
5 | const event: eventWithTime = {
6 | type: EventType.DomContentLoaded,
7 | data: {},
8 | timestamp: new Date('2020-01-01').getTime(),
9 | };
10 |
11 | describe('pack', () => {
12 | it('can pack event', () => {
13 | const packedData = pack(event);
14 | expect(packedData).toMatchSnapshot();
15 | });
16 | });
17 |
18 | describe('unpack', () => {
19 | it('is compatible with unpacked data 1', () => {
20 | const result = unpack((event as unknown) as string);
21 | expect(result).toEqual(event);
22 | });
23 |
24 | it('is compatible with unpacked data 2', () => {
25 | const result = unpack(JSON.stringify(event));
26 | expect(result).toEqual(event);
27 | });
28 |
29 | it('stop on unknown data format', () => {
30 | const consoleSpy = jest
31 | .spyOn(console, 'error')
32 | .mockImplementation(() => {});
33 |
34 | expect(() => unpack('[""]')).toThrow('');
35 |
36 | expect(consoleSpy).toHaveBeenCalled();
37 | jest.resetAllMocks();
38 | });
39 |
40 | it('can unpack packed data', () => {
41 | const packedData = pack(event);
42 | const result = unpack(packedData);
43 | expect(result).toEqual({
44 | ...event,
45 | v: MARK,
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/docs/recipes/live-mode.md:
--------------------------------------------------------------------------------
1 | # Real-time Replay (Live Mode)
2 |
3 | If you want to replay the events in a real-time way, you can use the live mode API. This API is also useful for some real-time collaboration usage.
4 |
5 | When you are using rrweb's Replayer to do a real-time replay, you need to configure `liveMode: true` and call the `startLive` API to enable the live mode.
6 |
7 | ```js
8 | const ANY_OLD_EVENTS = [];
9 | const replayer = new rrweb.Replayer(ANY_OLD_EVENTS, {
10 | liveMode: true,
11 | });
12 | replayer.startLive();
13 | ```
14 |
15 | Later when you receive new events (e.g. over websockets), you can add them using:
16 |
17 | ```
18 | function onReceive(event) {
19 | replayer.addEvent(event);
20 | }
21 | ```
22 |
23 | When calling the `startLive` API, there is an optional parameter to set the baseline time. By default, this is `Date.now()` so that events are applied as soon as they come in, however this may cause your replay to look laggy. Because data transportation needs time(such as the delay of the network). And some events have been throttled(such as mouse movements) which has a delay by default.
24 |
25 | Here is how you introduce a buffer:
26 |
27 | ```js
28 | const BUFFER_MS = 1000;
29 | replayer.startLive(Date.now() - BUFFER_MS);
30 | ```
31 |
32 | This will let the replay always delay 1 second than the source. If the time of data transportation is not longer than 1 second, the user will not feel laggy.
33 |
--------------------------------------------------------------------------------
/docs/recipes/custom-event.md:
--------------------------------------------------------------------------------
1 | # Custom Event
2 |
3 | You may need to record some custom events along with the rrweb events, and let them be played as other events. The custom event API was designed for this.
4 |
5 | After starting the recording, we can call the `record.addCustomEvent` API to add a custom event.
6 |
7 | ```js
8 | // start recording
9 | rrweb.record({
10 | emit(event) {
11 | ...
12 | }
13 | })
14 |
15 | // record some custom events at any time
16 | rrweb.record.addCustomEvent('submit-form', {
17 | name: 'Adam',
18 | age: 18
19 | })
20 | rrweb.record.addCustomEvent('some-error', {
21 | error
22 | })
23 | ```
24 |
25 | `addCustomEvent` accepts two parameters. The first one is a string-type `tag`, while the second one is an any-type `payload`.
26 |
27 | During the replay, we can add an event listener to custom events, or configure the style of custom events in rrweb-player's timeline.
28 |
29 | **Listen to custom events**
30 |
31 | ```js
32 | const replayer = new rrweb.Replayer(events);
33 |
34 | replayer.on('custom-event', (event) => {
35 | console.log(event.tag, event.payload);
36 | });
37 | ```
38 |
39 | **Display in rrweb-player**
40 |
41 | ```js
42 | new rrwebPlayer({
43 | target: document.body,
44 | props: {
45 | events,
46 | // configure the color of tag which will be displayed on the timeline
47 | tags: {
48 | 'submit-form': '#21e676',
49 | 'some-error': 'red',
50 | },
51 | },
52 | });
53 | ```
54 |
--------------------------------------------------------------------------------
/packages/rrweb/test/replay/webgl-mutation.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { polyfillWebGLGlobals } from '../utils';
6 | polyfillWebGLGlobals();
7 |
8 | import webglMutation from '../../src/replay/canvas/webgl';
9 | import { CanvasContext } from '../../src/types';
10 | import { variableListFor } from '../../src/replay/canvas/deserialize-args';
11 |
12 | let canvas: HTMLCanvasElement;
13 | describe('webglMutation', () => {
14 | beforeEach(() => {
15 | canvas = document.createElement('canvas');
16 | });
17 | afterEach(() => {
18 | jest.clearAllMocks();
19 | });
20 |
21 | it('should create webgl variables', async () => {
22 | const createShaderMock = jest.fn().mockImplementation(() => {
23 | return new WebGLShader();
24 | });
25 | const context = ({
26 | createShader: createShaderMock,
27 | } as unknown) as WebGLRenderingContext;
28 | jest.spyOn(canvas, 'getContext').mockImplementation(() => {
29 | return context;
30 | });
31 |
32 | expect(variableListFor(context, 'WebGLShader')).toHaveLength(0);
33 |
34 | await webglMutation({
35 | mutation: {
36 | property: 'createShader',
37 | args: [35633],
38 | },
39 | type: CanvasContext.WebGL,
40 | target: canvas,
41 | imageMap: new Map(),
42 | errorHandler: () => {},
43 | });
44 |
45 | expect(createShaderMock).toHaveBeenCalledWith(35633);
46 | expect(variableListFor(context, 'WebGLShader')).toHaveLength(1);
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/packages/rrdom/src/style.ts:
--------------------------------------------------------------------------------
1 | export function parseCSSText(cssText: string): Record {
2 | const res: Record = {};
3 | const listDelimiter = /;(?![^(]*\))/g;
4 | const propertyDelimiter = /:(.+)/;
5 | const comment = /\/\*.*?\*\//g;
6 | cssText
7 | .replace(comment, '')
8 | .split(listDelimiter)
9 | .forEach(function (item) {
10 | if (item) {
11 | const tmp = item.split(propertyDelimiter);
12 | tmp.length > 1 && (res[camelize(tmp[0].trim())] = tmp[1].trim());
13 | }
14 | });
15 | return res;
16 | }
17 |
18 | export function toCSSText(style: Record): string {
19 | const properties = [];
20 | for (const name in style) {
21 | const value = style[name];
22 | if (typeof value !== 'string') continue;
23 | const normalizedName = hyphenate(name);
24 | properties.push(`${normalizedName}: ${value};`);
25 | }
26 | return properties.join(' ');
27 | }
28 |
29 | /**
30 | * Camelize a hyphen-delimited string.
31 | */
32 | const camelizeRE = /-([a-z])/g;
33 | const CUSTOM_PROPERTY_REGEX = /^--[a-zA-Z0-9-]+$/;
34 | export const camelize = (str: string): string => {
35 | if (CUSTOM_PROPERTY_REGEX.test(str)) return str;
36 | return str.replace(camelizeRE, (_, c: string) => (c ? c.toUpperCase() : ''));
37 | };
38 |
39 | /**
40 | * Hyphenate a camelCase string.
41 | */
42 | const hyphenateRE = /\B([A-Z])/g;
43 | export const hyphenate = (str: string): string => {
44 | return str.replace(hyphenateRE, '-$1').toLowerCase();
45 | };
46 |
--------------------------------------------------------------------------------
/packages/rrweb/test/html/benchmark-dom-mutation-add-and-remove.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
46 |
47 |
--------------------------------------------------------------------------------
/packages/rrdom/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rrdom",
3 | "version": "0.1.4",
4 | "homepage": "https://github.com/rrweb-io/rrweb/tree/main/packages/rrdom#readme",
5 | "license": "MIT",
6 | "main": "lib/rrdom.js",
7 | "module": "es/rrdom.js",
8 | "typings": "es",
9 | "unpkg": "dist/rrdom.js",
10 | "files": [
11 | "dist",
12 | "lib",
13 | "es",
14 | "typings"
15 | ],
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/rrweb-io/rrweb.git"
19 | },
20 | "scripts": {
21 | "dev": "rollup -c -w",
22 | "bundle": "rollup --config",
23 | "bundle:es-only": "cross-env ES_ONLY=true rollup --config",
24 | "check-types": "tsc -noEmit",
25 | "test": "jest",
26 | "prepublish": "npm run bundle",
27 | "lint": "yarn eslint src/**/*.ts"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/rrweb-io/rrweb/issues"
31 | },
32 | "devDependencies": {
33 | "@rollup/plugin-commonjs": "^20.0.0",
34 | "@types/jest": "^27.4.1",
35 | "@types/puppeteer": "^5.4.4",
36 | "@typescript-eslint/eslint-plugin": "^5.23.0",
37 | "@typescript-eslint/parser": "^5.23.0",
38 | "eslint": "^8.15.0",
39 | "jest": "^27.5.1",
40 | "puppeteer": "^9.1.1",
41 | "rollup": "^2.56.3",
42 | "rollup-plugin-terser": "^7.0.2",
43 | "rollup-plugin-typescript2": "^0.31.2",
44 | "rollup-plugin-web-worker-loader": "^1.6.1",
45 | "ts-jest": "^27.1.3",
46 | "typescript": "^4.7.3"
47 | },
48 | "dependencies": {
49 | "rrweb-snapshot": "^2.0.0-alpha.1"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/rrweb-snapshot/test/html/about-mozilla.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | The Book of Mozilla, 11:9
6 |
37 |
38 |
39 |
40 |
41 |
42 | Mammon slept. And the beast reborn spread over the earth and its numbers
43 | grew legion. And they proclaimed the times and sacrificed crops unto the
44 | fire, with the cunning of foxes. And they built a new world in their own
45 | image as promised by the
46 | sacred words, and spoke
47 | of the beast with their children. Mammon awoke, and lo! it was
48 | naught but a follower.
49 |
50 |
51 |
52 | from The Book of Mozilla, 11:9 (10th Edition)
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/docs/replay.zh_CN.md:
--------------------------------------------------------------------------------
1 | # 回放
2 |
3 | rrweb 的设计原则是尽量少的在录制端进行处理,最大程度减少对被录制页面的影响,因此在回放端我们需要做一些特殊的处理。
4 |
5 | ## 高精度计时器
6 |
7 | 在回放时我们会一次性拿到完整的快照链,如果将所有快照依次同步执行我们可以直接获取被录制页面最后的状态,但是我们需要的是同步初始化第一个全量快照,再异步地按照正确的时间间隔依次重放每一个增量快照,这就需要一个高精度的计时器。
8 |
9 | 之所以强调**高精度**,是因为原生的 `setTimeout` 并不能保证在设置的延迟时间之后准确执行,例如主线程阻塞时就会被推迟。
10 |
11 | 对于我们的回放功能而言,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发生,因此我们通过 `requestAnimationFrame` 来实现一个不断校准的定时器,确保绝大部分情况下增量快照的重放延迟不超过一帧。
12 |
13 | 同时自定义的计时器也是我们实现“快进”功能的基础。
14 |
15 | ## 补全缺失节点
16 |
17 | 在[增量快照设计](./observer.zh_CN.md)中提到了 rrweb 使用 MutationObserver 时的延迟序列化策略,这一策略可能导致以下场景中我们不能记录完整的增量快照:
18 |
19 | ```
20 | parent
21 | child2
22 | child1
23 | ```
24 |
25 | 1. parent 节点插入子节点 child1
26 | 2. parent 节点在 child1 之前插入子节点 child2
27 |
28 | 按照实际执行顺序 child1 会被 rrweb 先序列化,但是在序列化新增节点时我们除了记录父节点之外还需要记录相邻节点,从而保证回放时可以把新增节点放置在正确的位置。但是此时 child 1 相邻节点 child2 已经存在但是还未被序列化,我们会将其记录为 `id: -1`(不存在相邻节点时 id 为 null)。
29 |
30 | 重放时当我们处理到新增 child1 的增量快照时,我们可以通过其相邻节点 id 为 -1 这一特征知道帮助它定位的节点还未生成,然后将它临时放入”缺失节点池“中暂不插入 DOM 树中。
31 |
32 | 之后在处理到新增 child2 的增量快照时,我们正常处理并插入 child2,完成重放之后检查 child2 的相邻节点 id 是否指向缺失节点池中的某个待添加节点,如果吻合则将其从池中取出插入对应位置。
33 |
34 | ## 模拟 Hover
35 |
36 | 在许多前端页面中都会存在 `:hover` 选择器对应的 CSS 样式,但是我们并不能通过 JavaScript 触发 hover 事件。因此回放时我们需要模拟 hover 事件让样式正确显示。
37 |
38 | 具体方式包括两部分:
39 |
40 | 1. 遍历 CSS 样式表,对于 `:hover` 选择器相关 CSS 规则增加一条完全一致的规则,但是选择器为一个特殊的 class,例如 `.:hover`。
41 | 2. 当回放 mouse up 鼠标交互事件时,为事件目标及其所有祖先节点都添加 `.:hover` 类名,mouse down 时再对应移除。
42 |
43 | ## 从任意时间点开始播放
44 |
45 | 除了基础的回放功能之外,我们还希望 `rrweb-player` 这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条至任意时间点播放。
46 |
47 | 实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执行之前的快照链,再正常异步执行之后的快照链就可以做到从任意时间点开始播放的效果。
48 |
--------------------------------------------------------------------------------
/packages/rrdom-nodejs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rrdom-nodejs",
3 | "version": "0.1.4",
4 | "scripts": {
5 | "dev": "rollup -c -w",
6 | "bundle": "rollup --config",
7 | "bundle:es-only": "cross-env ES_ONLY=true rollup --config",
8 | "check-types": "tsc -noEmit",
9 | "test": "jest",
10 | "prepublish": "npm run bundle",
11 | "lint": "yarn eslint src/**/*.ts"
12 | },
13 | "keywords": [
14 | "rrweb",
15 | "rrdom-nodejs"
16 | ],
17 | "license": "MIT",
18 | "main": "lib/rrdom-nodejs.js",
19 | "module": "es/rrdom-nodejs.js",
20 | "typings": "es",
21 | "files": [
22 | "dist",
23 | "lib",
24 | "es",
25 | "typings"
26 | ],
27 | "devDependencies": {
28 | "@rollup/plugin-commonjs": "^20.0.0",
29 | "@rollup/plugin-node-resolve": "^13.0.4",
30 | "@types/cssom": "^0.4.1",
31 | "@types/cssstyle": "^2.2.1",
32 | "@types/jest": "^27.4.1",
33 | "@types/nwsapi": "^2.2.2",
34 | "@types/puppeteer": "^5.4.4",
35 | "@typescript-eslint/eslint-plugin": "^5.23.0",
36 | "@typescript-eslint/parser": "^5.23.0",
37 | "compare-versions": "^4.1.3",
38 | "eslint": "^8.15.0",
39 | "jest": "^27.5.1",
40 | "puppeteer": "^9.1.1",
41 | "rollup": "^2.56.3",
42 | "rollup-plugin-terser": "^7.0.2",
43 | "rollup-plugin-typescript2": "^0.31.2",
44 | "rollup-plugin-web-worker-loader": "^1.6.1",
45 | "ts-jest": "^27.1.3",
46 | "typescript": "^4.7.3"
47 | },
48 | "dependencies": {
49 | "cssom": "^0.5.0",
50 | "cssstyle": "^2.3.0",
51 | "nwsapi": "^2.2.0",
52 | "rrdom": "^0.1.4",
53 | "rrweb-snapshot": "^2.0.0-alpha.1"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/packages/rrweb/test/machine.test.ts:
--------------------------------------------------------------------------------
1 | import { discardPriorSnapshots } from '../src/replay/machine';
2 | import { sampleEvents } from './utils';
3 | import { EventType } from '../src/types';
4 |
5 | const events = sampleEvents.filter(
6 | (e) => ![EventType.DomContentLoaded, EventType.Load].includes(e.type),
7 | );
8 | const nextEvents = events.map((e) => ({
9 | ...e,
10 | timestamp: e.timestamp + 1000,
11 | }));
12 | const nextNextEvents = nextEvents.map((e) => ({
13 | ...e,
14 | timestamp: e.timestamp + 1000,
15 | }));
16 |
17 | describe('get last session', () => {
18 | it('will return all the events when there is only one session', () => {
19 | expect(discardPriorSnapshots(events, events[0].timestamp)).toEqual(events);
20 | });
21 |
22 | it('will return last session when there is more than one in the events', () => {
23 | const multiple = events.concat(nextEvents).concat(nextNextEvents);
24 | expect(
25 | discardPriorSnapshots(
26 | multiple,
27 | nextNextEvents[nextNextEvents.length - 1].timestamp,
28 | ),
29 | ).toEqual(nextNextEvents);
30 | });
31 |
32 | it('will return last session when baseline time is future time', () => {
33 | const multiple = events.concat(nextEvents).concat(nextNextEvents);
34 | expect(
35 | discardPriorSnapshots(
36 | multiple,
37 | nextNextEvents[nextNextEvents.length - 1].timestamp + 1000,
38 | ),
39 | ).toEqual(nextNextEvents);
40 | });
41 |
42 | it('will return all sessions when baseline time is prior time', () => {
43 | expect(discardPriorSnapshots(events, events[0].timestamp - 1000)).toEqual(
44 | events,
45 | );
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/docs/sandbox.zh_CN.md:
--------------------------------------------------------------------------------
1 | # 沙盒
2 |
3 | 在[序列化设计](./serialization.zh_CN.md)中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript,在重建快照的过程中我们将所有 `script` 标签改写为 `noscript` 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 `script` 标签中的,例如 HTML 中的 inline script、表单提交等。
4 |
5 | 脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。
6 |
7 | ## iframe sandbox
8 |
9 | 我们在重建快照时将被录制的 DOM 重建在一个 `iframe` 元素中,通过设置它的 `sandbox` 属性,我们可以禁止以下行为:
10 |
11 | - 表单提交
12 | - `window.open` 等弹出窗
13 | - JS 脚本(包含 inline event handler 和 `` )
14 |
15 | 这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。
16 |
17 | ## 避免链接跳转
18 |
19 | 当点击 a 元素链接时默认事件为跳转至它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页面 DOM 的方式保证视觉上的正确重放,而原本的跳转则应该被禁止执行。
20 |
21 | 通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 `event.preventDefault()` 禁用默认事件。但当我们将回放页面放在沙盒内时,所有的 event handler 都将不被执行,我们也就无法实现事件代理。
22 |
23 | 重新查看我们回放交互事件增量快照的实现,我们会发现其实 `click` 事件是可以不被重放的。因为在禁用 JS 的情况下点击行为并不会产生视觉上的影响,也无需被感知。
24 |
25 | 不过为了优化回放的效果,我们可以在点击时给模拟的鼠标元素添加特殊的动画效果,用来提示观看者此处发生了一次点击。
26 |
27 | ## iframe 样式设置
28 |
29 | 由于我们将 DOM 重建在 iframe 中,所以我们无法通过父页面的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执行的情况下 `noscript` 标签会被显示,而我们希望将其隐藏,就需要动态的向 iframe 中添加样式,示例代码如下:
30 |
31 | ```typescript
32 | const injectStyleRules: string[] = [
33 | 'iframe { background: #f1f3f5 }',
34 | 'noscript { display: none !important; }',
35 | ];
36 |
37 | const styleEl = document.createElement('style');
38 | const { documentElement, head } = this.iframe.contentDocument!;
39 | documentElement!.insertBefore(styleEl, head);
40 | for (let idx = 0; idx < injectStyleRules.length; idx++) {
41 | (styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
42 | }
43 | ```
44 |
45 | 需要注意的是这个插入的 style 元素在被录制页面中并不存在,所以我们不能将其序列化,否则 `id -> Node` 的映射将出现错误。
46 |
--------------------------------------------------------------------------------
/packages/rrweb-snapshot/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2';
2 | import { terser } from 'rollup-plugin-terser';
3 | import pkg from './package.json';
4 |
5 | function toMinPath(path) {
6 | return path.replace(/\.js$/, '.min.js');
7 | }
8 |
9 | let configs = [
10 | // ES module - for building rrweb
11 | {
12 | input: './src/index.ts',
13 | plugins: [typescript()],
14 | output: [
15 | {
16 | format: 'esm',
17 | file: pkg.module,
18 | },
19 | ],
20 | },
21 | ];
22 | let extra_configs = [
23 | // browser
24 | {
25 | input: './src/index.ts',
26 | plugins: [typescript()],
27 | output: [
28 | {
29 | name: 'rrwebSnapshot',
30 | format: 'iife',
31 | file: pkg.unpkg,
32 | },
33 | ],
34 | },
35 | {
36 | input: './src/index.ts',
37 | plugins: [typescript(), terser()],
38 | output: [
39 | {
40 | name: 'rrwebSnapshot',
41 | format: 'iife',
42 | file: toMinPath(pkg.unpkg),
43 | sourcemap: true,
44 | },
45 | ],
46 | },
47 | // CommonJS
48 | {
49 | input: './src/index.ts',
50 | plugins: [typescript()],
51 | output: [
52 | {
53 | format: 'cjs',
54 | file: pkg.main,
55 | },
56 | ],
57 | },
58 | // ES module (packed)
59 | {
60 | input: './src/index.ts',
61 | plugins: [typescript(), terser()],
62 | output: [
63 | {
64 | format: 'esm',
65 | file: toMinPath(pkg.module),
66 | sourcemap: true,
67 | },
68 | ],
69 | },
70 | ];
71 |
72 | if (!process.env.ES_ONLY) {
73 | configs.push(...extra_configs);
74 | }
75 |
76 | export default configs;
77 |
--------------------------------------------------------------------------------
/docs/recipes/customize-replayer.zh_CN.md:
--------------------------------------------------------------------------------
1 | # 自定义回放 UI
2 |
3 | 当 rrweb Replayer 和 rrweb-player 的 UI 不能满足需求时,可以通过自定义回放 UI 制作属于你自己的回放器。
4 |
5 | 你可以通过以下几种方式从不同角度自定义回放 UI:
6 |
7 | 1. 使用 rrweb-player 时,通过覆盖 CSS 样式表定制 UI。
8 | 2. 使用 rrweb-player 时,通过 `showController: false` 隐藏控制器 UI,重新实现控制器 UI。
9 | 3. 通过 `insertStyleRules` 在回放页面(iframe)内定制 CSS 样式。
10 | 4. 基于 rrweb Replayer 开发自己的回放器 UI。
11 |
12 | ## 实现控制器 UI
13 |
14 | 使用 rrweb-player 时,可以隐藏其控制器 UI:
15 |
16 | ```js
17 | new rrwebPlayer({
18 | target: document.body,
19 | props: {
20 | events,
21 | showController: false,
22 | },
23 | });
24 | ```
25 |
26 | 实现自己的控制器 UI 时,你可能需要与 rrweb-player 进行交互。
27 |
28 | 通过 API 控制 rrweb-player:
29 |
30 | ```js
31 | // 在播放和暂停间切换
32 | rrwebPlayer.toggle();
33 | // 播放
34 | rrwebPlayer.play();
35 | // 暂停
36 | rrwebPlayer.pause();
37 | // 更新 rrweb-player 宽高
38 | rrwebPlayer.$set({
39 | width: NEW_WIDTH,
40 | height: NEW_HEIGHT,
41 | });
42 | rrwebPlayer.triggerResize();
43 | // 切换否跳过无操作时间
44 | rrwebPlayer.toggleSkipInactive();
45 | // 设置播放速度为 2 倍
46 | rrwebPlayer.setSpeed(2);
47 | // 跳转至播放 3 秒处
48 | rrwebPlayer.goto(3000);
49 | ```
50 |
51 | 通过监听事件获得 rrweb-player 的状态:
52 |
53 | ```js
54 | // 当前播放时间
55 | rrwebPlayer.addEventListener('ui-update-current-time', (event) => {
56 | console.log(event.payload);
57 | });
58 |
59 | // 当前播放状态
60 | rrwebPlayer.addEventListener('ui-update-player-state', (event) => {
61 | console.log(event.payload);
62 | });
63 |
64 | // 当前播放进度
65 | rrwebPlayer.addEventListener('ui-update-progress', (event) => {
66 | console.log(event.payload);
67 | });
68 | ```
69 |
70 | ## 基于 rrweb Replayer 开发自己的回放器 UI
71 |
72 | 可以参照 [rrweb-player](https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player/) 的方式进行开发。
73 |
--------------------------------------------------------------------------------
/docs/recipes/index.zh_CN.md:
--------------------------------------------------------------------------------
1 | # 场景示例
2 |
3 | > 除场景示例外,你可能还想通过[使用指南](../../guide.zh_CN.md)掌握 rrweb 常用 API,或是通过[设计文档](../)深入 rrweb 的技术细节。
4 |
5 | ## 场景列表
6 |
7 | ### 录制与回放
8 |
9 | 录制与回放是最常用的使用方式,适用于任何需要采集用户行为数据并重新查看的场景。
10 |
11 | [链接](./record-and-replay.zh_CN.md)
12 |
13 | ### 深入录制数据
14 |
15 | 录制数据是一组类型严格的 JSON 数据,通过熟悉其格式,可以更灵活的使用录制数据。
16 |
17 | [链接](./dive-into-event.zh_CN.md)
18 |
19 | ### 异步加载数据
20 |
21 | 当录制的数据较多时,一次性加载至回放页面可能带来较大的网络开销和较长的等待时间。这时可以采取数据分页的方式,异步地加载数据并回放。
22 |
23 | [链接](./pagination.zh_CN.md)
24 |
25 | ### 实时回放(直播)
26 |
27 | 如果希望持续、实时地看到录制的数据,达到类似直播的效果,则可以使用实时回放 API。这个方式也适用于一些实时协同的场景。
28 |
29 | [链接](./live-mode.zh_CN.md)
30 |
31 | ### 自定义事件
32 |
33 | 录制时可能需要在特定的时间点记录一些特定含义的数据,如果希望这部分数据作为回放时的一部分,则可以通过自定义事件的方式实现。
34 |
35 | [链接](./custom-event.zh_CN.md)
36 |
37 | ### 回放时与 UI 交互
38 |
39 | 回放时的 UI 默认不可交互,但在特定场景下也可以通过 API 允许用户与回放场景进行交互。
40 |
41 | [链接](./interaction.zh_CN.md)
42 |
43 | ### 自定义回放 UI
44 |
45 | 当 rrweb Replayer 和 rrweb-player 的 UI 不能满足需求时,可以通过自定义回放 UI 制作属于你自己的回放器。
46 |
47 | [链接](./customize-replayer.zh_CN.md)
48 |
49 | ### 转换为视频
50 |
51 | rrweb 录制的数据是一种高效、易于压缩的文本格式,可以用于像素级的回放。但如果有进一步将录制数据转换为视频的需求,同样可以通过一些工具实现。
52 |
53 | [链接](./export-to-video.zh_CN.md)
54 |
55 | ### 优化存储容量
56 |
57 | 在一些场景下 rrweb 的录制数据量可能高于你的预期,这部分文档可以帮助你选择适用于你的存储优化策略。
58 |
59 | [链接](./optimize-storage.zh_CN.md)
60 |
61 | ### Canvas
62 |
63 | Canvas 是一种特殊的 HTML 元素,默认情况下其内容不会被 rrweb 观测。我们可以通过特定的配置让 rrweb 能够录制并回放 Canvas。
64 |
65 | [链接](./canvas.zh_CN.md)
66 |
67 | ### console 录制和播放
68 |
69 | 从 v1.0.0 版本开始,我们以插件的形式增加了录制和播放控制台输出的功能。这个功能旨在为开发者提供更多的 bug 信息。对这项功能我们还提供了一些设置选项。
70 |
71 | [链接](./console.zh_CN.md)
72 |
73 | ### 插件
74 |
75 | 插件 API 的设计目标是在不增加 rrweb 核心部分大小和复杂性的前提下,扩展 rrweb 的功能。
76 |
77 | [链接](./plugin.zh_CN.md)
78 |
--------------------------------------------------------------------------------
/packages/rrweb/src/replay/canvas/2d.ts:
--------------------------------------------------------------------------------
1 | import type { Replayer } from '../';
2 | import type { canvasMutationCommand } from '../../types';
3 | import { deserializeArg } from './deserialize-args';
4 |
5 | export default async function canvasMutation({
6 | event,
7 | mutation,
8 | target,
9 | imageMap,
10 | errorHandler,
11 | }: {
12 | event: Parameters[0];
13 | mutation: canvasMutationCommand;
14 | target: HTMLCanvasElement;
15 | imageMap: Replayer['imageMap'];
16 | errorHandler: Replayer['warnCanvasMutationFailed'];
17 | }): Promise {
18 | try {
19 | const ctx = target.getContext('2d')!;
20 |
21 | if (mutation.setter) {
22 | // skip some read-only type checks
23 | ((ctx as unknown) as Record)[mutation.property] =
24 | mutation.args[0];
25 | return;
26 | }
27 | const original = ctx[
28 | mutation.property as Exclude
29 | ] as (ctx: CanvasRenderingContext2D, args: unknown[]) => void;
30 |
31 | /**
32 | * We have serialized the image source into base64 string during recording,
33 | * which has been preloaded before replay.
34 | * So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
35 | */
36 | if (
37 | mutation.property === 'drawImage' &&
38 | typeof mutation.args[0] === 'string'
39 | ) {
40 | imageMap.get(event);
41 | original.apply(ctx, mutation.args);
42 | } else {
43 | const args = await Promise.all(
44 | mutation.args.map(deserializeArg(imageMap, ctx)),
45 | );
46 | original.apply(ctx, args);
47 | }
48 | } catch (error) {
49 | errorHandler(mutation, error);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/rrweb-snapshot/test/images/symbol-defs.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Suggest an idea for rrweb
3 | title: '[Feature Request]: '
4 | labels:
5 | - 'feature request'
6 | body:
7 | - type: checkboxes
8 | attributes:
9 | label: Preflight Checklist
10 | description: Please ensure you've completed all of the following.
11 | options:
12 | - label: I have searched the [issue tracker](https://www.github.com/rrweb-io/rrweb/issues) for a feature request that matches the one I want to file, without success.
13 | required: true
14 | - type: dropdown
15 | attributes:
16 | label: What package is this feature request for?
17 | options:
18 | - rrweb
19 | - rrweb-snapshot
20 | - rrdom
21 | - rrweb-player
22 | - Other (specify below)
23 | validations:
24 | required: true
25 | - type: textarea
26 | attributes:
27 | label: Problem Description
28 | description: Please add a clear and concise description of the problem you are seeking to solve with this feature request.
29 | validations:
30 | required: true
31 | - type: textarea
32 | attributes:
33 | label: Proposed Solution
34 | description: Describe the solution you'd like in a clear and concise manner.
35 | validations:
36 | required: true
37 | - type: textarea
38 | attributes:
39 | label: Alternatives Considered
40 | description: A clear and concise description of any alternative solutions or features you've considered.
41 | validations:
42 | required: true
43 | - type: textarea
44 | attributes:
45 | label: Additional Information
46 | description: Add any other context about the problem here.
47 | validations:
48 | required: false
49 |
--------------------------------------------------------------------------------
/packages/rrweb-snapshot/README.md:
--------------------------------------------------------------------------------
1 | # rrweb-snapshot
2 |
3 | [](https://travis-ci.org/rrweb-io/rrweb) [](https://join.slack.com/t/rrweb/shared_invite/zt-siwoc6hx-uWay3s2wyG8t5GpZVb8rWg)
4 |
5 | Snapshot the DOM into a stateful and serializable data structure.
6 | Also, provide the ability to rebuild the DOM via snapshot.
7 |
8 | ## API
9 |
10 | This module export following methods:
11 |
12 | ### snapshot
13 |
14 | `snapshot` will traverse the DOM and return a stateful and serializable data structure which can represent the current DOM **view**.
15 |
16 | There are several things will be done during snapshot:
17 |
18 | 1. Inline some DOM states into HTML attributes, e.g, HTMLInputElement's value.
19 | 2. Turn script tags into `noscript` tags to avoid scripts being executed.
20 | 3. Try to inline stylesheets to make sure local stylesheets can be used.
21 | 4. Make relative paths in href, src, CSS to be absolute paths.
22 | 5. Give an id to each Node, and return the id node map when snapshot finished.
23 |
24 | #### rebuild
25 |
26 | `rebuild` will build the DOM according to the taken snapshot.
27 |
28 | There are several things will be done during rebuild:
29 |
30 | 1. Add data-rrid attribute if the Node is an Element.
31 | 2. Create some extra DOM node like text node to place inline CSS and some states.
32 | 3. Add data-extra-child-index attribute if Node has some extra child DOM.
33 |
34 | #### serializeNodeWithId
35 |
36 | `serializeNodeWithId` can serialize a node into snapshot format with id.
37 |
38 | #### buildNodeWithSN
39 |
40 | `buildNodeWithSN` will build DOM from serialized node and store serialized information in the `mirror.getMeta(node)`.
41 |
--------------------------------------------------------------------------------
/packages/rrweb/src/rrdom/tree-node.ts:
--------------------------------------------------------------------------------
1 | export type AnyObject = { [key: string]: any; __rrdom__?: RRdomTreeNode };
2 |
3 | export class RRdomTreeNode implements AnyObject {
4 | public parent: AnyObject | null = null;
5 | public previousSibling: AnyObject | null = null;
6 | public nextSibling: AnyObject | null = null;
7 |
8 | public firstChild: AnyObject | null = null;
9 | public lastChild: AnyObject | null = null;
10 |
11 | // This value is incremented anytime a children is added or removed
12 | public childrenVersion = 0;
13 | // The last child object which has a cached index
14 | public childIndexCachedUpTo: AnyObject | null = null;
15 |
16 | /**
17 | * This value represents the cached node index, as long as
18 | * cachedIndexVersion matches with the childrenVersion of the parent
19 | */
20 | public cachedIndex = -1;
21 | public cachedIndexVersion = NaN;
22 |
23 | public get isAttached() {
24 | return Boolean(this.parent || this.previousSibling || this.nextSibling);
25 | }
26 |
27 | public get hasChildren() {
28 | return Boolean(this.firstChild);
29 | }
30 |
31 | public childrenChanged() {
32 | this.childrenVersion = (this.childrenVersion + 1) & 0xffffffff;
33 | this.childIndexCachedUpTo = null;
34 | }
35 |
36 | public getCachedIndex(parentNode: AnyObject) {
37 | if (this.cachedIndexVersion !== parentNode.childrenVersion) {
38 | this.cachedIndexVersion = NaN;
39 | // cachedIndex is no longer valid
40 | return -1;
41 | }
42 |
43 | return this.cachedIndex;
44 | }
45 |
46 | public setCachedIndex(parentNode: AnyObject, index: number) {
47 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
48 | this.cachedIndexVersion = parentNode.childrenVersion;
49 | this.cachedIndex = index;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/rrweb-player/src/components/Switch.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
74 |
75 |