├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── config.yml └── workflows │ └── style-check.yml ├── .gitignore ├── .markdownlint.yml ├── .npmignore ├── .prettierrc ├── .release-it.json ├── .travis.yml ├── .vscode └── rrweb-monorepo.code-workspace ├── .yarn └── releases │ └── yarn-1.23.0-20220130.1630.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── README.zh_CN.md ├── docs ├── development │ └── coding-style.md ├── observer.md ├── observer.zh_CN.md ├── recipes │ ├── canvas.md │ ├── canvas.zh_CN.md │ ├── console.md │ ├── console.zh_CN.md │ ├── custom-event.md │ ├── custom-event.zh_CN.md │ ├── customize-replayer.md │ ├── customize-replayer.zh_CN.md │ ├── dive-into-event.md │ ├── dive-into-event.zh_CN.md │ ├── export-to-video.md │ ├── export-to-video.zh_CN.md │ ├── index.md │ ├── index.zh_CN.md │ ├── interaction.md │ ├── interaction.zh_CN.md │ ├── live-mode.md │ ├── live-mode.zh_CN.md │ ├── optimize-storage.md │ ├── optimize-storage.zh_CN.md │ ├── pagination.md │ ├── pagination.zh_CN.md │ ├── plugin.md │ ├── plugin.zh_CN.md │ ├── record-and-replay.md │ └── record-and-replay.zh_CN.md ├── replay.md ├── replay.zh_CN.md ├── sandbox.md ├── sandbox.zh_CN.md ├── serialization.md ├── serialization.zh_CN.md └── styleguilde.md ├── guide.md ├── guide.zh_CN.md ├── lerna.json ├── package.json ├── packages ├── rrdom-nodejs │ ├── .gitignore │ ├── .vscode │ │ ├── extensions.json │ │ ├── launch.json │ │ └── settings.json │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── document-nodejs.ts │ │ ├── index.ts │ │ └── polyfill.ts │ ├── test │ │ ├── document-nodejs.test.ts │ │ └── polyfill.test.ts │ └── tsconfig.json ├── rrdom │ ├── .gitignore │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── diff.ts │ │ ├── document.ts │ │ ├── index.ts │ │ └── style.ts │ ├── test │ │ ├── __snapshots__ │ │ │ └── virtual-dom.test.ts.snap │ │ ├── diff.test.ts │ │ ├── document.test.ts │ │ ├── html │ │ │ ├── iframe.html │ │ │ ├── main.html │ │ │ └── shadow-dom.html │ │ └── virtual-dom.test.ts │ └── tsconfig.json ├── rrweb-player │ ├── .eslintrc.json │ ├── .gitignore │ ├── .release-it.json │ ├── README.md │ ├── package.json │ ├── public │ │ ├── events.js │ │ ├── global.css │ │ └── index.html │ ├── rollup.config.js │ ├── src │ │ ├── Controller.svelte │ │ ├── Player.svelte │ │ ├── components │ │ │ └── Switch.svelte │ │ ├── main.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── typings │ │ └── index.d.ts ├── rrweb-snapshot │ ├── .gitignore │ ├── .release-it.json │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── css.ts │ │ ├── index.ts │ │ ├── rebuild.ts │ │ ├── snapshot.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── test │ │ ├── __snapshots__ │ │ │ └── integration.test.ts.snap │ │ ├── css.test.ts │ │ ├── css │ │ │ ├── benchmark.css │ │ │ ├── style-with-import.css │ │ │ └── style.css │ │ ├── html │ │ │ ├── about-mozilla.html │ │ │ ├── basic.html │ │ │ ├── block-element.html │ │ │ ├── compat-mode.html │ │ │ ├── cors-style-sheet.html │ │ │ ├── dynamic-stylesheet.html │ │ │ ├── form-fields.html │ │ │ ├── hover.html │ │ │ ├── iframe-inner.html │ │ │ ├── iframe.html │ │ │ ├── invalid-attribute.html │ │ │ ├── invalid-doctype.html │ │ │ ├── invalid-tagname.html │ │ │ ├── mask-text.html │ │ │ ├── picture-blob-in-frame.html │ │ │ ├── picture-blob.html │ │ │ ├── picture-in-frame.html │ │ │ ├── picture.html │ │ │ ├── preload.html │ │ │ ├── shadow-dom.html │ │ │ ├── svg.html │ │ │ ├── video.html │ │ │ ├── with-relative-res.html │ │ │ ├── with-script.html │ │ │ ├── with-style-sheet-with-import.html │ │ │ └── with-style-sheet.html │ │ ├── iframe-html │ │ │ ├── frame1.html │ │ │ ├── frame2.html │ │ │ └── main.html │ │ ├── images │ │ │ ├── compat-bottom.png │ │ │ ├── compat-top-left.png │ │ │ ├── compat-top-right.png │ │ │ ├── robot.png │ │ │ └── symbol-defs.svg │ │ ├── integration.test.ts │ │ ├── js │ │ │ └── a.js │ │ ├── rebuild.test.ts │ │ ├── snapshot.test.ts │ │ └── utils.ts │ └── tsconfig.json └── rrweb │ ├── .gitignore │ ├── .release-it.json │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── scripts │ ├── repl.js │ ├── stream.js │ └── utils.js │ ├── src │ ├── entries │ │ ├── all.ts │ │ ├── record-pack.ts │ │ └── replay-unpack.ts │ ├── index.ts │ ├── packer │ │ ├── base.ts │ │ ├── index.ts │ │ ├── pack.ts │ │ └── unpack.ts │ ├── plugins │ │ ├── canvas-webrtc │ │ │ ├── Readme.md │ │ │ ├── record │ │ │ │ └── index.ts │ │ │ ├── replay │ │ │ │ └── index.ts │ │ │ ├── simple-peer-light.d.ts │ │ │ └── types.ts │ │ ├── console │ │ │ ├── record │ │ │ │ ├── error-stack-parser.ts │ │ │ │ ├── index.ts │ │ │ │ └── stringify.ts │ │ │ └── replay │ │ │ │ └── index.ts │ │ └── sequential-id │ │ │ ├── record │ │ │ └── index.ts │ │ │ └── replay │ │ │ └── index.ts │ ├── record │ │ ├── iframe-manager.ts │ │ ├── index.ts │ │ ├── mutation.ts │ │ ├── observer.ts │ │ ├── observers │ │ │ └── canvas │ │ │ │ ├── 2d.ts │ │ │ │ ├── canvas-manager.ts │ │ │ │ ├── canvas.ts │ │ │ │ ├── serialize-args.ts │ │ │ │ └── webgl.ts │ │ ├── shadow-dom-manager.ts │ │ ├── stylesheet-manager.ts │ │ └── workers │ │ │ ├── image-bitmap-data-url-worker.ts │ │ │ ├── tsconfig.json │ │ │ └── workers.d.ts │ ├── replay │ │ ├── canvas │ │ │ ├── 2d.ts │ │ │ ├── deserialize-args.ts │ │ │ ├── index.ts │ │ │ └── webgl.ts │ │ ├── index.ts │ │ ├── machine.ts │ │ ├── smoothscroll.ts │ │ ├── styles │ │ │ ├── inject-style.ts │ │ │ └── style.css │ │ └── timer.ts │ ├── rrdom │ │ ├── index.ts │ │ └── tree-node.ts │ ├── types.ts │ └── utils.ts │ ├── test │ ├── __snapshots__ │ │ ├── integration.test.ts.snap │ │ ├── packer.test.ts.snap │ │ ├── record.test.ts.snap │ │ └── replayer.test.ts.snap │ ├── benchmark │ │ ├── dom-mutation.test.ts │ │ └── replay-fast-forward.test.ts │ ├── e2e │ │ ├── __image_snapshots__ │ │ │ ├── webgl-test-ts-e-2-e-webgl-will-record-and-replay-a-webgl-image-1-snap.png │ │ │ └── webgl-test-ts-e-2-e-webgl-will-record-and-replay-a-webgl-square-1-snap.png │ │ └── webgl.test.ts │ ├── events │ │ ├── canvas-in-iframe.ts │ │ ├── iframe.ts │ │ ├── input.ts │ │ ├── ordering.ts │ │ ├── scroll.ts │ │ ├── selection.ts │ │ ├── shadow-dom.ts │ │ ├── style-sheet-rule-events.ts │ │ ├── style-sheet-text-mutation.ts │ │ └── webgl.ts │ ├── html │ │ ├── assets │ │ │ ├── robot.png │ │ │ └── webgl-utils.js │ │ ├── benchmark-dom-mutation-add-and-remove.html │ │ ├── benchmark-dom-mutation.html │ │ ├── block.html │ │ ├── blocked-unblocked.html │ │ ├── canvas-webgl-image.html │ │ ├── canvas-webgl-square.html │ │ ├── canvas-webgl.html │ │ ├── canvas.html │ │ ├── form.html │ │ ├── frame-image-blob-url.html │ │ ├── frame1.html │ │ ├── frame2.html │ │ ├── ignore.html │ │ ├── image-blob-url.html │ │ ├── log.html │ │ ├── main.html │ │ ├── mask-text.html │ │ ├── move-node.html │ │ ├── mutation-observer.html │ │ ├── password.html │ │ ├── polyfilled-shadowdom-mutation.html │ │ ├── react-styled-components.html │ │ ├── select2.html │ │ ├── shadow-dom.html │ │ └── shuffle.html │ ├── integration.test.ts │ ├── machine.test.ts │ ├── packer.test.ts │ ├── record.test.ts │ ├── record │ │ ├── __snapshots__ │ │ │ └── webgl.test.ts.snap │ │ ├── serialize-args.test.ts │ │ └── webgl.test.ts │ ├── replay │ │ ├── __image_snapshots__ │ │ │ └── webgl-test-ts-replayer-webgl-should-output-simple-webgl-object-1-snap.png │ │ ├── deserialize-args.test.ts │ │ ├── preload-all-images.test.ts │ │ ├── webgl-mutation.test.ts │ │ └── webgl.test.ts │ ├── replayer.test.ts │ └── utils.ts │ └── tsconfig.json ├── tsconfig.eslint.json ├── tsconfig.json ├── turbo.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | 'jest/globals': true, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | sourceType: 'module', 17 | tsconfigRootDir: __dirname, 18 | project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'], 19 | }, 20 | plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc', 'jest'], 21 | rules: { 22 | 'tsdoc/syntax': 'warn', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Yuyz0112] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report an rrweb bug 3 | title: '[Bug]: ' 4 | labels: 5 | - 'bug' 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 bug report that matches the one I want to file, without success. 13 | required: true 14 | - type: dropdown 15 | attributes: 16 | label: What package is this bug report for? 17 | options: 18 | - rrweb 19 | - rrweb-snapshot 20 | - rrdom 21 | - rrweb-player 22 | - Other (specify below) 23 | validations: 24 | required: true 25 | - type: input 26 | attributes: 27 | label: Version 28 | description: What's the version of the package? 29 | placeholder: v1.0.0 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Expected Behavior 35 | description: A clear and concise description of what you expected to happen. 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: Actual Behavior 41 | description: A clear description of what actually happens. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Steps to Reproduce 47 | description: Describe detailed steps for reproducing the issue. 48 | validations: 49 | required: true 50 | - type: input 51 | attributes: 52 | label: Testcase Gist URL 53 | description: If you can reproduce the issue in a standalone test case, please save your recording events.json to a [GitHub gist](https://gist.github.com), paste that gist link into [rrwebdebug.com](https://rrwebdebug.com) and put the rrwebdebug.com URL here. This is **the best way** to ensure this issue is triaged quickly. 54 | placeholder: https://rrwebdebug.com/... 55 | - type: textarea 56 | attributes: 57 | label: Additional Information 58 | description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here. 59 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/style-check.yml: -------------------------------------------------------------------------------- 1 | name: Code Style Check 2 | 3 | on: [push, pull_request_target] 4 | 5 | jobs: 6 | eslint_check_upload: 7 | runs-on: ubuntu-latest 8 | name: ESLint Check and Report Upload 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | repository: ${{ github.event.pull_request.head.repo.full_name }} 14 | ref: ${{ github.head_ref }} 15 | - name: Setup Node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | cache: 'yarn' 20 | - name: Install Dependencies 21 | run: yarn 22 | - name: Build Packages 23 | run: yarn build:all 24 | - name: Eslint Check 25 | run: yarn turbo run lint 26 | - name: Save Code Linting Report JSON 27 | run: yarn lint:report 28 | # Continue to the next step even if this fails 29 | continue-on-error: true 30 | - name: Upload ESLint report 31 | uses: actions/upload-artifact@v3 32 | with: 33 | name: eslint_report.json 34 | path: eslint_report.json 35 | 36 | annotation: 37 | # Skip the annotation action in push events 38 | if: github.event_name == 'pull_request_target' 39 | needs: eslint_check_upload 40 | runs-on: ubuntu-latest 41 | name: ESLint Annotation 42 | steps: 43 | - uses: actions/download-artifact@v3 44 | with: 45 | name: eslint_report.json 46 | - name: Annotate Code Linting Results 47 | uses: ataylorme/eslint-annotate-action@v2 48 | with: 49 | repo-token: '${{ secrets.GITHUB_TOKEN }}' 50 | report-json: 'eslint_report.json' 51 | 52 | prettier_check: 53 | # In the forked PR, it's hard to format code and push to the branch directly. 54 | if: github.event_name != 'push' && github.event.pull_request.head.repo.full_name != 'rrweb-io/rrweb' 55 | runs-on: ubuntu-latest 56 | name: Format Check 57 | steps: 58 | - uses: actions/checkout@v3 59 | with: 60 | repository: ${{ github.event.pull_request.head.repo.full_name }} 61 | ref: ${{ github.head_ref }} 62 | - name: Setup Node 63 | uses: actions/setup-node@v3 64 | with: 65 | node-version: 16 66 | cache: 'yarn' 67 | - name: Install Dependencies 68 | run: yarn 69 | - name: Prettify code 70 | run: yarn prettier --check '**/*.{ts,md}' 71 | 72 | prettier: 73 | # Skip the format code action in forked PRs 74 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'rrweb-io/rrweb' 75 | runs-on: ubuntu-latest 76 | name: Format Code 77 | steps: 78 | - uses: actions/checkout@v3 79 | with: 80 | repository: ${{ github.event.pull_request.head.repo.full_name }} 81 | ref: ${{ github.head_ref }} 82 | - name: Setup Node 83 | uses: actions/setup-node@v3 84 | with: 85 | node-version: 16 86 | cache: 'yarn' 87 | - name: Install Dependencies 88 | run: yarn 89 | - name: Prettify code 90 | run: yarn prettier --write '**/*.{ts,md}' 91 | - name: Commit changes 92 | uses: stefanzweifel/git-auto-commit-action@v4 93 | with: 94 | commit_message: Apply formatting changes 95 | branch: ${{ github.head_ref }} 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | !/.vscode/rrweb-monorepo.code-workspace 3 | .idea 4 | node_modules 5 | package-lock.json 6 | tsconfig.tsbuildinfo 7 | 8 | temp 9 | 10 | *.log 11 | 12 | .env 13 | 14 | .DS_Store 15 | 16 | build 17 | dist 18 | 19 | .turbo 20 | 21 | # `.yarn/install-state.gz` is an optimization file that you shouldn't ever have to commit. 22 | # It simply stores the exact state of your project so that the next commands can boot without having to resolve your workspaces all over again. 23 | .yarn/install-state.gz -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | node_modules 4 | package-lock.json 5 | yarn.lock 6 | temp 7 | *.log 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": {}, 3 | "git": { 4 | "commit": false, 5 | "tag": false, 6 | "push": false 7 | }, 8 | "npm": { 9 | "publish": false 10 | }, 11 | "github": { 12 | "release": true, 13 | "releaseName": "Release ${version}" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | os: linux 4 | 5 | dist: focal 6 | 7 | node_js: 8 | - lts/* 9 | 10 | install: 11 | - yarn 12 | 13 | script: 14 | - yarn build:all 15 | - yarn turbo run check-types 16 | - xvfb-run --server-args="-screen 0 1920x1080x24" yarn test 17 | -------------------------------------------------------------------------------- /.vscode/rrweb-monorepo.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": " rrweb monorepo", // added a space to bump it to the top 5 | "path": ".." 6 | }, 7 | { 8 | "name": "rrdom (package)", 9 | "path": "../packages/rrdom" 10 | }, 11 | { 12 | "name": "rrdom-nodejs (package)", 13 | "path": "../packages/rrdom-nodejs" 14 | }, 15 | { 16 | "name": "rrweb (package)", 17 | "path": "../packages/rrweb" 18 | }, 19 | { 20 | "name": "rrweb-player (package)", 21 | "path": "../packages/rrweb-player" 22 | }, 23 | { 24 | "name": "rrweb-snapshot (package)", 25 | "path": "../packages/rrweb-snapshot" 26 | } 27 | ], 28 | "settings": { 29 | "jest.disabledWorkspaceFolders": [ 30 | " rrweb monorepo", 31 | "rrweb-player (package)" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: '.yarn/releases/yarn-1.23.0-20220130.1630.cjs' 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/development/coding-style.md: -------------------------------------------------------------------------------- 1 | # Coding Style 2 | 3 | These are the style guidelines for coding in Electron. 4 | 5 | You can run `yarn lint` to show any style issues detected by `eslint`. 6 | 7 | ## General Code 8 | 9 | - End files with a newline. 10 | - Using a plain `return` when returning explicitly at the end of a function. 11 | - Not `return null`, `return undefined`, `null` or `undefined` 12 | 13 | ## Documentation 14 | 15 | - Write [remark](https://github.com/remarkjs/remark) markdown style. 16 | 17 | 19 | 20 | ## TypeScript 21 | 22 | - Write [standard](https://www.npmjs.com/package/standard) JavaScript style. 23 | - File names should be concatenated with `-` instead of `_`, e.g. 24 | `file-name.js` rather than `file_name.js`, because in 25 | [github/atom](https://github.com/github/atom) module names are usually in 26 | the `module-name` form. This rule only applies to `.js` files. 27 | - Use newer ES6/ES2015 syntax where appropriate 28 | - [`const`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) 29 | for requires and other constants. If the value is a primitive, use uppercase naming (eg `const NUMBER_OF_RETRIES = 5`). 30 | - [`let`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let) 31 | for defining variables 32 | - [Arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) 33 | instead of `function () { }` 34 | - [Template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) 35 | instead of string concatenation using `+` 36 | 37 | ## Naming Things 38 | 39 | Electron APIs uses the same capitalization scheme as Node.js: 40 | 41 | - When the module itself is a class like `BrowserWindow`, use `PascalCase`. 42 | - When the module is a set of APIs, like `globalShortcut`, use `camelCase`. 43 | - When the API is a property of object, and it is complex enough to be in a 44 | separate chapter like `win.webContents`, use `mixedCase`. 45 | - For other non-module APIs, use natural titles, like ` Tag` or 46 | `Process Object`. 47 | 48 | When creating a new API, it is preferred to use getters and setters instead of 49 | jQuery's one-function style. For example, `.getText()` and `.setText(text)` 50 | are preferred to `.text([text])`. There is a 51 | [discussion](https://github.com/electron/electron/issues/46) on this. 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/recipes/canvas.zh_CN.md: -------------------------------------------------------------------------------- 1 | # Canvas 2 | 3 | Canvas 是一种特殊的 HTML 元素,默认情况下其内容不会被 rrweb 观测。我们可以通过特定的配置让 rrweb 能够录制并回放 Canvas。 4 | 5 | 录制时包含 Canvas 内的内容: 6 | 7 | ```js 8 | rrweb.record({ 9 | emit(event) {}, 10 | // 对 canvas 进行录制 11 | recordCanvas: true, 12 | }); 13 | ``` 14 | 15 | 或者启用每秒 15 帧的 Canvas 图像快照记录: 16 | 17 | ```js 18 | rrweb.record({ 19 | emit(event) {}, 20 | recordCanvas: true, 21 | sampling: { 22 | canvas: 15, 23 | }, 24 | // 图像的格式 25 | dataURLOptions: { 26 | type: 'image/webp', 27 | quality: 0.6, 28 | }, 29 | }); 30 | ``` 31 | 32 | 回放时对 Canvas 进行回放: 33 | 34 | ```js 35 | const replayer = new rrweb.Replayer(events, { 36 | UNSAFE_replayCanvas: true, 37 | }); 38 | replayer.play(); 39 | ``` 40 | 41 | **回放 Canvas 将会关闭沙盒策略,导致一定风险**。 42 | 43 | 另外,您可以使用 canvas-webrtc 插件通过 WEBRTC 流式传输 Canvas 元素。 44 | 有关更多信息,请参考[canvas-webrtc 文档](../../packages/rrweb/src/plugins/canvas-webrtc/readme.md) 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/recipes/customize-replayer.md: -------------------------------------------------------------------------------- 1 | # Customize the Replayer 2 | 3 | When rrweb's Replayer and the rrweb-player UI do not fit your need, you can customize your replayer UI. 4 | 5 | There are several ways to do this: 6 | 7 | 1. Use rrweb-player, and customize its CSS. 8 | 2. Use rrweb-player, and set `showController: false` to hide the controller UI. With this config, you can implement your controller UI. 9 | 3. Use the `insertStyleRules` options to inject some CSS into the replay iframe. 10 | 4. Develop a new replayer UI with rrweb's Replayer. 11 | 12 | ## Implement Your Controller UI 13 | 14 | When using rrweb-player, you can hide its controller UI: 15 | 16 | ```js 17 | new rrwebPlayer({ 18 | target: document.body, 19 | props: { 20 | events, 21 | showController: false, 22 | }, 23 | }); 24 | ``` 25 | 26 | When you are implementing a controller UI, you may need to interact with rrweb-player. 27 | 28 | The follwing APIs show some common use case of a controller UI: 29 | 30 | ```js 31 | // toggle between play and pause 32 | rrwebPlayer.toggle(); 33 | // play 34 | rrwebPlayer.play(); 35 | // pause 36 | rrwebPlayer.pause(); 37 | // update the dimension 38 | rrwebPlayer.$set({ 39 | width: NEW_WIDTH, 40 | height: NEW_HEIGHT, 41 | }); 42 | rrwebPlayer.triggerResize(); 43 | // toggle whether to skip the inactive time 44 | rrwebPlayer.toggleSkipInactive(); 45 | // set replay speed 46 | rrwebPlayer.setSpeed(2); 47 | // go to some timing 48 | rrwebPlayer.goto(3000); 49 | ``` 50 | 51 | And there are some ways to listen rrweb-player's state: 52 | 53 | ```js 54 | // get current timing 55 | rrwebPlayer.addEventListener('ui-update-current-time', (event) => { 56 | console.log(event.payload); 57 | }); 58 | 59 | // get current state 60 | rrwebPlayer.addEventListener('ui-update-player-state', (event) => { 61 | console.log(event.payload); 62 | }); 63 | 64 | // get current progress 65 | rrwebPlayer.addEventListener('ui-update-progress', (event) => { 66 | console.log(event.payload); 67 | }); 68 | ``` 69 | 70 | ## Develop a new replayer UI with rrweb's Replayer. 71 | 72 | Please refer [rrweb-player](https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player/). 73 | -------------------------------------------------------------------------------- /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/dive-into-event.md: -------------------------------------------------------------------------------- 1 | # Dive Into Events 2 | 3 | The events recorded by rrweb are a set of strictly-typed JSON data. You may discover some flexible ways to use them when you are familiar with the details. 4 | 5 | ## Data Types 6 | 7 | Every event has a `timestamp` attribute to record the time it was emitted. 8 | 9 | There is also a `type` attribute indicates the event's type, the semantic of event's type is: 10 | 11 | ``` 12 | type -> EventType.DomContentLoaded 13 | event -> domContentLoadedEvent 14 | 15 | type = EventType.Load 16 | event -> loadedEvent 17 | 18 | type -> EventType.FullSnapshot 19 | event -> fullSnapshotEvent 20 | 21 | type -> EventType.IncrementalSnapshot 22 | event -> incrementalSnapshotEvent 23 | 24 | type -> EventType.Meta 25 | event -> metaEvent 26 | 27 | type -> EventType.Custom 28 | event -> customEvent 29 | ``` 30 | 31 | The EventType is Typescript's numeric enum, which is a self-increased number from 0 in runtime. You can find its definition in this [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L10). 32 | 33 | In these kinds of events, the incrementalSnapshotEvent is the event that contains incremental data. You can use `event.data.source` to find which kind of incremental data it belongs to: 34 | 35 | ``` 36 | source -> IncrementalSource.Mutation 37 | data -> mutationData 38 | 39 | source -> IncrementalSource.MouseMove 40 | data -> mousemoveData 41 | 42 | source -> IncrementalSource.MouseInteraction 43 | data -> mouseInteractionData 44 | 45 | source -> IncrementalSource.Scroll 46 | data -> scrollData 47 | 48 | source -> IncrementalSource.ViewportResize 49 | data -> viewportResizeData 50 | 51 | source -> IncrementalSource.Input 52 | data -> inputData 53 | 54 | source -> IncrementalSource.TouchMove 55 | data -> mouseInteractionData 56 | 57 | source -> IncrementalSource.MediaInteraction 58 | data -> mediaInteractionData 59 | 60 | source -> IncrementalSource.StyleSheetRule 61 | data -> styleSheetRuleData 62 | 63 | source -> IncrementalSource.CanvasMutation 64 | data -> canvasMutationData 65 | 66 | source -> IncrementalSource.Font 67 | data -> fontData 68 | ``` 69 | 70 | enum IncrementalSource's definition can be found in this [list](https://github.com/rrweb-io/rrweb/blob/master/packages/rrweb/typings/types.d.ts#L62). 71 | -------------------------------------------------------------------------------- /docs/recipes/dive-into-event.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 深入录制数据 2 | 3 | 录制数据是一组类型严格的 JSON 数据,通过熟悉其格式,可以更灵活的使用录制数据。 4 | 5 | ## 数据类型 6 | 7 | 每个 event 都拥有 `timestamp` 属性用于标记时间戳。 8 | 9 | 除此之外,也都拥有 `type` 属性标记 event 类型,其对应关系如下: 10 | 11 | ``` 12 | type -> EventType.DomContentLoaded 13 | event -> domContentLoadedEvent 14 | 15 | type = EventType.Load 16 | event -> loadedEvent 17 | 18 | type -> EventType.FullSnapshot 19 | event -> fullSnapshotEvent 20 | 21 | type -> EventType.IncrementalSnapshot 22 | event -> incrementalSnapshotEvent 23 | 24 | type -> EventType.Meta 25 | event -> metaEvent 26 | 27 | type -> EventType.Custom 28 | event -> customEvent 29 | ``` 30 | 31 | 其中 EventType 是 Typescipt 的 numeric enum,在运行时是从 0 开始的数字,其类型定义详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L10)。 32 | 33 | 其中 incrementalSnapshotEvent 代表增量数据,其具体增量类型可以通过 `event.data.source` 字段进行判断: 34 | 35 | ``` 36 | source -> IncrementalSource.Mutation 37 | data -> mutationData 38 | 39 | source -> IncrementalSource.MouseMove 40 | data -> mousemoveData 41 | 42 | source -> IncrementalSource.MouseInteraction 43 | data -> mouseInteractionData 44 | 45 | source -> IncrementalSource.Scroll 46 | data -> scrollData 47 | 48 | source -> IncrementalSource.ViewportResize 49 | data -> viewportResizeData 50 | 51 | source -> IncrementalSource.Input 52 | data -> inputData 53 | 54 | source -> IncrementalSource.TouchMove 55 | data -> mouseInteractionData 56 | 57 | source -> IncrementalSource.MediaInteraction 58 | data -> mediaInteractionData 59 | 60 | source -> IncrementalSource.StyleSheetRule 61 | data -> styleSheetRuleData 62 | 63 | source -> IncrementalSource.CanvasMutation 64 | data -> canvasMutationData 65 | 66 | source -> IncrementalSource.Font 67 | data -> fontData 68 | ``` 69 | 70 | enum IncrementalSource 的定义详见[列表](https://github.com/rrweb-io/rrweb/blob/master/src/types.ts#L64)。 71 | -------------------------------------------------------------------------------- /docs/recipes/export-to-video.md: -------------------------------------------------------------------------------- 1 | # Convert To Video 2 | 3 | The event data recorded by rrweb is a performant, easy to compress, text-based format. And the replay is also pixel perfect. 4 | 5 | But if you really need to convert it into a video format, there are some tools that can do this work. 6 | 7 | Use [rrvideo](https://github.com/rrweb-io/rrvideo). 8 | -------------------------------------------------------------------------------- /docs/recipes/export-to-video.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 转换为视频 2 | 3 | rrweb 录制的数据是一种高效、易于压缩的文本格式,可以用于像素级的回放。但如果有进一步将录制数据转换为视频的需求,同样可以通过一些工具实现。 4 | 5 | 使用 [rrvideo](https://github.com/rrweb-io/rrvideo)。 6 | -------------------------------------------------------------------------------- /docs/recipes/index.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | > You may also want to read the [guide](../../guide.md) to understand the APIs, or read the [design docs](../) to know more technical details of rrweb. 4 | 5 | ## Scenarios 6 | 7 | ### Record And Replay 8 | 9 | Record and Replay is the most common use case, which is suitable for any scenario that needs to collect user behaviors and replay them. 10 | 11 | [link](./record-and-replay.md) 12 | 13 | ### Dive Into Events 14 | 15 | The events recorded by rrweb are a set of strictly-typed JSON data. You may discover some flexible ways to use them when you are familiar with the details. 16 | 17 | [link](./dive-into-event.md) 18 | 19 | ### Load Events Asynchronous 20 | 21 | When the size of the recorded events increased, load them in one request is not performant. You can paginate the events and load them as you need. 22 | 23 | [link](./pagination.md) 24 | 25 | ### Real-time Replay (Live Mode) 26 | 27 | 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. 28 | 29 | [link](./live-mode.md) 30 | 31 | ### Custom Event 32 | 33 | 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. 34 | 35 | [link](./custom-event.md) 36 | 37 | ### Interact With UI During Replay 38 | 39 | By default, the UI could not interact during replay. But you can use API to enable/disable this programmatically. 40 | 41 | [link](./interaction.md) 42 | 43 | ### Customize The Replayer 44 | 45 | When rrweb's Replayer and the rrweb-player UI do not fit your need, you can customize your own replayer UI. 46 | 47 | [link](./customize-replayer.md) 48 | 49 | ### Convert To Video 50 | 51 | The event data recorded by rrweb is a performant, easy to compress, text-based format. And the replay is also pixel perfect. 52 | 53 | But if you really need to convert it into a video format, there are some tools that can do this work. 54 | 55 | [link](./export-to-video.md) 56 | 57 | ### Optimize The Storage Size 58 | 59 | In some Apps, rrweb may record an unexpected amount of data. This part will help to find a suitable way to optimize the storage. 60 | 61 | [link](./optimize-storage.md) 62 | 63 | ### Canvas 64 | 65 | Canvas is a special HTML element, which will not be recorded by rrweb by default. There are some options for recording and replaying Canvas. 66 | 67 | [link](./canvas.md) 68 | 69 | ### Console Recorder and Replayer 70 | 71 | Starting from v1.0.0, we add the plugin to record and play back console output. 72 | This feature aims to provide developers with more information about the bug scene. There are some options for recording and replaying console output. 73 | 74 | [link](./console.md) 75 | 76 | ### Plugin 77 | 78 | The plugin API is designed to extend the function of rrweb without bump the size and complexity of rrweb's core part. 79 | 80 | [link](./plugin.md) 81 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/recipes/interaction.md: -------------------------------------------------------------------------------- 1 | # Interact With UI During Replay 2 | 3 | By default, the UI could not interact during replay. But you can use API to enable/disable this programmatically. 4 | 5 | ```js 6 | const replayer = new rrweb.Replayer(events); 7 | 8 | // enable user interact with the UI 9 | replayer.enableInteract(); 10 | 11 | // disable user interact with the UI 12 | replayer.disableInteract(); 13 | ``` 14 | 15 | rrweb uses the `pointer-events: none` CSS property to disable interaction. 16 | 17 | This will let the replay more stable and avoid some problems like navigate by clicking an external link. 18 | 19 | If you want to enable user interaction, like input, then you can use the `enableInteract` API. But be sure you have handled the problems that may cause unstable replay. 20 | -------------------------------------------------------------------------------- /docs/recipes/interaction.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 回放时与 UI 交互 2 | 3 | 回放时的 UI 默认不可交互,但在特定场景下也可以通过 API 允许用户与回放场景进行交互。 4 | 5 | ```js 6 | const replayer = new rrweb.Replayer(events); 7 | 8 | // 允许用户在回放的 UI 中进行交互 9 | replayer.enableInteract(); 10 | 11 | // 禁用用户在回放的 UI 中进行交互 12 | replayer.disableInteract(); 13 | ``` 14 | 15 | rrweb 使用 CSS 的 `pointer-events: none` 属性禁用交互。 16 | 17 | 这能够让回放更加稳定,例如避免用户点击回放中的超链接发生跳转等。 18 | 19 | 如果你希望允许用户交互,例如用户可以在回放时在输入框中进行输入,那么就可以调用 `enableInteract` API,但需要对不稳定的场景自行加以处理。 20 | -------------------------------------------------------------------------------- /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/live-mode.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 实时回放(直播) 2 | 3 | 如果希望持续、实时地看到录制的数据,达到类似直播的效果,则可以使用实时回放 API。这个方式也适用于一些实时协同的场景。 4 | 5 | 使用 rrweb Replayer 进行实时回放时,需要传入 `liveMode: true` 配置,并通过 `startLive` API 启动直播模式。 6 | 7 | ```js 8 | const replayer = new rrweb.Replayer([], { 9 | liveMode: true, 10 | }); 11 | 12 | replayer.startLive(FIRST_EVENT.timestamp - BUFFER); 13 | ``` 14 | 15 | 使用 `startLive` API 启动直播模式时,你可以传入一个可选参数,用于设置基线时间戳,这对于需要一定缓冲时间的直播场景非常有用。 16 | 17 | 例如录制时的第一个事件记录于 1500 这个时间点,实时回放时传入 `startLive(1500)` 就会让回放器将基线时间戳定为 1500,并用于计算后续事件的延迟时间。 18 | 19 | 但这有时会让实时回放看起来卡顿,因为数据的传输需要一定的时间(例如网络延迟),同时一些事件因为节流的性能优化会延迟发出(例如鼠标移动)。 20 | 21 | 因此我们可以通过 `startLive` 传入一个较小值的方式来提供一个缓冲时间,例如 `startLive(500)` 就会让回放总是延迟 1 秒播放。如果传输延迟小于 1 秒,则观看者不会感到卡顿。 22 | 23 | 启动直播模式后,可以通过 `addEvent` API 不断将最新的事件传入回放器中: 24 | 25 | ```js 26 | replayer.addEvent(NEW_EVENT); 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/recipes/optimize-storage.md: -------------------------------------------------------------------------------- 1 | # Optimize The Storage Size 2 | 3 | In some Apps, rrweb may record an unexpected amount of data. This part will help to find a suitable way to optimize the storage. 4 | 5 | Currently, we have the following optimize strategies: 6 | 7 | - block some DOM element to reduce the recording area 8 | - use sampling config to reduce the events 9 | - use deduplication and compression to reduce storage size 10 | 11 | ## Block DOM element 12 | 13 | Some DOM elements may emit lots of events, and some of them may not be the thing user cares about. So you can use the block class to ignore these kinds of elements. 14 | 15 | Some common patterns may emit lots of events are: 16 | 17 | - long list 18 | - complex SVG 19 | - element with JS controlled animation 20 | - canvas animations 21 | 22 | ## Sampling 23 | 24 | Use the sampling config in the recording can reduce the storage size by dropping some events: 25 | 26 | **Scenario 1** 27 | 28 | ```js 29 | rrweb.record({ 30 | emit(event) {}, 31 | sampling: { 32 | // do not record mouse movement 33 | mousemove: false 34 | // do not record mouse interaction 35 | mouseInteraction: false 36 | // set the interval of scrolling event 37 | scroll: 150 // do not emit twice in 150ms 38 | // set the interval of media interaction event 39 | media: 800 40 | // set the timing of record input 41 | input: 'last' // When input mulitple characters, only record the final input 42 | } 43 | }) 44 | ``` 45 | 46 | **Scenario 2** 47 | 48 | ```js 49 | rrweb.record({ 50 | emit(event) {}, 51 | sampling: { 52 | // Configure which kins of mouse interaction should be recorded 53 | mouseInteraction: { 54 | MouseUp: false, 55 | MouseDown: false, 56 | Click: false, 57 | ContextMenu: false, 58 | DblClick: false, 59 | Focus: false, 60 | Blur: false, 61 | TouchStart: false, 62 | TouchEnd: false, 63 | }, 64 | }, 65 | }); 66 | ``` 67 | 68 | ## Compression 69 | 70 | ### Use packFn to compress every event 71 | 72 | rrweb provides an fflate-based simple compress function rrweb.pack. 73 | 74 | You can use it by passing it as the `packFn` in the recording. 75 | 76 | ```js 77 | rrweb.record({ 78 | emit(event) {}, 79 | packFn: rrweb.pack, 80 | }); 81 | ``` 82 | 83 | And you need to pass rrweb.unpack as the `unpackFn` in replaying. 84 | 85 | ```js 86 | const replayer = new rrweb.Replayer(events, { 87 | unpackFn: rrweb.unpack, 88 | }); 89 | ``` 90 | 91 | ### Compress the whole session 92 | 93 | Use packFn to compress every event may not get the best result. 94 | 95 | It's recommended to compress the whole session in the backend, which will have a more efficient compression ratio for some algorithms like deflate. 96 | 97 | ## Deduplication 98 | 99 | Another optimizing strategy is deduplication. 100 | 101 | Since we need to simulate hover in the replay, rrweb will try its best to inline CSS styles in the events. 102 | 103 | So if we are applying rrweb to github.com, we may record many duplicate CSS styles across sessions. 104 | 105 | We can iterate the events and extract CSS. Then we can only store one copy of the styles. 106 | 107 | This strategy is also possible for the full snapshot across sessions. 108 | -------------------------------------------------------------------------------- /docs/recipes/optimize-storage.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 优化存储容量 2 | 3 | 在一些场景下 rrweb 的录制数据量可能高于你的预期,这部分文档可以帮助你选择适用于你的存储优化策略。 4 | 5 | 优化策略分为以下几类: 6 | 7 | - 通过屏蔽 DOM 元素,减少录制的内容 8 | - 通过 sampling 配置抽样策略,减少录制的数据 9 | - 通过去冗、压缩,减少数据存储体积 10 | 11 | ## 屏蔽 DOM 元素 12 | 13 | 一些特定 DOM 元素可能会产生大量的录制数据,而这些数据未必是回放时关注的,这种情况下可以选择将这些 DOM 元素进行屏蔽,不录制其内容。 14 | 15 | 常见的大数据量 DOM 元素包括: 16 | 17 | - 长列表 18 | - 复杂的 SVG 19 | - 包含 JS 控制动画的元素 20 | - canvas 动画 21 | 22 | ## 抽样策略 23 | 24 | 录制时通过 sampling 配置可以让特定数据以抽样的形式减少录制频率: 25 | 26 | **示例 1** 27 | 28 | ```js 29 | rrweb.record({ 30 | emit(event) {}, 31 | sampling: { 32 | // 不录制鼠标移动事件 33 | mousemove: false 34 | // 不录制鼠标交互事件 35 | mouseInteraction: false, 36 | // 设置滚动事件的触发频率 37 | scroll: 150 // 每 150ms 最多触发一次 38 | // set the interval of media interaction event 39 | media: 800 40 | // 设置输入事件的录制时机 41 | input: 'last' // 连续输入时,只录制最终值 42 | } 43 | }) 44 | ``` 45 | 46 | **示例 2** 47 | 48 | ```js 49 | rrweb.record({ 50 | emit(event) {}, 51 | sampling: { 52 | // 定义不录制的鼠标交互事件类型,可以细粒度的开启或关闭对应交互录制 53 | mouseInteraction: { 54 | MouseUp: false, 55 | MouseDown: false, 56 | Click: false, 57 | ContextMenu: false, 58 | DblClick: false, 59 | Focus: false, 60 | Blur: false, 61 | TouchStart: false, 62 | TouchEnd: false, 63 | }, 64 | }, 65 | }); 66 | ``` 67 | 68 | ## 压缩 69 | 70 | ### 基于 packFn 的单数据压缩 71 | 72 | rrweb 内包含了基于 fflate 的简单压缩 rrweb.pack,在录制时可以作为 `packFn` 传入。 73 | 74 | ```js 75 | rrweb.record({ 76 | emit(event) {}, 77 | packFn: rrweb.pack, 78 | }); 79 | ``` 80 | 81 | 回放时通用需要传入 rrweb.unpack 作为 `unpackFn` 传入。 82 | 83 | ```js 84 | const replayer = new rrweb.Replayer(events, { 85 | unpackFn: rrweb.unpack, 86 | }); 87 | ``` 88 | 89 | ### 批量压缩 90 | 91 | 基于 packFn 的单数据压缩以每个 event 数据为单位进行压缩,但这很多时候不能发挥 rrweb 录制数据易于压缩的优势。 92 | 93 | 因此**更加推荐**在服务端实现多个 event 的批量压缩,例如将单次用户操作产生的所有 event 数据进行一次压缩,对于 gzip 等压缩算法来说更为友好。 94 | 95 | ## 去冗 96 | 97 | 另一个优化存储容量的思路是去冗。 98 | 99 | 为了模拟 hover 等需求,rrweb 会尽可能的将 CSS 样式 inline 在录制数据中。 100 | 101 | 可以想象,如果使用 rrweb 录制每个用户对 github.com 的访问,则会在录制数据中保存大量重复的样式表内容。 102 | 103 | 可以通过遍历录制数据,将包含样式表的内容提取单独保存的方式,将这部分相同数据仅保存一份。 104 | 105 | 另一方面,全量快照类的数据也存在同样的问题,可以使用同样的思路去冗,减少存储总量。 106 | -------------------------------------------------------------------------------- /docs/recipes/pagination.md: -------------------------------------------------------------------------------- 1 | # Load Events Asynchronous 2 | 3 | When the size of the recorded events increased, load them in one request is not performant. You can paginate the events and load them as you need. 4 | 5 | rrweb's API for loading async events is quite simple: 6 | 7 | ```js 8 | const replayer = new rrweb.Replayer(events); 9 | 10 | replayer.addEvent(NEW_EVENT); 11 | ``` 12 | 13 | When calling the `addEvent` API to add a new event, rrweb will resolve its timestamp and replay it as need. 14 | 15 | If you need to load several events, you can do a loop like this: 16 | 17 | ```js 18 | const replayer = new rrweb.Replayer(events); 19 | 20 | for (const event of NEW_EVENTS) { 21 | replayer.addEvent(event); 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/recipes/pagination.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 异步加载数据 2 | 3 | 当录制的数据较多时,一次性加载至回放页面可能带来较大的网络开销和较长的等待时间。这时可以采取数据分页的方式,异步地加载数据并回放。 4 | 5 | rrweb 中用于实现异步加载数据的 API 非常简单直观: 6 | 7 | ```js 8 | const replayer = new rrweb.Replayer(events); 9 | 10 | replayer.addEvent(NEW_EVENT); 11 | ``` 12 | 13 | 只需要调用 `addEvent` 传入新的数据,rrweb 就会自动处理其中的时间关系,以最恰当的方式进行回放。 14 | 15 | 如果需要异步加载多个数据,只需这样使用: 16 | 17 | ```js 18 | const replayer = new rrweb.Replayer(events); 19 | 20 | for (const event of NEW_EVENTS) { 21 | replayer.addEvent(event); 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/recipes/plugin.md: -------------------------------------------------------------------------------- 1 | # Plugin 2 | 3 | The plugin API is designed to extend the function of rrweb without bump the size and complexity of rrweb's core part. 4 | 5 | # Available plugins 6 | 7 | - [console](./console.md) 8 | 9 | ## Interface 10 | 11 | Same to with other functionality in rrweb, a plugin can implement record or replay or both features. 12 | 13 | ```ts 14 | export type RecordPlugin = { 15 | name: string; 16 | observer: (cb: Function, options: TOptions) => listenerHandler; 17 | options: TOptions; 18 | }; 19 | 20 | export type ReplayPlugin = { 21 | handler: ( 22 | event: eventWithTime, 23 | isSync: boolean, 24 | context: { replayer: Replayer }, 25 | ) => void; 26 | }; 27 | ``` 28 | 29 | Both record and replay plugins have a type interface. 30 | 31 | ### example 32 | 33 | #### record plugin 34 | 35 | ```ts 36 | const exampleRecordPlugin: RecordPlugin<{ foo: string }> = { 37 | name: 'my-scope/example@1', 38 | observer(cb, options) { 39 | const timer = setInterval(() => { 40 | cb({ 41 | foo: options.foo, 42 | timestamp: Date.now(), 43 | }); 44 | }, 1000); 45 | return () => clearInterval(timer); 46 | }, 47 | options: { 48 | foo: 'bar', 49 | }, 50 | }; 51 | 52 | rrweb.record({ 53 | emit: emit(event) {}, 54 | plugins: [exampleRecordPlugin], 55 | }); 56 | ``` 57 | 58 | In this example, the record plugin will emit events like this: 59 | 60 | ```js 61 | { 62 | type: 6, 63 | data: { 64 | plugin: 'my-scope/example@1', 65 | payload: { 66 | foo: 'bar', 67 | timestamp: 1624693882345, 68 | }, 69 | }, 70 | timestamp: 1624693882345, 71 | } 72 | ``` 73 | 74 | #### replay plugin 75 | 76 | ```ts 77 | const exampleReplayPlugin: ReplayPlugin = { 78 | handler(event, isSync, context) { 79 | if (event.type === EventType.Plugin) { 80 | // do something with event.data.payload 81 | if (event.data.plugin === 'my-scope/example@1') { 82 | // handle example plugin data 83 | } 84 | } 85 | }, 86 | }; 87 | 88 | const replayer = new rrweb.Replayer(events, { 89 | plugins: [exampleReplayPlugin], 90 | }); 91 | ``` 92 | 93 | A replay plugin can interact with the replayer by using `context.replayer`. 94 | 95 | ## naming a plugin 96 | 97 | A record plugin should have a unique name, and it will be stored in the event it emits. 98 | 99 | **Since we will have both plugins in the rrweb repo and plugins in users' own codebase, which may cause naming conflicts in the future, we strongly recommended users naming their own plugins in this way:** 100 | 101 | > scope/name@version 102 | 103 | For example `rrweb/console@1` or `github/pr@2`. 104 | -------------------------------------------------------------------------------- /docs/recipes/plugin.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 插件 2 | 3 | 插件 API 的设计目标是在不增加 rrweb 核心部分大小和复杂性的前提下,扩展 rrweb 的功能。 4 | 5 | # 可用插件 6 | 7 | - [console](./console.zh_CN.md) 8 | 9 | ## 接口 10 | 11 | 与 rrweb 其它功能相似,插件可以包含同时包含录制、回放侧的功能,也可以只实现其中任一。 12 | 13 | ```ts 14 | export type RecordPlugin = { 15 | name: string; 16 | observer: (cb: Function, options: TOptions) => listenerHandler; 17 | options: TOptions; 18 | }; 19 | 20 | export type ReplayPlugin = { 21 | handler: ( 22 | event: eventWithTime, 23 | isSync: boolean, 24 | context: { replayer: Replayer }, 25 | ) => void; 26 | }; 27 | ``` 28 | 29 | 以上是录制和回放插件的接口类型。 30 | 31 | ### 示例 32 | 33 | #### 录制侧插件 34 | 35 | ```ts 36 | const exampleRecordPlugin: RecordPlugin<{ foo: string }> = { 37 | name: 'my-scope/example@1', 38 | observer(cb, options) { 39 | const timer = setInterval(() => { 40 | cb({ 41 | foo: options.foo, 42 | timestamp: Date.now(), 43 | }); 44 | }, 1000); 45 | return () => clearInterval(timer); 46 | }, 47 | options: { 48 | foo: 'bar', 49 | }, 50 | }; 51 | 52 | rrweb.record({ 53 | emit: emit(event) {}, 54 | plugins: [exampleRecordPlugin], 55 | }); 56 | ``` 57 | 58 | 在这个示例中,录制侧插件将会输出这样的事件: 59 | 60 | ```js 61 | { 62 | type: 6, 63 | data: { 64 | plugin: 'my-scope/example@1', 65 | payload: { 66 | foo: 'bar', 67 | timestamp: 1624693882345, 68 | }, 69 | }, 70 | timestamp: 1624693882345, 71 | } 72 | ``` 73 | 74 | #### 回放侧插件 75 | 76 | ```ts 77 | const exampleReplayPlugin: ReplayPlugin = { 78 | handler(event, isSync, context) { 79 | if (event.type === EventType.Plugin) { 80 | // 使用 event.data.payload 81 | if (event.data.plugin === 'my-scope/example@1') { 82 | // 处理示例插件录制的数据 83 | } 84 | } 85 | }, 86 | }; 87 | 88 | const replayer = new rrweb.Replayer(events, { 89 | plugins: [exampleReplayPlugin], 90 | }); 91 | ``` 92 | 93 | 回放侧插件可以通过 `context.replayer` 与播放器进行交互。 94 | 95 | ## 插件命名 96 | 97 | 录制侧插件应该拥有全局唯一的名称,并且其名称会被记录在输出的事件中。 98 | 99 | **由于会同时存在 rrweb 仓库中的官方插件与用户自行实现的自定义插件,所以我们推荐使用统一的命名规则避免冲突,命名方式如下:** 100 | 101 | > scope/name@version 102 | 103 | 例如: `rrweb/console@1` 或 `github/pr@2`。 104 | -------------------------------------------------------------------------------- /docs/recipes/record-and-replay.md: -------------------------------------------------------------------------------- 1 | # Record And Replay 2 | 3 | Record and Replay is the most common use case, which is suitable for any scenario that needs to collect user behaviors and replay them. 4 | 5 | You only need a simple API call to record the website: 6 | 7 | ```js 8 | const stopFn = rrweb.record({ 9 | emit(event) { 10 | // save the event 11 | }, 12 | }); 13 | ``` 14 | 15 | You can use any approach to store the recorded events, like sending the events to your backend and save them into the database. 16 | 17 | But you should guarantee: 18 | 19 | - a set of events are sorted by its timestamp 20 | - save every event 21 | 22 | You can use the `stopFn` to stop the recording. 23 | 24 | The replay is also as simple as putting events into rrweb's Replayer. 25 | 26 | ```js 27 | const events = GET_YOUR_EVENTS; 28 | 29 | const replayer = new rrweb.Replayer(events); 30 | replayer.play(); 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/recipes/record-and-replay.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 录制与回放 2 | 3 | 录制与回放是最常用的使用方式,适用于任何需要采集用户行为数据并重新查看的场景。 4 | 5 | 仅需一个函数调用就可以录制当前页面: 6 | 7 | ```js 8 | const stopFn = rrweb.record({ 9 | emit(event) { 10 | // 保存获取到的 event 数据 11 | }, 12 | }); 13 | ``` 14 | 15 | 你可以使用任何方式保存录制的数据,例如通过网络请求将数据传入至后端持久化保存,但请确保: 16 | 17 | - 一组录制的数据按照 event.timestamp 中的时间戳从小至大保存 18 | - 完整保存数据,不缺失任何一个 event。 19 | 20 | 如果需要手动停止录制,可以调用返回的 `stopFn` 函数。 21 | 22 | 回放时只需要获取一段录制数据,并传入 rrweb 提供的 Replayer: 23 | 24 | ```js 25 | const events = GET_YOUR_EVENTS; 26 | 27 | const replayer = new rrweb.Replayer(events); 28 | replayer.play(); 29 | ``` 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/sandbox.md: -------------------------------------------------------------------------------- 1 | # Sandbox 2 | 3 | In the [serialization design](./serialization.md) we mentioned the "de-scripting" process, that is, we will not execute any JavaScript in the recorded page during replay, but instead reproduce its effects the snapshots. The `script` tag is rewritten as a `noscript` tag to solve some of the problems. However, there are still some scripted behaviors that are not included in the `script` tag, such as inline scripts in HTML, form submissions, and so on. 4 | 5 | There are many kinds of scripting behaviors. A filtering approach to getting rid of these scripts will never be a complete solution, and once a script slips through and is executed, it may cause irreversible unintended consequences. So we use the iframe sandbox feature provided by HTML for browser-level restrictions. 6 | 7 | ## iframe sandbox 8 | 9 | We reconstruct the recorded DOM in an `iframe` element when we rebuild the snapshot. By setting its `sandbox` attribute, we can disable the following behavior: 10 | 11 | - Form submission 12 | - pop-up window such as `window.open` 13 | - JS script (including inline event handlers and `javascript:` URLs) 14 | 15 | This is in line with our expectations, especially when dealing with JS scripts is safer and more reliable than implementing this security ourselves. 16 | 17 | ## Avoid link jumps 18 | 19 | When you click the a element link, the default event is to jump to the URL corresponding to its href attribute. During replay, we will ensure visually correct replay by rebuilding the page DOM after the jump, and the original jump should be prohibited. 20 | 21 | Usually we will capture all an elements click events through the event handler proxy and disable the default event via `event.preventDefault()`. But when we put the replay page in the sandbox, all the event handlers will not be executed, and we will not be able to implement the event delegation. 22 | 23 | When replaying interactive events, note that replaying the JS `click` event is not nessecary because click events do not have any impact when JS is disabled. However, in order to optimize the replay effect, we can add special animation effects to visualize elements being clicked with the mouse, to clearly show the viewer that a click has occurred. 24 | 25 | ## iframe style settings 26 | 27 | Since we're rebuilding the DOM in an iframe, we can't affect the elements in the iframe through the CSS stylesheet of the parent page. But if JS scripts are not allowed to execute, the `noscript` tag will be displayed, and we want to hide it. So we need to dynamically add styles to the iframe. The sample code is as follows: 28 | 29 | ```typescript 30 | const injectStyleRules: string[] = [ 31 | 'iframe { background: #f1f3f5 }', 32 | 'noscript { display: none !important; }', 33 | ]; 34 | 35 | const styleEl = document.createElement('style'); 36 | const { documentElement, head } = this.iframe.contentDocument!; 37 | documentElement!.insertBefore(styleEl, head); 38 | for (let idx = 0; idx < injectStyleRules.length; idx++) { 39 | (styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx); 40 | } 41 | ``` 42 | 43 | Note that this inserted style element does not exist in the original recorded page, so we can't serialize it, otherwise the `id -> Node` mapping will be wrong. 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/serialization.zh_CN.md: -------------------------------------------------------------------------------- 1 | # 序列化 2 | 3 | 如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存。例如通过以下的代码实现(使用 jQuery 简化示例,仅保存 body 部分): 4 | 5 | ```javascript 6 | // record 7 | const snapshot = $('body').clone(); 8 | // replay 9 | $('body').replaceWith(snapshot); 10 | ``` 11 | 12 | 我们通过将 DOM 对象整体保存在内存中实现了快照。 13 | 14 | 但是这个对象本身并不是**可序列化**的,因此我们不能将其保存为特定的文本格式(例如 JSON)进行传输,也就无法做到远程录制,所以我们首先需要实现将 DOM 及其视图状态序列化的方法。在这里我们不使用一些开源方案例如 [parse5](https://github.com/inikulin/parse5) 的原因包含两个方面: 15 | 16 | 1. 我们需要实现一个“非标准”的序列化方法,下文会详细展开。 17 | 2. 此部分代码需要运行在被录制的页面中,要尽可能的控制代码量,只保留必要功能。 18 | 19 | ## 序列化中的特殊处理 20 | 21 | 之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理: 22 | 23 | 1. 去脚本化。被录制页面中的所有 JavaScript 都不应该被执行,例如我们会在重建快照时将 `script` 标签改为 `noscript` 标签,此时 script 内部的内容就不再重要,录制时可以简单记录一个标记值而不需要将可能存在的大量脚本内容全部记录。 24 | 2. 记录没有反映在 HTML 中的视图状态。例如 `` 输入后的值不会反映在其 HTML 中,而是通过 `value` 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成 ``。 25 | 3. 相对路径转换为绝对路径。回放时我们会将被录制的页面放置在一个 ` 26 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /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 | This is an image 38 | 39 |
40 | 41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /packages/rrdom/test/html/shadow-dom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | shadow dom 7 | 8 | 9 |
10 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/rrdom/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "ES6", 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "removeComments": true, 9 | "preserveConstEnums": true, 10 | "sourceMap": true, 11 | "rootDir": "src", 12 | "outDir": "build", 13 | "lib": ["es6", "dom"], 14 | "skipLibCheck": true, 15 | "declaration": true, 16 | "importsNotUsedAsValues": "error" 17 | }, 18 | "references": [ 19 | { 20 | "path": "../rrweb-snapshot" 21 | } 22 | ], 23 | "compileOnSave": true, 24 | "exclude": ["test"], 25 | "include": ["src", "../rrweb/src/record/workers/workers.d.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/rrweb-player/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.js"], 3 | "rules": { 4 | "require-jsdoc": "off", 5 | "arrow-parens": "off", 6 | "object-curly-spacing": "off", 7 | "indent": "off" 8 | }, 9 | "parserOptions": { 10 | "extraFileExtensions": [".svelte"] 11 | }, 12 | "plugins": ["svelte3"], 13 | "overrides": [ 14 | { 15 | "files": ["*.svelte"], 16 | "processor": "svelte3/svelte3" 17 | } 18 | ], 19 | "settings": { 20 | "svelte3/typescript": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/rrweb-player/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | public/bundle.* 4 | public/build 5 | package-lock.json 6 | yarn.lock 7 | 8 | .vscode 9 | temp 10 | 11 | dist 12 | lib 13 | 14 | *.log -------------------------------------------------------------------------------- /packages/rrweb-player/.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": false, 3 | "buildCommand": "npm run build", 4 | "requireCleanWorkingDir": false 5 | } 6 | -------------------------------------------------------------------------------- /packages/rrweb-player/README.md: -------------------------------------------------------------------------------- 1 | _Psst — looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)_ 2 | 3 | _Looking for a Vue.js version? Go here --> [@preflight-hq/rrweb-player-vue](https://github.com/Preflight-HQ/rrweb-player-vue)_ 4 | 5 | --- 6 | 7 | # svelte app 8 | 9 | This is a project template for [Svelte](https://svelte.technology) apps. It lives at https://github.com/sveltejs/template. 10 | 11 | To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit): 12 | 13 | ```bash 14 | npm install -g degit # you only need to do this once 15 | 16 | degit sveltejs/template svelte-app 17 | cd svelte-app 18 | ``` 19 | 20 | _Note that you will need to have [Node.js](https://nodejs.org) installed._ 21 | 22 | ## Get started 23 | 24 | Install the dependencies... 25 | 26 | ```bash 27 | cd svelte-app 28 | npm install 29 | ``` 30 | 31 | ...then start [Rollup](https://rollupjs.org): 32 | 33 | ```bash 34 | npm run dev 35 | ``` 36 | 37 | Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes. 38 | 39 | ## Deploying to the web 40 | 41 | ### With [now](https://zeit.co/now) 42 | 43 | Install `now` if you haven't already: 44 | 45 | ```bash 46 | npm install -g now 47 | ``` 48 | 49 | Then, from within your project folder: 50 | 51 | ```bash 52 | now 53 | ``` 54 | 55 | As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon. 56 | 57 | ### With [surge](https://surge.sh/) 58 | 59 | Install `surge` if you haven't already: 60 | 61 | ```bash 62 | npm install -g surge 63 | ``` 64 | 65 | Then, from within your project folder: 66 | 67 | ```bash 68 | npm run build 69 | surge public 70 | ``` 71 | -------------------------------------------------------------------------------- /packages/rrweb-player/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rrweb-player", 3 | "version": "1.0.0-alpha.1", 4 | "devDependencies": { 5 | "@rollup/plugin-commonjs": "^22.0.0", 6 | "@rollup/plugin-node-resolve": "^13.2.1", 7 | "@types/offscreencanvas": "^2019.6.4", 8 | "eslint-config-google": "^0.14.0", 9 | "eslint-plugin-svelte3": "^4.0.0", 10 | "postcss-easy-import": "^3.0.0", 11 | "rollup": "^2.71.1", 12 | "rollup-plugin-css-only": "^3.1.0", 13 | "rollup-plugin-livereload": "^2.0.0", 14 | "rollup-plugin-svelte": "^7.1.0", 15 | "rollup-plugin-terser": "^7.0.2", 16 | "rollup-plugin-typescript2": "^0.31.2", 17 | "rollup-plugin-web-worker-loader": "^1.6.1", 18 | "sirv-cli": "^0.4.4", 19 | "svelte": "^3.2.0", 20 | "svelte-check": "^1.4.0", 21 | "svelte-preprocess": "^4.0.0", 22 | "tslib": "^2.0.0", 23 | "typescript": "^4.7.3" 24 | }, 25 | "dependencies": { 26 | "@tsconfig/svelte": "^1.0.0", 27 | "rrweb": "^2.0.0-alpha.1" 28 | }, 29 | "scripts": { 30 | "build": "rollup -c", 31 | "dev": "rollup -c -w", 32 | "prepublishOnly": "yarn build", 33 | "start": "sirv public", 34 | "validate": "svelte-check", 35 | "prepublish": "yarn build", 36 | "lint": "yarn eslint src/**/*.ts" 37 | }, 38 | "description": "rrweb's replayer UI", 39 | "main": "lib/index.js", 40 | "module": "dist/index.mjs", 41 | "unpkg": "dist/index.js", 42 | "files": [ 43 | "lib", 44 | "dist", 45 | "typings" 46 | ], 47 | "typings": "typings/index.d.ts", 48 | "repository": { 49 | "type": "git", 50 | "url": "git+https://github.com/rrweb-io/rrweb.git" 51 | }, 52 | "keywords": [ 53 | "rrweb" 54 | ], 55 | "author": "yanzhen@smartx.com", 56 | "license": "MIT", 57 | "bugs": { 58 | "url": "https://github.com/rrweb-io/rrweb/issues" 59 | }, 60 | "homepage": "https://github.com/rrweb-io/rrweb#readme" 61 | } 62 | -------------------------------------------------------------------------------- /packages/rrweb-player/public/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | position: relative; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | body { 9 | box-sizing: border-box; 10 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 11 | Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/rrweb-player/rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import sveltePreprocess from 'svelte-preprocess'; 7 | import webWorkerLoader from 'rollup-plugin-web-worker-loader'; 8 | import typescript from 'rollup-plugin-typescript2'; 9 | import pkg from './package.json'; 10 | import css from 'rollup-plugin-css-only'; 11 | 12 | // eslint-disable-next-line no-undef 13 | const production = !process.env.ROLLUP_WATCH; 14 | 15 | const entries = (production 16 | ? [ 17 | { file: pkg.module, format: 'es', css: false }, 18 | { file: pkg.main, format: 'cjs', css: false }, 19 | { 20 | file: pkg.unpkg, 21 | format: 'iife', 22 | name: 'rrwebPlayer', 23 | css: 'style.css', 24 | }, 25 | ] 26 | : [] 27 | ).concat([ 28 | { 29 | file: 'public/bundle.js', 30 | format: 'iife', 31 | name: 'rrwebPlayer', 32 | css: 'bundle.css', 33 | }, 34 | ]); 35 | 36 | export default entries.map((output) => ({ 37 | input: 'src/main.ts', 38 | output: { 39 | file: output.file, 40 | format: output.format, 41 | name: output.name, 42 | sourcemap: true, 43 | exports: 'auto', 44 | }, 45 | plugins: [ 46 | svelte({ 47 | compilerOptions: { 48 | // enable run-time checks when not in production 49 | dev: !production, 50 | }, 51 | preprocess: sveltePreprocess({ 52 | postcss: { 53 | // eslint-disable-next-line no-undef 54 | plugins: [require('postcss-easy-import')], 55 | }, 56 | }), 57 | }), 58 | 59 | // If you have external dependencies installed from 60 | // npm, you'll most likely need these plugins. In 61 | // some cases you'll need additional configuration — 62 | // consult the documentation for details: 63 | // https://github.com/rollup/rollup-plugin-commonjs 64 | resolve({ 65 | browser: true, 66 | dedupe: ['svelte'], 67 | extensions: ['.js', '.ts', '.svelte'], 68 | }), 69 | 70 | commonjs(), 71 | 72 | // supports bundling `web-worker:..filename` from rrweb 73 | webWorkerLoader(), 74 | 75 | typescript(), 76 | 77 | css({ 78 | // we'll extract any component CSS out into 79 | // a separate file — better for performance 80 | output: output.css, 81 | }), 82 | 83 | // In dev mode, call `npm run start` once 84 | // the bundle has been generated 85 | !production && serve(), 86 | 87 | // Watch the `public` directory and refresh the 88 | // browser on changes when not in production 89 | !production && livereload('public'), 90 | 91 | // If we're building for production (npm run build 92 | // instead of npm run dev), minify 93 | production && terser(), 94 | ], 95 | watch: { 96 | clearScreen: false, 97 | }, 98 | })); 99 | 100 | function serve() { 101 | let started = false; 102 | 103 | return { 104 | writeBundle() { 105 | if (!started) { 106 | started = true; 107 | 108 | // eslint-disable-next-line @typescript-eslint/no-var-requires, no-undef 109 | require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { 110 | stdio: ['ignore', 'inherit', 'inherit'], 111 | shell: true, 112 | }); 113 | } 114 | }, 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /packages/rrweb-player/src/components/Switch.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 74 | 75 |
76 | 77 |
80 | -------------------------------------------------------------------------------- /packages/rrweb-player/src/main.ts: -------------------------------------------------------------------------------- 1 | import type { eventWithTime } from 'rrweb/typings/types'; 2 | import _Player from './Player.svelte'; 3 | 4 | type PlayerProps = { 5 | events: eventWithTime[]; 6 | }; 7 | 8 | class Player extends _Player { 9 | constructor(options: { 10 | target: Element; 11 | props: PlayerProps; 12 | // for compatibility 13 | data?: PlayerProps; 14 | }) { 15 | super({ 16 | target: options.target, 17 | props: options.data || options.props, 18 | }); 19 | } 20 | } 21 | 22 | export default Player; 23 | -------------------------------------------------------------------------------- /packages/rrweb-player/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": [ 5 | "node_modules/*", 6 | "__sapper__/*", 7 | "public/*", 8 | "../rrweb/src/record/workers/workers.d.ts" 9 | ], 10 | "compilerOptions": { 11 | "composite": true 12 | }, 13 | "references": [ 14 | { 15 | "path": "../rrweb" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | package-lock.json 4 | build 5 | dist 6 | es 7 | lib 8 | temp 9 | typings 10 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": true, 3 | "hooks": { 4 | "before:init": ["npm run bundle", "npm run typings"] 5 | }, 6 | "git": { 7 | "requireCleanWorkingDir": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/README.md: -------------------------------------------------------------------------------- 1 | # rrweb-snapshot 2 | 3 | [![Build Status](https://travis-ci.org/rrweb-io/rrweb.svg?branch=master)](https://travis-ci.org/rrweb-io/rrweb) [![Join the chat at slack](https://img.shields.io/badge/slack-@rrweb-teal.svg?logo=slack)](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-snapshot/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/**.test.ts'], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@blitz/rrweb-snapshot", 3 | "version": "2.0.0-alpha.4", 4 | "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", 5 | "scripts": { 6 | "prepare": "npm run prepack", 7 | "prepack": "npm run bundle && npm run typings", 8 | "test": "jest", 9 | "test:watch": "jest --watch", 10 | "bundle": "rollup --config", 11 | "bundle:es-only": "cross-env ES_ONLY=true rollup --config", 12 | "dev": "yarn bundle:es-only --watch", 13 | "typings": "tsc -d --declarationDir typings", 14 | "prepublish": "npm run typings && npm run bundle", 15 | "lint": "yarn eslint src" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/rrweb-io/rrweb.git" 20 | }, 21 | "keywords": [ 22 | "rrweb", 23 | "snapshot", 24 | "DOM" 25 | ], 26 | "main": "lib/rrweb-snapshot.js", 27 | "module": "es/rrweb-snapshot.js", 28 | "unpkg": "dist/rrweb-snapshot.js", 29 | "typings": "typings/index.d.ts", 30 | "files": [ 31 | "dist", 32 | "lib", 33 | "es", 34 | "typings" 35 | ], 36 | "author": "yanzhen@smartx.com", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/rrweb-io/rrweb/issues" 40 | }, 41 | "homepage": "https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-snapshot#readme", 42 | "devDependencies": { 43 | "@types/chai": "^4.1.4", 44 | "@types/jest": "^27.0.2", 45 | "@types/jsdom": "^20.0.0", 46 | "@types/node": "^10.11.3", 47 | "@types/puppeteer": "^1.12.4", 48 | "cross-env": "^5.2.0", 49 | "jest": "^27.2.4", 50 | "jest-snapshot": "^23.6.0", 51 | "jsdom": "^16.4.0", 52 | "puppeteer": "^1.15.0", 53 | "rollup": "^2.45.2", 54 | "rollup-plugin-terser": "^7.0.2", 55 | "rollup-plugin-typescript2": "^0.31.2", 56 | "ts-jest": "^27.0.5", 57 | "ts-node": "^7.0.1", 58 | "tslib": "^1.9.3", 59 | "typescript": "^4.7.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/src/index.ts: -------------------------------------------------------------------------------- 1 | import snapshot, { 2 | serializeNodeWithId, 3 | transformAttribute, 4 | visitSnapshot, 5 | cleanupSnapshot, 6 | needMaskingText, 7 | classMatchesRegex, 8 | IGNORED_NODE, 9 | } from './snapshot'; 10 | import rebuild, { 11 | buildNodeWithSN, 12 | addHoverClass, 13 | createCache, 14 | } from './rebuild'; 15 | export * from './types'; 16 | export * from './utils'; 17 | 18 | export { 19 | snapshot, 20 | serializeNodeWithId, 21 | rebuild, 22 | buildNodeWithSN, 23 | addHoverClass, 24 | createCache, 25 | transformAttribute, 26 | visitSnapshot, 27 | cleanupSnapshot, 28 | needMaskingText, 29 | classMatchesRegex, 30 | IGNORED_NODE, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/css/style-with-import.css: -------------------------------------------------------------------------------- 1 | @import "./style.css"; 2 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background: url('../a.jpg'); 4 | border-image: url('data:image/svg+xml;utf8,'); 5 | } 6 | p { 7 | color: red; 8 | background: url('./b.jpg'); 9 | } 10 | body > p { 11 | color: yellow; 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 |

Title

13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/block-element.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 19 | 20 | 21 | 22 |
block 1
23 |
record 2
24 |
block 3
25 |
block 3
26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/compat-mode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Compat Mode; image resizing 5 | 6 | 7 |
8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/cors-style-sheet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | with style sheet 8 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/dynamic-stylesheet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | dynamic stylesheet 8 | 9 | 16 | 17 | 18 |

p tag

19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/form-fields.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | form fields 8 | 9 | 10 | 11 |
12 | 15 | 18 | 21 | 24 | 30 | 33 |
34 | 35 | 42 | 43 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/hover.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | hover selector 9 | 25 | 26 | 27 | 28 |
hover me
29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/iframe-inner.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | iframe 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/invalid-attribute.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/invalid-doctype.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Invalid Doctype 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/invalid-tagname.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | Hello 11 | Hello 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/mask-text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |

mask 1

12 |
13 | mask 2 14 |
15 |
mask 3
16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/picture-blob-in-frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/picture-blob.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | This is a robot 4 | 5 | 16 | 17 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/picture-in-frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/picture.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | This is a robot 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/preload.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/svg.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | IcoMoon - SVG Icons 5 | 6 | 7 | 8 | 9 |
10 |

Grid Size: 0

11 |
12 |
13 | 14 | Icon-behance 16 |
17 |
18 |
19 |
20 | 21 | Icon-linkedin 25 |
26 |
27 |
28 | 32 | 33 | 38 | 39 | 45 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | video 8 | 9 | 10 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/with-relative-res.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | Hello 12 | Hello 13 | Hello 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/with-script.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | with script 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | with style sheet with import 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/html/with-style-sheet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | with style sheet 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/iframe-html/frame1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frame 1 7 | 8 | 9 | frame 1 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/iframe-html/frame2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frame 2 7 | 8 | 9 | frame 2 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/iframe-html/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Main 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/images/compat-bottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb-snapshot/test/images/compat-bottom.png -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/images/compat-top-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb-snapshot/test/images/compat-top-left.png -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/images/compat-top-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb-snapshot/test/images/compat-top-right.png -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/images/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb-snapshot/test/images/robot.png -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/images/symbol-defs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | behance 5 | 6 | 7 | 8 | linkedin 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/js/a.js: -------------------------------------------------------------------------------- 1 | var a = 1 + 1; 2 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/rebuild.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { addHoverClass, createCache } from '../src/rebuild'; 4 | 5 | function getDuration(hrtime: [number, number]) { 6 | const [seconds, nanoseconds] = hrtime; 7 | return seconds * 1000 + nanoseconds / 1000000; 8 | } 9 | 10 | describe('add hover class to hover selector related rules', function () { 11 | let cache: ReturnType; 12 | 13 | beforeEach(() => { 14 | cache = createCache(); 15 | }); 16 | 17 | it('will do nothing to css text without :hover', () => { 18 | const cssText = 'body { color: white }'; 19 | expect(addHoverClass(cssText, cache)).toEqual(cssText); 20 | }); 21 | 22 | it('can add hover class to css text', () => { 23 | const cssText = '.a:hover { color: white }'; 24 | expect(addHoverClass(cssText, cache)).toEqual( 25 | '.a:hover, .a.\\:hover { color: white }', 26 | ); 27 | }); 28 | 29 | it('can add hover class when there is multi selector', () => { 30 | const cssText = '.a, .b:hover, .c { color: white }'; 31 | expect(addHoverClass(cssText, cache)).toEqual( 32 | '.a, .b:hover, .b.\\:hover, .c { color: white }', 33 | ); 34 | }); 35 | 36 | it('can add hover class when there is a multi selector with the same prefix', () => { 37 | const cssText = '.a:hover, .a:hover::after { color: white }'; 38 | expect(addHoverClass(cssText, cache)).toEqual( 39 | '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', 40 | ); 41 | }); 42 | 43 | it('can add hover class when :hover is not the end of selector', () => { 44 | const cssText = 'div:hover::after { color: white }'; 45 | expect(addHoverClass(cssText, cache)).toEqual( 46 | 'div:hover::after, div.\\:hover::after { color: white }', 47 | ); 48 | }); 49 | 50 | it('can add hover class when the selector has multi :hover', () => { 51 | const cssText = 'a:hover b:hover { color: white }'; 52 | expect(addHoverClass(cssText, cache)).toEqual( 53 | 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', 54 | ); 55 | }); 56 | 57 | it('will ignore :hover in css value', () => { 58 | const cssText = '.a::after { content: ":hover" }'; 59 | expect(addHoverClass(cssText, cache)).toEqual(cssText); 60 | }); 61 | 62 | // this benchmark is unreliable when run in parallel with other tests 63 | it.skip('benchmark', () => { 64 | const cssText = fs.readFileSync( 65 | path.resolve(__dirname, './css/benchmark.css'), 66 | 'utf8', 67 | ); 68 | const start = process.hrtime(); 69 | addHoverClass(cssText, cache); 70 | const end = process.hrtime(start); 71 | const duration = getDuration(end); 72 | expect(duration).toBeLessThan(100); 73 | }); 74 | 75 | it('should be a lot faster to add a hover class to a previously processed css string', () => { 76 | const factor = 100; 77 | 78 | let cssText = fs.readFileSync( 79 | path.resolve(__dirname, './css/benchmark.css'), 80 | 'utf8', 81 | ); 82 | 83 | const start = process.hrtime(); 84 | addHoverClass(cssText, cache); 85 | const end = process.hrtime(start); 86 | 87 | const cachedStart = process.hrtime(); 88 | addHoverClass(cssText, cache); 89 | const cachedEnd = process.hrtime(cachedStart); 90 | 91 | expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end)); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/test/utils.ts: -------------------------------------------------------------------------------- 1 | import * as puppeteer from 'puppeteer'; 2 | 3 | export async function waitForRAF(page: puppeteer.Page) { 4 | return await page.evaluate(() => { 5 | return new Promise((resolve) => { 6 | requestAnimationFrame(() => { 7 | requestAnimationFrame(resolve); 8 | }); 9 | }); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /packages/rrweb-snapshot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "removeComments": true, 9 | "preserveConstEnums": true, 10 | "rootDir": "src", 11 | "outDir": "build", 12 | "lib": ["es6", "dom"] 13 | }, 14 | "exclude": ["test"], 15 | "include": ["src"], 16 | "references": [] 17 | } 18 | -------------------------------------------------------------------------------- /packages/rrweb/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | node_modules 4 | package-lock.json 5 | # yarn.lock 6 | tsconfig.tsbuildinfo 7 | build 8 | dist 9 | es 10 | lib 11 | typings 12 | 13 | temp 14 | 15 | *.log 16 | 17 | .env 18 | __diff_output__ -------------------------------------------------------------------------------- /packages/rrweb/.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": true, 3 | "hooks": { 4 | "before:init": ["npm run bundle", "npm run typings"] 5 | }, 6 | "git": { 7 | "requireCleanWorkingDir": false 8 | }, 9 | "github": { 10 | "release": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/rrweb/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/**.test.ts'], 6 | moduleNameMapper: { 7 | '\\.css$': 'identity-obj-proxy', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/rrweb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rrweb", 3 | "version": "2.0.0-alpha.1", 4 | "description": "record and replay the web", 5 | "scripts": { 6 | "prepare": "npm run prepack", 7 | "prepack": "npm run bundle", 8 | "test": "npm run bundle:browser && jest --testPathIgnorePatterns test/benchmark", 9 | "test:headless": "PUPPETEER_HEADLESS=true npm run test", 10 | "test:watch": "PUPPETEER_HEADLESS=true npm run test -- --watch", 11 | "repl": "npm run bundle:browser && node scripts/repl.js", 12 | "live-stream": "yarn bundle:browser && node scripts/stream.js", 13 | "dev": "yarn bundle:browser --watch", 14 | "bundle:browser": "cross-env BROWSER_ONLY=true rollup --config", 15 | "bundle": "rollup --config", 16 | "typings": "tsc -d --declarationDir typings", 17 | "check-types": "tsc -noEmit", 18 | "prepublish": "npm run typings && npm run bundle", 19 | "lint": "yarn eslint src", 20 | "benchmark": "jest test/benchmark" 21 | }, 22 | "type": "module", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+ssh://git@github.com/rrweb-io/rrweb.git" 26 | }, 27 | "keywords": [ 28 | "rrweb" 29 | ], 30 | "main": "lib/rrweb-all.js", 31 | "module": "es/rrweb/packages/rrweb/src/entries/all.js", 32 | "unpkg": "dist/rrweb.js", 33 | "sideEffects": false, 34 | "typings": "typings/entries/all.d.ts", 35 | "files": [ 36 | "dist", 37 | "lib", 38 | "es", 39 | "typings" 40 | ], 41 | "author": "yanzhen@smartx.com", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/rrweb-io/rrweb/issues" 45 | }, 46 | "homepage": "https://github.com/rrweb-io/rrweb#readme", 47 | "devDependencies": { 48 | "@rollup/plugin-node-resolve": "^13.1.3", 49 | "@types/chai": "^4.1.6", 50 | "@types/dom-mediacapture-transform": "^0.1.3", 51 | "@types/inquirer": "^8.2.1", 52 | "@types/jest": "^27.4.1", 53 | "@types/jest-image-snapshot": "^4.3.1", 54 | "@types/node": "^17.0.21", 55 | "@types/offscreencanvas": "^2019.6.4", 56 | "@types/prettier": "^2.3.2", 57 | "@types/puppeteer": "^5.4.4", 58 | "cross-env": "^5.2.0", 59 | "esbuild": "^0.14.38", 60 | "fast-mhtml": "^1.1.9", 61 | "identity-obj-proxy": "^3.0.0", 62 | "ignore-styles": "^5.0.1", 63 | "inquirer": "^9.0.0", 64 | "jest": "^27.5.1", 65 | "jest-image-snapshot": "^4.5.1", 66 | "jest-snapshot": "^23.6.0", 67 | "prettier": "2.2.1", 68 | "puppeteer": "^9.1.1", 69 | "rollup": "^2.68.0", 70 | "rollup-plugin-esbuild": "^4.9.1", 71 | "rollup-plugin-postcss": "^3.1.1", 72 | "rollup-plugin-rename-node-modules": "^1.3.1", 73 | "rollup-plugin-typescript2": "^0.31.2", 74 | "rollup-plugin-web-worker-loader": "^1.6.1", 75 | "simple-peer-light": "^9.10.0", 76 | "ts-jest": "^27.1.3", 77 | "ts-node": "^10.9.1", 78 | "tslib": "^2.3.1", 79 | "typescript": "^4.7.3" 80 | }, 81 | "dependencies": { 82 | "@types/css-font-loading-module": "0.0.7", 83 | "@xstate/fsm": "^1.4.0", 84 | "base64-arraybuffer": "^1.0.1", 85 | "fflate": "^0.4.4", 86 | "mitt": "^3.0.0", 87 | "rrdom": "^0.1.4", 88 | "rrweb-snapshot": "^2.0.0-alpha.1" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /packages/rrweb/scripts/utils.js: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as http from 'http'; 3 | import * as url from 'url'; 4 | import * as fs from 'fs'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | export const startServer = (defaultPort = 3030) => 11 | new Promise((resolve) => { 12 | const mimeType = { 13 | '.html': 'text/html', 14 | '.js': 'text/javascript', 15 | '.css': 'text/css', 16 | }; 17 | const s = http.createServer((req, res) => { 18 | const parsedUrl = url.parse(req.url); 19 | const sanitizePath = path 20 | .normalize(parsedUrl.pathname) 21 | .replace(/^(\.\.[\/\\])+/, ''); 22 | let pathname = path.join(__dirname, sanitizePath); 23 | if ( 24 | /^\/rrweb.*\.js.*/.test(sanitizePath) || 25 | /^\/plugins\/.*/.test(sanitizePath) 26 | ) { 27 | pathname = path.join(__dirname, `../dist`, sanitizePath); 28 | } 29 | try { 30 | const data = fs.readFileSync(pathname); 31 | const ext = path.parse(pathname).ext; 32 | res.setHeader('Content-type', mimeType[ext] || 'text/plain'); 33 | res.setHeader('Access-Control-Allow-Origin', '*'); 34 | res.setHeader('Access-Control-Allow-Methods', 'GET'); 35 | res.setHeader('Access-Control-Allow-Headers', 'Content-type'); 36 | setTimeout(() => { 37 | res.end(data); 38 | }, 100); 39 | } catch (error) { 40 | res.end(); 41 | } 42 | }); 43 | s.listen(defaultPort) 44 | .on('listening', () => { 45 | resolve(s); 46 | }) 47 | .on('error', (e) => { 48 | console.log('port in use, trying next one'); 49 | s.listen().on('listening', () => { 50 | resolve(s); 51 | }); 52 | }); 53 | }); 54 | 55 | export function getServerURL(server) { 56 | const address = server.address(); 57 | if (address && typeof address !== 'string') { 58 | return `http://localhost:${address.port}`; 59 | } else { 60 | return `${address}`; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/rrweb/src/entries/all.ts: -------------------------------------------------------------------------------- 1 | export * from '../index'; 2 | export * from '../packer'; 3 | export * from '../plugins/console/record'; 4 | export * from '../plugins/console/replay'; 5 | -------------------------------------------------------------------------------- /packages/rrweb/src/entries/record-pack.ts: -------------------------------------------------------------------------------- 1 | export * from '../record/index'; 2 | export * from '../packer/pack'; 3 | -------------------------------------------------------------------------------- /packages/rrweb/src/entries/replay-unpack.ts: -------------------------------------------------------------------------------- 1 | export * from '../replay'; 2 | export * from '../packer/unpack'; 3 | -------------------------------------------------------------------------------- /packages/rrweb/src/index.ts: -------------------------------------------------------------------------------- 1 | import record from './record'; 2 | import { Replayer } from './replay'; 3 | import { _mirror } from './utils'; 4 | import * as utils from './utils'; 5 | 6 | export { 7 | EventType, 8 | IncrementalSource, 9 | MouseInteractions, 10 | ReplayerEvents, 11 | } from './types'; 12 | 13 | const { addCustomEvent } = record; 14 | const { freezePage } = record; 15 | 16 | export { 17 | record, 18 | addCustomEvent, 19 | freezePage, 20 | Replayer, 21 | _mirror as mirror, 22 | utils, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/rrweb/src/packer/base.ts: -------------------------------------------------------------------------------- 1 | import type { eventWithTime } from '../types'; 2 | 3 | export type PackFn = (event: eventWithTime) => string; 4 | export type UnpackFn = (raw: string) => eventWithTime; 5 | 6 | export type eventWithTimeAndPacker = eventWithTime & { 7 | v: string; 8 | }; 9 | 10 | export const MARK = 'v1'; 11 | -------------------------------------------------------------------------------- /packages/rrweb/src/packer/index.ts: -------------------------------------------------------------------------------- 1 | export { pack } from './pack'; 2 | export { unpack } from './unpack'; 3 | -------------------------------------------------------------------------------- /packages/rrweb/src/packer/pack.ts: -------------------------------------------------------------------------------- 1 | import { strFromU8, strToU8, zlibSync } from 'fflate'; 2 | import { PackFn, MARK, eventWithTimeAndPacker } from './base'; 3 | 4 | export const pack: PackFn = (event) => { 5 | const _e: eventWithTimeAndPacker = { 6 | ...event, 7 | v: MARK, 8 | }; 9 | return strFromU8(zlibSync(strToU8(JSON.stringify(_e))), true); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/rrweb/src/packer/unpack.ts: -------------------------------------------------------------------------------- 1 | import { strFromU8, strToU8, unzlibSync } from 'fflate'; 2 | import { UnpackFn, eventWithTimeAndPacker, MARK } from './base'; 3 | import type { eventWithTime } from '../types'; 4 | 5 | export const unpack: UnpackFn = (raw: string) => { 6 | if (typeof raw !== 'string') { 7 | return raw; 8 | } 9 | try { 10 | const e: eventWithTime = JSON.parse(raw) as eventWithTime; 11 | if (e.timestamp) { 12 | return e; 13 | } 14 | } catch (error) { 15 | // ignore and continue 16 | } 17 | try { 18 | const e: eventWithTimeAndPacker = JSON.parse( 19 | strFromU8(unzlibSync(strToU8(raw, true))), 20 | ) as eventWithTimeAndPacker; 21 | if (e.v === MARK) { 22 | return e; 23 | } 24 | throw new Error( 25 | `These events were packed with packer ${e.v} which is incompatible with current packer ${MARK}.`, 26 | ); 27 | } catch (error) { 28 | console.error(error); 29 | throw new Error('Unknown data format.'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /packages/rrweb/src/plugins/canvas-webrtc/Readme.md: -------------------------------------------------------------------------------- 1 | # rrweb canvas webrtc plugin 2 | 3 | Plugin that live streams contents of canvas elements via webrtc 4 | 5 | ## Example of live streaming via `yarn live-stream` 6 | 7 | https://user-images.githubusercontent.com/4106/186701616-fd71a107-5d53-423c-ba09-0395a3a0252f.mov 8 | 9 | ## Instructions 10 | 11 | ### Record side 12 | 13 | ```js 14 | // Record side 15 | 16 | import rrweb from 'rrweb'; 17 | import { RRWebPluginCanvasWebRTCRecord } from 'rrweb-plugin-canvas-webrtc-record'; 18 | 19 | const webRTCRecordPlugin = new RRWebPluginCanvasWebRTCRecord({ 20 | signalSendCallback: (msg) => { 21 | // provides webrtc sdp offer signal & connect message 22 | // make sure you send this to the replayer's `webRTCReplayPlugin.signalReceive(signal)` 23 | sendSignalToReplayer(msg); // example of function that sends the signal to the replayer 24 | }, 25 | }); 26 | 27 | rrweb.record({ 28 | emit: (event) => { 29 | // send these events to the `replayer.addEvent(event)`, how you do that is up to you 30 | // you can send them to a server for example which can then send them to the replayer 31 | sendEventToReplayer(event); // example of function that sends the event to the replayer 32 | }, 33 | plugins: [ 34 | // add the plugin to the list of plugins, and initialize it via `.initPlugin()` 35 | webRTCRecordPlugin.initPlugin(), 36 | ], 37 | recordCanvas: false, // we don't want canvas recording turned on, we're going to do that via the plugin 38 | }); 39 | ``` 40 | 41 | ### Replay Side 42 | 43 | ```js 44 | // Replay side 45 | import rrweb from 'rrweb'; 46 | import { RRWebPluginCanvasWebRTCReplay } from 'rrweb-plugin-canvas-webrtc-replay'; 47 | 48 | const webRTCReplayPlugin = new RRWebPluginCanvasWebRTCReplay({ 49 | canvasFoundCallback(canvas, context) { 50 | console.log('canvas', canvas); 51 | // send the canvas id to `webRTCRecordPlugin.setupStream(id)`, how you do that is up to you 52 | // you can send them to a server for example which can then send them to the replayer 53 | sendCanvasIdToRecordScript(context.id); // example of function that sends the id to the record script 54 | }, 55 | signalSendCallback(signal) { 56 | // provides webrtc sdp offer signal & connect message 57 | // make sure you send this to the record script's `webRTCRecordPlugin.signalReceive(signal)` 58 | sendSignalToRecordScript(signal); // example of function that sends the signal to the record script 59 | }, 60 | }); 61 | 62 | const replayer = new rrweb.Replayer([], { 63 | UNSAFE_replayCanvas: true, // turn canvas replay on! 64 | liveMode: true, // live mode is needed to stream events to the replayer 65 | plugins: [webRTCReplayPlugin.initPlugin()], 66 | }); 67 | replayer.startLive(); // start the replayer in live mode 68 | 69 | replayer.addEvent(event); // call this whenever an event is received from the record script 70 | ``` 71 | 72 | ## More info 73 | 74 | https://github.com/rrweb-io/rrweb/pull/976 75 | -------------------------------------------------------------------------------- /packages/rrweb/src/plugins/canvas-webrtc/record/index.ts: -------------------------------------------------------------------------------- 1 | import type { Mirror } from 'rrweb-snapshot'; 2 | import SimplePeer from 'simple-peer-light'; 3 | import type { RecordPlugin } from '../../../types'; 4 | import type { WebRTCDataChannel } from '../types'; 5 | 6 | export const PLUGIN_NAME = 'rrweb/canvas-webrtc@1'; 7 | 8 | export class RRWebPluginCanvasWebRTCRecord { 9 | private peer: SimplePeer.Instance | null = null; 10 | private mirror: Mirror; 11 | private streamMap: Map = new Map(); 12 | private signalSendCallback: (msg: RTCSessionDescriptionInit) => void; 13 | 14 | constructor({ 15 | signalSendCallback, 16 | peer, 17 | }: { 18 | signalSendCallback: RRWebPluginCanvasWebRTCRecord['signalSendCallback']; 19 | peer?: SimplePeer.Instance; 20 | }) { 21 | this.signalSendCallback = signalSendCallback; 22 | if (peer) this.peer = peer; 23 | } 24 | 25 | public initPlugin(): RecordPlugin { 26 | return { 27 | name: PLUGIN_NAME, 28 | getMirror: (mirror) => { 29 | this.mirror = mirror; 30 | }, 31 | options: {}, 32 | }; 33 | } 34 | 35 | public signalReceive(signal: RTCSessionDescriptionInit) { 36 | if (!this.peer) this.setupPeer(); 37 | this.peer?.signal(signal); 38 | } 39 | 40 | private startStream(id: number, stream: MediaStream) { 41 | if (!this.peer) return this.setupPeer(); 42 | 43 | const data: WebRTCDataChannel = { 44 | nodeId: id, 45 | streamId: stream.id, 46 | }; 47 | this.peer?.send(JSON.stringify(data)); 48 | this.peer?.addStream(stream); 49 | } 50 | 51 | public setupPeer() { 52 | if (!this.peer) { 53 | this.peer = new SimplePeer({ 54 | initiator: true, 55 | // trickle: false, // only create one WebRTC offer per session 56 | }); 57 | 58 | this.peer.on('error', (err: Error) => { 59 | this.peer = null; 60 | console.log('error', err); 61 | }); 62 | 63 | this.peer.on('close', () => { 64 | this.peer = null; 65 | console.log('closing'); 66 | }); 67 | 68 | this.peer.on('signal', (data: RTCSessionDescriptionInit) => { 69 | this.signalSendCallback(data); 70 | }); 71 | 72 | this.peer.on('connect', () => { 73 | for (const [id, stream] of this.streamMap) { 74 | this.startStream(id, stream); 75 | } 76 | }); 77 | } 78 | } 79 | 80 | public setupStream(id: number): false | MediaStream { 81 | if (id === -1) return false; 82 | let stream: MediaStream | undefined = this.streamMap.get(id); 83 | if (stream) return stream; 84 | 85 | const el = this.mirror.getNode(id) as HTMLCanvasElement | null; 86 | if (!el || !('captureStream' in el)) return false; 87 | 88 | stream = el.captureStream(); 89 | this.streamMap.set(id, stream); 90 | this.setupPeer(); 91 | 92 | return stream; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/rrweb/src/plugins/canvas-webrtc/types.ts: -------------------------------------------------------------------------------- 1 | export interface WebRTCDataChannel { 2 | nodeId: number; 3 | streamId: string; 4 | } 5 | -------------------------------------------------------------------------------- /packages/rrweb/src/plugins/sequential-id/record/index.ts: -------------------------------------------------------------------------------- 1 | import type { RecordPlugin } from '../../../types'; 2 | 3 | export type SequentialIdOptions = { 4 | key: string; 5 | }; 6 | 7 | const defaultOptions: SequentialIdOptions = { 8 | key: '_sid', 9 | }; 10 | 11 | export const PLUGIN_NAME = 'rrweb/sequential-id@1'; 12 | 13 | export const getRecordSequentialIdPlugin: ( 14 | options?: Partial, 15 | ) => RecordPlugin = (options) => { 16 | const _options = options 17 | ? Object.assign({}, defaultOptions, options) 18 | : defaultOptions; 19 | let id = 0; 20 | 21 | return { 22 | name: PLUGIN_NAME, 23 | eventProcessor(event) { 24 | Object.assign(event, { 25 | [_options.key]: ++id, 26 | }); 27 | return event; 28 | }, 29 | options: _options, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /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/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/rrweb/src/record/observers/canvas/2d.ts: -------------------------------------------------------------------------------- 1 | import type { Mirror } from 'rrweb-snapshot'; 2 | import { 3 | blockClass, 4 | CanvasContext, 5 | canvasManagerMutationCallback, 6 | IWindow, 7 | listenerHandler, 8 | } from '../../../types'; 9 | import { hookSetter, isBlocked, patch } from '../../../utils'; 10 | import { serializeArgs } from './serialize-args'; 11 | 12 | export default function initCanvas2DMutationObserver( 13 | cb: canvasManagerMutationCallback, 14 | win: IWindow, 15 | blockClass: blockClass, 16 | blockSelector: string | null, 17 | mirror: Mirror, 18 | ): listenerHandler { 19 | const handlers: listenerHandler[] = []; 20 | const props2D = Object.getOwnPropertyNames( 21 | win.CanvasRenderingContext2D.prototype, 22 | ); 23 | for (const prop of props2D) { 24 | try { 25 | if ( 26 | typeof win.CanvasRenderingContext2D.prototype[ 27 | prop as keyof CanvasRenderingContext2D 28 | ] !== 'function' 29 | ) { 30 | continue; 31 | } 32 | const restoreHandler = patch( 33 | win.CanvasRenderingContext2D.prototype, 34 | prop, 35 | function ( 36 | original: ( 37 | this: CanvasRenderingContext2D, 38 | ...args: unknown[] 39 | ) => void, 40 | ) { 41 | return function ( 42 | this: CanvasRenderingContext2D, 43 | ...args: Array 44 | ) { 45 | if (!isBlocked(this.canvas, blockClass, blockSelector, true)) { 46 | // Using setTimeout as toDataURL can be heavy 47 | // and we'd rather not block the main thread 48 | setTimeout(() => { 49 | const recordArgs = serializeArgs([...args], win, this); 50 | cb(this.canvas, { 51 | type: CanvasContext['2D'], 52 | property: prop, 53 | args: recordArgs, 54 | }); 55 | }, 0); 56 | } 57 | return original.apply(this, args); 58 | }; 59 | }, 60 | ); 61 | handlers.push(restoreHandler); 62 | } catch { 63 | const hookHandler = hookSetter( 64 | win.CanvasRenderingContext2D.prototype, 65 | prop, 66 | { 67 | set(v) { 68 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access 69 | cb(this.canvas, { 70 | type: CanvasContext['2D'], 71 | property: prop, 72 | args: [v], 73 | setter: true, 74 | }); 75 | }, 76 | }, 77 | ); 78 | handlers.push(hookHandler); 79 | } 80 | } 81 | return () => { 82 | handlers.forEach((h) => h()); 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /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/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/src/record/workers/image-bitmap-data-url-worker.ts: -------------------------------------------------------------------------------- 1 | import { encode } from 'base64-arraybuffer'; 2 | import type { DataURLOptions } from 'rrweb-snapshot'; 3 | import type { 4 | ImageBitmapDataURLWorkerParams, 5 | ImageBitmapDataURLWorkerResponse, 6 | } from '../../types'; 7 | 8 | const lastBlobMap: Map = new Map(); 9 | const transparentBlobMap: Map = new Map(); 10 | 11 | export interface ImageBitmapDataURLRequestWorker { 12 | postMessage: ( 13 | message: ImageBitmapDataURLWorkerParams, 14 | transfer?: [ImageBitmap], 15 | ) => void; 16 | onmessage: (message: MessageEvent) => void; 17 | } 18 | 19 | interface ImageBitmapDataURLResponseWorker { 20 | onmessage: 21 | | null 22 | | ((message: MessageEvent) => void); 23 | postMessage(e: ImageBitmapDataURLWorkerResponse): void; 24 | } 25 | 26 | async function getTransparentBlobFor( 27 | width: number, 28 | height: number, 29 | dataURLOptions: DataURLOptions, 30 | ): Promise { 31 | const id = `${width}-${height}`; 32 | if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!; 33 | const offscreen = new OffscreenCanvas(width, height); 34 | offscreen.getContext('2d'); // creates rendering context for `converToBlob` 35 | const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while 36 | const arrayBuffer = await blob.arrayBuffer(); 37 | const base64 = encode(arrayBuffer); // cpu intensive 38 | transparentBlobMap.set(id, base64); 39 | return base64; 40 | } 41 | 42 | // `as any` because: https://github.com/Microsoft/TypeScript/issues/20595 43 | const worker: ImageBitmapDataURLResponseWorker = self; 44 | 45 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 46 | worker.onmessage = async function (e) { 47 | if (!('OffscreenCanvas' in globalThis)) 48 | return worker.postMessage({ id: e.data.id }); 49 | 50 | const { id, bitmap, width, height, dataURLOptions } = e.data; 51 | 52 | const transparentBase64 = getTransparentBlobFor( 53 | width, 54 | height, 55 | dataURLOptions, 56 | ); 57 | 58 | const offscreen = new OffscreenCanvas(width, height); 59 | const ctx = offscreen.getContext('2d')!; 60 | 61 | ctx.drawImage(bitmap, 0, 0); 62 | bitmap.close(); 63 | const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while 64 | const type = blob.type; 65 | const arrayBuffer = await blob.arrayBuffer(); 66 | const base64 = encode(arrayBuffer); // cpu intensive 67 | 68 | // on first try we should check if canvas is transparent, 69 | // no need to save it's contents in that case 70 | if (!lastBlobMap.has(id) && (await transparentBase64) === base64) { 71 | lastBlobMap.set(id, base64); 72 | return worker.postMessage({ id }); 73 | } 74 | 75 | if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged 76 | worker.postMessage({ 77 | id, 78 | type, 79 | base64, 80 | width, 81 | height, 82 | }); 83 | lastBlobMap.set(id, base64); 84 | }; 85 | -------------------------------------------------------------------------------- /packages/rrweb/src/record/workers/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["webworker"] 5 | }, 6 | "exclude": ["workers.d.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/rrweb/src/record/workers/workers.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'web-worker:*' { 2 | const WorkerFactory: new () => Worker; 3 | export default WorkerFactory; 4 | } 5 | -------------------------------------------------------------------------------- /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/src/replay/canvas/index.ts: -------------------------------------------------------------------------------- 1 | import type { Replayer } from '..'; 2 | import { 3 | CanvasContext, 4 | canvasMutationCommand, 5 | canvasMutationData, 6 | canvasMutationParam, 7 | } from '../../types'; 8 | import webglMutation from './webgl'; 9 | import canvas2DMutation from './2d'; 10 | 11 | export default async function canvasMutation({ 12 | event, 13 | mutation, 14 | target, 15 | imageMap, 16 | canvasEventMap, 17 | errorHandler, 18 | }: { 19 | event: Parameters[0]; 20 | mutation: canvasMutationData; 21 | target: HTMLCanvasElement; 22 | imageMap: Replayer['imageMap']; 23 | canvasEventMap: Replayer['canvasEventMap']; 24 | errorHandler: Replayer['warnCanvasMutationFailed']; 25 | }): Promise { 26 | try { 27 | const precomputedMutation: canvasMutationParam = 28 | canvasEventMap.get(event) || mutation; 29 | 30 | const commands: canvasMutationCommand[] = 31 | 'commands' in precomputedMutation 32 | ? precomputedMutation.commands 33 | : [precomputedMutation]; 34 | 35 | if ([CanvasContext.WebGL, CanvasContext.WebGL2].includes(mutation.type)) { 36 | for (let i = 0; i < commands.length; i++) { 37 | const command = commands[i]; 38 | await webglMutation({ 39 | mutation: command, 40 | type: mutation.type, 41 | target, 42 | imageMap, 43 | errorHandler, 44 | }); 45 | } 46 | return; 47 | } 48 | // default is '2d' for backwards compatibility (rrweb below 1.1.x) 49 | for (let i = 0; i < commands.length; i++) { 50 | const command = commands[i]; 51 | await canvas2DMutation({ 52 | event, 53 | mutation: command, 54 | target, 55 | imageMap, 56 | errorHandler, 57 | }); 58 | } 59 | } catch (error) { 60 | errorHandler(mutation, error); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/rrweb/src/replay/styles/inject-style.ts: -------------------------------------------------------------------------------- 1 | const rules: (blockClass: string) => string[] = (blockClass: string) => [ 2 | `.${blockClass} { background: currentColor }`, 3 | 'noscript { display: none !important; }', 4 | ]; 5 | 6 | export default rules; 7 | -------------------------------------------------------------------------------- /packages/rrweb/src/replay/styles/style.css: -------------------------------------------------------------------------------- 1 | .replayer-wrapper { 2 | position: relative; 3 | } 4 | .replayer-mouse { 5 | position: absolute; 6 | width: 20px; 7 | height: 20px; 8 | transition: left 0.05s linear, top 0.05s linear; 9 | background-size: contain; 10 | background-position: center center; 11 | background-repeat: no-repeat; 12 | background-image: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg=='); 13 | border-color: transparent; /* otherwise we transition from black when .touch-device class is added */ 14 | } 15 | .replayer-mouse::after { 16 | content: ''; 17 | display: inline-block; 18 | width: 20px; 19 | height: 20px; 20 | background: rgb(73, 80, 246); 21 | border-radius: 100%; 22 | transform: translate(-50%, -50%); 23 | opacity: 0.3; 24 | } 25 | .replayer-mouse.active::after { 26 | animation: click 0.2s ease-in-out 1; 27 | } 28 | .replayer-mouse.touch-device { 29 | background-image: none; /* there's no passive cursor on touch-only screens */ 30 | width: 70px; 31 | height: 70px; 32 | border-width: 4px; 33 | border-style: solid; 34 | border-radius: 100%; 35 | margin-left: -37px; 36 | margin-top: -37px; 37 | border-color: rgba(73, 80, 246, 0); 38 | transition: left 0s linear, top 0s linear, border-color 0.2s ease-in-out; 39 | } 40 | .replayer-mouse.touch-device.touch-active { 41 | border-color: rgba(73, 80, 246, 1); 42 | transition: left 0.25s linear, top 0.25s linear, border-color 0.2s ease-in-out; 43 | } 44 | .replayer-mouse.touch-device::after { 45 | opacity: 0; /* there's no passive cursor on touch-only screens */ 46 | } 47 | .replayer-mouse.touch-device.active::after { 48 | animation: touch-click 0.2s ease-in-out 1; 49 | } 50 | .replayer-mouse-tail { 51 | position: absolute; 52 | pointer-events: none; 53 | } 54 | 55 | @keyframes click { 56 | 0% { 57 | opacity: 0.3; 58 | width: 20px; 59 | height: 20px; 60 | } 61 | 50% { 62 | opacity: 0.5; 63 | width: 10px; 64 | height: 10px; 65 | } 66 | } 67 | 68 | @keyframes touch-click { 69 | 0% { 70 | opacity: 0; 71 | width: 20px; 72 | height: 20px; 73 | } 74 | 50% { 75 | opacity: 0.5; 76 | width: 10px; 77 | height: 10px; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /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/test/__snapshots__/packer.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`pack can pack event 1`] = `"xœ«V*©,HU²2ÐQJI,IT²ª®ÕQ*ÉÌM-.IÌ-P²2457·06³0¥2%+¥2C¥ZÛË"`; 4 | -------------------------------------------------------------------------------- /packages/rrweb/test/e2e/__image_snapshots__/webgl-test-ts-e-2-e-webgl-will-record-and-replay-a-webgl-image-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb/test/e2e/__image_snapshots__/webgl-test-ts-e-2-e-webgl-will-record-and-replay-a-webgl-image-1-snap.png -------------------------------------------------------------------------------- /packages/rrweb/test/e2e/__image_snapshots__/webgl-test-ts-e-2-e-webgl-will-record-and-replay-a-webgl-square-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb/test/e2e/__image_snapshots__/webgl-test-ts-e-2-e-webgl-will-record-and-replay-a-webgl-square-1-snap.png -------------------------------------------------------------------------------- /packages/rrweb/test/events/ordering.ts: -------------------------------------------------------------------------------- 1 | import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; 2 | 3 | const now = Date.now(); 4 | const events: eventWithTime[] = [ 5 | { 6 | type: EventType.DomContentLoaded, 7 | data: {}, 8 | timestamp: now, 9 | }, 10 | { 11 | type: EventType.Load, 12 | data: {}, 13 | timestamp: now + 10, 14 | }, 15 | { 16 | type: EventType.Meta, 17 | data: { 18 | href: 'http://localhost', 19 | width: 1000, 20 | height: 800, 21 | }, 22 | timestamp: now + 10, 23 | }, 24 | // full snapshot: 25 | { 26 | data: { 27 | node: { 28 | id: 1, 29 | type: 0, 30 | childNodes: [ 31 | { id: 2, name: 'html', type: 1, publicId: '', systemId: '' }, 32 | { 33 | id: 3, 34 | type: 2, 35 | tagName: 'html', 36 | attributes: { lang: 'en' }, 37 | childNodes: [ 38 | { 39 | id: 4, 40 | type: 2, 41 | tagName: 'head', 42 | attributes: {}, 43 | childNodes: [], 44 | }, 45 | { 46 | id: 100, 47 | type: 2, 48 | tagName: 'body', 49 | attributes: {}, 50 | childNodes: [ 51 | { 52 | id: 101, 53 | type: 2, 54 | tagName: 'span', 55 | attributes: {}, 56 | childNodes: [ 57 | { 58 | id: 102, 59 | type: 3, 60 | textContent: 'Initial', 61 | }, 62 | ], 63 | }, 64 | ], 65 | }, 66 | ], 67 | }, 68 | ], 69 | }, 70 | initialOffset: { top: 0, left: 0 }, 71 | }, 72 | type: EventType.FullSnapshot, 73 | timestamp: now + 20, 74 | }, 75 | // 1st mutation that modifies text content 76 | { 77 | data: { 78 | adds: [], 79 | texts: [ 80 | { 81 | id: 102, 82 | value: 'Intermediate - incorrect', 83 | }, 84 | ], 85 | source: IncrementalSource.Mutation, 86 | removes: [], 87 | attributes: [], 88 | }, 89 | type: EventType.IncrementalSnapshot, 90 | timestamp: now + 30, 91 | }, 92 | // 2nd mutation (with same timestamp) that modifies text content 93 | { 94 | data: { 95 | adds: [], 96 | texts: [ 97 | { 98 | id: 102, 99 | value: 'Final - correct', 100 | }, 101 | ], 102 | source: IncrementalSource.Mutation, 103 | removes: [], 104 | attributes: [], 105 | }, 106 | type: EventType.IncrementalSnapshot, 107 | timestamp: now + 30, 108 | }, 109 | // dummy - presence triggers a bug 110 | { 111 | data: { 112 | adds: [], 113 | texts: [], 114 | source: IncrementalSource.Mutation, 115 | removes: [], 116 | attributes: [], 117 | }, 118 | type: EventType.IncrementalSnapshot, 119 | timestamp: now + 35, 120 | }, 121 | ]; 122 | 123 | export default events; 124 | -------------------------------------------------------------------------------- /packages/rrweb/test/events/scroll.ts: -------------------------------------------------------------------------------- 1 | import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; 2 | 3 | const now = Date.now(); 4 | const events: eventWithTime[] = [ 5 | { 6 | type: EventType.DomContentLoaded, 7 | data: {}, 8 | timestamp: now, 9 | }, 10 | { 11 | type: EventType.Load, 12 | data: {}, 13 | timestamp: now + 100, 14 | }, 15 | { 16 | type: EventType.Meta, 17 | data: { 18 | href: 'http://localhost', 19 | width: 1200, 20 | height: 500, 21 | }, 22 | timestamp: now + 100, 23 | }, 24 | // full snapshot: 25 | { 26 | data: { 27 | node: { 28 | id: 1, 29 | type: 0, 30 | childNodes: [ 31 | { id: 2, name: 'html', type: 1, publicId: '', systemId: '' }, 32 | { 33 | id: 3, 34 | type: 2, 35 | tagName: 'html', 36 | attributes: { lang: 'en' }, 37 | childNodes: [ 38 | { 39 | id: 4, 40 | type: 2, 41 | tagName: 'head', 42 | attributes: {}, 43 | childNodes: [], 44 | }, 45 | { 46 | id: 5, 47 | type: 2, 48 | tagName: 'body', 49 | attributes: {}, 50 | childNodes: [], 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | initialOffset: { top: 0, left: 0 }, 57 | }, 58 | type: EventType.FullSnapshot, 59 | timestamp: now + 100, 60 | }, 61 | // mutation that adds two div elements 62 | { 63 | type: EventType.IncrementalSnapshot, 64 | data: { 65 | source: IncrementalSource.Mutation, 66 | texts: [], 67 | attributes: [], 68 | removes: [], 69 | adds: [ 70 | { 71 | parentId: 5, 72 | nextId: null, 73 | node: { 74 | type: 2, 75 | tagName: 'div', 76 | attributes: { 77 | id: 'container', 78 | style: 'height: 1000px; overflow: scroll;', 79 | }, 80 | childNodes: [], 81 | id: 6, 82 | }, 83 | }, 84 | { 85 | parentId: 6, 86 | nextId: null, 87 | node: { 88 | type: 2, 89 | tagName: 'div', 90 | attributes: { 91 | id: 'block', 92 | style: 'height: 10000px; background-color: yellow;', 93 | }, 94 | childNodes: [], 95 | id: 7, 96 | }, 97 | }, 98 | ], 99 | }, 100 | timestamp: now + 500, 101 | }, 102 | // scroll event on the "#container" div 103 | { 104 | type: EventType.IncrementalSnapshot, 105 | data: { source: IncrementalSource.Scroll, id: 6, x: 0, y: 2500 }, 106 | timestamp: now + 1000, 107 | }, 108 | // scroll event on document 109 | { 110 | type: EventType.IncrementalSnapshot, 111 | data: { source: IncrementalSource.Scroll, id: 1, x: 0, y: 250 }, 112 | timestamp: now + 1500, 113 | }, 114 | // remove the "#container" div 115 | { 116 | type: EventType.IncrementalSnapshot, 117 | data: { 118 | source: IncrementalSource.Mutation, 119 | texts: [], 120 | attributes: [], 121 | removes: [{ parentId: 5, id: 6 }], 122 | adds: [], 123 | }, 124 | timestamp: now + 2000, 125 | }, 126 | ]; 127 | 128 | export default events; 129 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/assets/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb/test/html/assets/robot.png -------------------------------------------------------------------------------- /packages/rrweb/test/html/benchmark-dom-mutation-add-and-remove.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 46 | 47 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/benchmark-dom-mutation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 27 | 28 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/block.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Block record 8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/blocked-unblocked.html: -------------------------------------------------------------------------------- 1 | 2 | Uber Application for Codegen Testing 3 | 4 | 5 | 6 | 7 | 60 |
61 |

62 | Verify that block class bugs are fixed 63 |

64 |
65 |
66 |
67 | 68 |
69 |


70 |
71 | 72 |
73 |


74 | 75 |
76 |


77 |
78 |
79 | 80 |
81 |


82 |
83 | 84 |
85 |


86 | 87 |
88 | 89 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/canvas-webgl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | canvas 7 | 8 | 9 | 15 | 16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/canvas.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | canvas 7 | 8 | 9 | 15 | 16 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | form fields 8 | 9 | 10 | 11 |
12 | 15 | 18 | 21 | 24 | 27 | 33 | 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/frame-image-blob-url.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frame with image 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/frame1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frame 1 7 | 8 | 9 | frame 1 10 | 11 | 12 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/frame2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frame 2 7 | 8 | 9 | frame 2 10 | 11 | 18 | 19 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/ignore.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ignore fields 8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/image-blob-url.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Image with blob:url 8 | 9 | 10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Log record 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Main 7 | 13 | 14 | 15 | 16 | 17 | 25 | 26 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/mask-text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mask text 8 | 9 | 10 |

mask1

11 |
12 | mask2 13 |
14 |
15 |
16 |
mask3
17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/move-node.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

6 |
7 | 8 | 9 | 1 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/mutation-observer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

mutation observer

4 |
    5 |
  • 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/polyfilled-shadowdom-mutation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |
10 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/react-styled-components.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | react styled components 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/select2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Select2 3.5 8 | 9 | 10 | 11 |
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 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/shadow-dom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Shadow DOM Observer 7 | 24 | 25 | 26 |

27 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit 28 | officiis necessitatibus laborum asperiores et adipisci dolores corporis, 29 | vero distinctio voluptas, suscipit commodi architecto, aliquam fugit. 30 | Nesciunt labore reiciendis blanditiis! 31 |

32 | 33 |
34 | 37 |
38 | 39 |

40 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit 41 | officiis necessitatibus laborum asperiores et adipisci dolores corporis, 42 | vero distinctio voluptas, suscipit commodi architecto, aliquam fugit. 43 | Nesciunt labore reiciendis blanditiis! 44 |

45 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /packages/rrweb/test/html/shuffle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | shuffle 7 | 8 | 9 | 10 |
  • 1
  • 2
  • 3
  • 4
  • 5
11 | 12 | 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/rrweb/test/replay/__image_snapshots__/webgl-test-ts-replayer-webgl-should-output-simple-webgl-object-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackblitz/rrweb/master/packages/rrweb/test/replay/__image_snapshots__/webgl-test-ts-replayer-webgl-should-output-simple-webgl-object-1-snap.png -------------------------------------------------------------------------------- /packages/rrweb/test/replay/preload-all-images.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { polyfillWebGLGlobals } from '../utils'; 5 | polyfillWebGLGlobals(); 6 | 7 | import { Replayer } from '../../src/replay'; 8 | import { 9 | CanvasContext, 10 | CanvasArg, 11 | IncrementalSource, 12 | EventType, 13 | eventWithTime, 14 | } from '../../src/types'; 15 | 16 | let replayer: Replayer; 17 | 18 | const canvasMutationEventWithArgs = (args: CanvasArg[]): eventWithTime => { 19 | return { 20 | timestamp: 100, 21 | type: EventType.IncrementalSnapshot, 22 | data: { 23 | source: IncrementalSource.CanvasMutation, 24 | property: 'x', 25 | args, 26 | id: 1, 27 | type: CanvasContext.WebGL, 28 | }, 29 | }; 30 | }; 31 | 32 | const event = (): eventWithTime => { 33 | return { 34 | timestamp: 1, 35 | type: EventType.DomContentLoaded, 36 | data: {}, 37 | }; 38 | }; 39 | 40 | describe('preloadAllImages', () => { 41 | beforeEach(() => { 42 | replayer = new Replayer( 43 | // Get around the error "Replayer need at least 2 events." 44 | [event(), event()], 45 | ); 46 | }); 47 | 48 | it('should preload image', () => { 49 | replayer.service.state.context.events = [ 50 | canvasMutationEventWithArgs([ 51 | { 52 | rr_type: 'HTMLImageElement', 53 | src: 'http://example.com', 54 | }, 55 | ]), 56 | ]; 57 | 58 | (replayer as any).preloadAllImages(); 59 | 60 | const expectedImage = new Image(); 61 | expectedImage.src = 'http://example.com'; 62 | expect((replayer as any).imageMap.get('http://example.com')).toEqual( 63 | expectedImage, 64 | ); 65 | }); 66 | 67 | it('should preload nested image', async () => { 68 | replayer.service.state.context.events = [ 69 | canvasMutationEventWithArgs([ 70 | { 71 | rr_type: 'Array', 72 | args: [ 73 | { 74 | rr_type: 'HTMLImageElement', 75 | src: 'http://example.com', 76 | }, 77 | ], 78 | }, 79 | ]), 80 | ]; 81 | 82 | await (replayer as any).preloadAllImages(); 83 | 84 | const expectedImage = new Image(); 85 | expectedImage.src = 'http://example.com'; 86 | 87 | expect((replayer as any).imageMap.get('http://example.com')).toEqual( 88 | expectedImage, 89 | ); 90 | }); 91 | 92 | it('should preload multiple images', () => { 93 | replayer.service.state.context.events = [ 94 | canvasMutationEventWithArgs([ 95 | { 96 | rr_type: 'HTMLImageElement', 97 | src: 'http://example.com/img1.png', 98 | }, 99 | { 100 | rr_type: 'HTMLImageElement', 101 | src: 'http://example.com/img2.png', 102 | }, 103 | ]), 104 | ]; 105 | 106 | (replayer as any).preloadAllImages(); 107 | 108 | const expectedImage1 = new Image(); 109 | expectedImage1.src = 'http://example.com/img1.png'; 110 | 111 | expect( 112 | (replayer as any).imageMap.get('http://example.com/img1.png'), 113 | ).toEqual(expectedImage1); 114 | 115 | const expectedImage2 = new Image(); 116 | expectedImage1.src = 'http://example.com/img2.png'; 117 | 118 | expect( 119 | (replayer as any).imageMap.get('http://example.com/img2.png'), 120 | ).toEqual(expectedImage1); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /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/rrweb/test/replay/webgl.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { launchPuppeteer } from '../utils'; 4 | import { toMatchImageSnapshot } from 'jest-image-snapshot'; 5 | import type * as puppeteer from 'puppeteer'; 6 | import events from '../events/webgl'; 7 | 8 | interface ISuite { 9 | code: string; 10 | browser: puppeteer.Browser; 11 | page: puppeteer.Page; 12 | } 13 | 14 | expect.extend({ toMatchImageSnapshot }); 15 | 16 | describe('replayer', function () { 17 | jest.setTimeout(10_000); 18 | 19 | let code: ISuite['code']; 20 | let browser: ISuite['browser']; 21 | let page: ISuite['page']; 22 | 23 | beforeAll(async () => { 24 | browser = await launchPuppeteer(); 25 | 26 | const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js'); 27 | code = fs.readFileSync(bundlePath, 'utf8'); 28 | }); 29 | 30 | beforeEach(async () => { 31 | page = await browser.newPage(); 32 | await page.goto('about:blank'); 33 | // mouse cursor canvas is large and pushes the replayer below the fold 34 | // lets hide it... 35 | await page.addStyleTag({ 36 | content: '.replayer-mouse-tail{display: none !important;}', 37 | }); 38 | await page.evaluate(code); 39 | await page.evaluate(`let events = ${JSON.stringify(events)}`); 40 | 41 | page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); 42 | }); 43 | 44 | afterEach(async () => { 45 | await page.close(); 46 | }); 47 | 48 | afterAll(async () => { 49 | await browser.close(); 50 | }); 51 | 52 | describe('webgl', () => { 53 | it('should output simple webgl object', async () => { 54 | await page.evaluate(` 55 | const { Replayer } = rrweb; 56 | const replayer = new Replayer(events, { 57 | UNSAFE_replayCanvas: true, 58 | }); 59 | replayer.play(2500); 60 | `); 61 | 62 | const image = await page.screenshot(); 63 | expect(image).toMatchImageSnapshot(); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/rrweb/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Node", 5 | "target": "ES6", 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "removeComments": true, 9 | "preserveConstEnums": true, 10 | "rootDir": "src", 11 | "outDir": "build", 12 | "lib": ["es6", "dom"], 13 | "downlevelIteration": true, 14 | "importsNotUsedAsValues": "error", 15 | "strictBindCallApply": true, 16 | "composite": true 17 | }, 18 | "references": [ 19 | { 20 | "path": "../rrdom" 21 | }, 22 | { 23 | "path": "../rrweb-snapshot" 24 | } 25 | ], 26 | "exclude": ["test", "scripts"], 27 | "include": [ 28 | "src", 29 | "node_modules/@types/css-font-loading-module/index.d.ts", 30 | "node_modules/@types/jest-image-snapshot/index.d.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [".eslintrc.js"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { 4 | "path": "packages/rrdom" 5 | }, 6 | { 7 | "path": "packages/rrdom-nodejs" 8 | }, 9 | { 10 | "path": "packages/rrweb" 11 | }, 12 | { 13 | "path": "packages/rrweb-player" 14 | }, 15 | { 16 | "path": "packages/rrweb-snapshot" 17 | } 18 | ], 19 | "files": [], 20 | "include": [], 21 | "exclude": [], 22 | "compilerOptions": { 23 | "rootDir": "." 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "baseBranch": "origin/master", 4 | "pipeline": { 5 | "prepublish": { 6 | "dependsOn": ["^prepublish"], 7 | "outputs": ["lib/**", "es/**", "dist/**", "typings/**"] 8 | }, 9 | "test": {}, 10 | "test:watch": {}, 11 | "dev": {}, 12 | "lint": {}, 13 | "check-types": {} 14 | } 15 | } 16 | --------------------------------------------------------------------------------