diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 00000000..6d856d22 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,47 @@ +name: Eslint Check + +on: [pull_request] + +jobs: + eslint_check_upload: + runs-on: ubuntu-latest + name: ESLint Check and Report Upload + + steps: + - uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'yarn' + - name: Install Dependencies + run: YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn + - name: Build Packages + run: yarn build:all + - name: Test Code Linting + run: yarn turbo run lint + - name: Save Code Linting Report JSON + run: yarn lint:report + # Continue to the next step even if this fails + continue-on-error: true + - name: Upload ESLint report + uses: actions/upload-artifact@v3 + with: + name: eslint_report.json + path: eslint_report.json + + Annotation: + # Skip the annotation action in PRs from the forked repositories + if: github.event.pull_request.head.repo.full_name == 'rrweb-io/rrweb' + needs: eslint_check_upload + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v3 + with: + name: eslint_report.json + - name: Annotate Code Linting Results + uses: ataylorme/eslint-annotate-action@v2 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + report-json: 'eslint_report.json' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5026d620 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release Rrweb +on: push +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v1 + with: + node-version: 16.15.0 + registry-url: https://registry.npmjs.org + - name: Install + run: YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn + - name: Build all + run: yarn build:all + # - name: Test all + # run: yarn test + - name: Setup Publish Env + run: echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} > ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish + if: github.ref == 'refs/heads/master' + run: yarn lerna publish from-package -y + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml index 50498ee9..054e7989 100644 --- a/.github/workflows/style-check.yml +++ b/.github/workflows/style-check.yml @@ -18,7 +18,7 @@ jobs: node-version: 16 cache: 'yarn' - name: Install Dependencies - run: yarn + run: YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn - name: Build Packages run: yarn build:all - name: Eslint Check @@ -56,16 +56,13 @@ jobs: name: Format Check steps: - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v1 with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.head_ref }} - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: 'yarn' - - name: Install Dependencies - run: yarn + node-version: 16.15.0 + registry-url: https://registry.npmjs.org + - name: Install + run: YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn - name: Prettier Check run: yarn prettier --check '**/*.{ts,md}' @@ -76,16 +73,13 @@ jobs: name: Format Code steps: - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v1 with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.head_ref }} - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: 'yarn' - - name: Install Dependencies - run: yarn + node-version: 16.15.0 + registry-url: https://registry.npmjs.org + - name: Install + run: YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn - name: Prettify Code run: yarn prettier --write '**/*.{ts,md}' - name: Commit Changes diff --git a/README.md b/README.md index 3e6490f1..ee3a7a31 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ rrweb refers to 'record and replay the web', which is a tool for recording and r [**🍳 Recipes 🍳**](./docs/recipes/index.md) +## Version History + +### 2.0.0 + +This version updates the highlight rrweb fork on rrweb 1.1.3 April 2022 Release. +Because this is a major update, it may not be suitable for customers looking for stable recording and replay functionality. +However, the major update brings lots of rrweb features that have been in development. See the rrweb release notes for more details. + ## Project Structure rrweb is mainly composed of 3 parts: diff --git a/guide.md b/guide.md index 76f781d0..b1e65547 100644 --- a/guide.md +++ b/guide.md @@ -140,11 +140,11 @@ The parameter of `rrweb.record` accepts the following options. | emit | required | the callback function to get emitted events | | checkoutEveryNth | - | take a full snapshot after every N events
refer to the [checkout](#checkout) chapter | | checkoutEveryNms | - | take a full snapshot after every N ms
refer to the [checkout](#checkout) chapter | -| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter | +| blockClass | 'highlight-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter | | blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter | -| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter | +| ignoreClass | 'highlight-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter | | ignoreCSSAttributes | null | array of CSS attributes that should be ignored | -| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter | +| maskTextClass | 'highlight-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter | | maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter | | maskAllInputs | false | mask all input content as \* | | maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | @@ -167,9 +167,9 @@ The parameter of `rrweb.record` accepts the following options. You may find some contents on the webpage which are not willing to be recorded, then you can use the following approaches: -- An element with the class name `.rr-block` will not be recorded. Instead, it will replay as a placeholder with the same dimension. -- An element with the class name `.rr-ignore` will not record its input events. -- All text of elements with the class name `.rr-mask` and their children will be masked. +- An element with the class name `.highlight-block` will not be recorded. Instead, it will replay as a placeholder with the same dimension. +- An element with the class name `.highlight-ignore` will not record its input events. +- All text of elements with the class name `.highlight-mask` and their children will be masked. - `input[type="password"]` will be masked by default. - Mask options to mask the content in input elements. @@ -300,7 +300,7 @@ The replayer accepts options as its constructor's second parameter, and it has t | skipInactive | false | whether to skip inactive time | | showWarning | true | whether to print warning messages during replay | | showDebug | false | whether to print debug messages during replay | -| blockClass | 'rr-block' | element with the class name will display as a blocked area | +| blockClass | 'highlight-block' | element with the class name will display as a blocked area | | liveMode | false | whether to enable live mode | | insertStyleRules | [] | accepts multiple CSS rule string, which will be injected into the replay iframe | | triggerFocus | true | whether to trigger focus during replay | diff --git a/guide.zh_CN.md b/guide.zh_CN.md index 1093dbb3..c9b9fd9a 100644 --- a/guide.zh_CN.md +++ b/guide.zh_CN.md @@ -136,11 +136,11 @@ setInterval(save, 10 * 1000); | emit | 必填 | 获取当前录制的数据 | | checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照
详见[“重新制作快照”](#重新制作快照)章节 | | checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照
详见[“重新制作快照”](#重新制作快照)章节 | -| blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 | +| blockClass | 'highlight-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 | | blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 | -| ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 | +| ignoreClass | 'highlight-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 | | ignoreCSSAttributes | null | 应该被忽略的 CSS 属性数组 | -| maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 | +| maskTextClass | 'highlight-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 | | maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 | | maskAllInputs | false | 将所有输入内容记录为 \* | | maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) | @@ -163,9 +163,9 @@ setInterval(save, 10 * 1000); 页面中可能存在一些隐私相关的内容不希望被录制,rrweb 为此做了以下支持: -- 在 HTML 元素中添加类名 `.rr-block` 将会避免该元素及其子元素被录制,回放时取而代之的是一个同等宽高的占位元素。 -- 在 HTML 元素中添加类名 `.rr-ignore` 将会避免录制该元素的输入事件。 -- 所有带有`.rr-mask`类名的元素及其子元素的 text 内容将会被屏蔽。 +- 在 HTML 元素中添加类名 `.highlight-block` 将会避免该元素及其子元素被录制,回放时取而代之的是一个同等宽高的占位元素。 +- 在 HTML 元素中添加类名 `.highlight-ignore` 将会避免录制该元素的输入事件。 +- 所有带有`.highlight-mask`类名的元素及其子元素的 text 内容将会被屏蔽。 - `input[type="password"]` 类型的密码输入框默认不会录制输入事件。 - 配置中还有更为丰富的隐私保护选项。 @@ -296,7 +296,7 @@ replayer.destroy(); | skipInactive | false | 是否快速跳过无用户操作的阶段 | | showWarning | true | 是否在回放过程中打印警告信息 | | showDebug | false | 是否在回放过程中打印 debug 信息 | -| blockClass | 'rr-block' | 需要在回放时展示为隐藏区域的元素类名 | +| blockClass | 'highlight-block' | 需要在回放时展示为隐藏区域的元素类名 | | liveMode | false | 是否开启直播模式 | | insertStyleRules | [] | 可以传入多个 CSS rule string,用于自定义回放时 iframe 内的样式 | | triggerFocus | true | 回放时是否回放 focus 交互 | diff --git a/packages/rrdom-nodejs/LICENSE b/packages/rrdom-nodejs/LICENSE new file mode 100644 index 00000000..fce28eb8 --- /dev/null +++ b/packages/rrdom-nodejs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb/graphs/contributors) and SmartX Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rrdom-nodejs/package.json b/packages/rrdom-nodejs/package.json index 84bcc457..f301cfa2 100644 --- a/packages/rrdom-nodejs/package.json +++ b/packages/rrdom-nodejs/package.json @@ -1,6 +1,6 @@ { - "name": "rrdom-nodejs", - "version": "0.1.6", + "name": "@highlight-run/rrdom-nodejs", + "version": "0.1.8", "scripts": { "dev": "rollup -c -w", "bundle": "rollup --config", @@ -46,11 +46,11 @@ "typescript": "^4.7.3" }, "dependencies": { + "@highlight-run/rrdom": "0.1.17", + "@highlight-run/rrweb-snapshot": "1.1.31", "cssom": "^0.5.0", "cssstyle": "^2.3.0", - "nwsapi": "^2.2.0", - "rrdom": "^0.1.6", - "rrweb-snapshot": "^2.0.0-alpha.3" + "nwsapi": "^2.2.0" }, "browserslist": [ "supports es6-class" diff --git a/packages/rrdom-nodejs/src/document-nodejs.ts b/packages/rrdom-nodejs/src/document-nodejs.ts index 69f85b87..517167e5 100644 --- a/packages/rrdom-nodejs/src/document-nodejs.ts +++ b/packages/rrdom-nodejs/src/document-nodejs.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@highlight-run/rrweb-snapshot'; import type { NWSAPI } from 'nwsapi'; import type { CSSStyleDeclaration as CSSStyleDeclarationType } from 'cssstyle'; import { @@ -14,7 +14,7 @@ import { ClassList, IRRDocument, CSSStyleDeclaration, -} from 'rrdom'; +} from '@highlight-run/rrdom'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const nwsapi = require('nwsapi'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires diff --git a/packages/rrdom-nodejs/test/document-nodejs.test.ts b/packages/rrdom-nodejs/test/document-nodejs.test.ts index ba3c6144..81469c87 100644 --- a/packages/rrdom-nodejs/test/document-nodejs.test.ts +++ b/packages/rrdom-nodejs/test/document-nodejs.test.ts @@ -3,7 +3,7 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@highlight-run/rrweb-snapshot'; import { RRCanvasElement, RRCDATASection, @@ -16,7 +16,7 @@ import { RRStyleElement, RRText, } from '../src/document-nodejs'; -import { buildFromDom } from 'rrdom'; +import { buildFromDom } from '@highlight-run/rrdom'; describe('RRDocument for nodejs environment', () => { describe('RRDocument API', () => { diff --git a/packages/rrdom/LICENSE b/packages/rrdom/LICENSE new file mode 100644 index 00000000..fce28eb8 --- /dev/null +++ b/packages/rrdom/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb/graphs/contributors) and SmartX Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rrdom/package.json b/packages/rrdom/package.json index 74ef10f1..e09ed0f0 100644 --- a/packages/rrdom/package.json +++ b/packages/rrdom/package.json @@ -1,6 +1,6 @@ { - "name": "rrdom", - "version": "0.1.6", + "name": "@highlight-run/rrdom", + "version": "0.1.18", "homepage": "https://github.com/rrweb-io/rrweb/tree/main/packages/rrdom#readme", "license": "MIT", "main": "lib/rrdom.js", @@ -46,6 +46,6 @@ "typescript": "^4.7.3" }, "dependencies": { - "rrweb-snapshot": "^2.0.0-alpha.3" + "@highlight-run/rrweb-snapshot": "1.1.31" } } diff --git a/packages/rrdom/rollup.config.js b/packages/rrdom/rollup.config.js index 5bd34667..a69dc3ad 100644 --- a/packages/rrdom/rollup.config.js +++ b/packages/rrdom/rollup.config.js @@ -24,8 +24,8 @@ const basePlugins = [ const baseConfigs = [ { input: './src/index.ts', - name: pkg.name, - path: pkg.name, + name: 'rrdom', + path: 'rrdom', }, ]; @@ -40,7 +40,7 @@ for (let config of baseConfigs) { output: [ { format: 'esm', - file: pkg.module.replace(pkg.name, config.path), + file: pkg.module.replace('rrdom', config.path), }, ], }, diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 1cb4dd80..d8642943 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -1,4 +1,4 @@ -import { NodeType as RRNodeType, Mirror as NodeMirror } from 'rrweb-snapshot'; +import { NodeType as RRNodeType, Mirror as NodeMirror } from '@highlight-run/rrweb-snapshot'; import type { canvasMutationData, canvasEventWithTime, @@ -6,7 +6,7 @@ import type { scrollData, styleDeclarationData, styleSheetRuleData, -} from 'rrweb/src/types'; +} from '@highlight-run/rrweb/src/types'; import type { IRRCDATASection, IRRComment, diff --git a/packages/rrdom/src/document.ts b/packages/rrdom/src/document.ts index f8ee5b86..b2f0310e 100644 --- a/packages/rrdom/src/document.ts +++ b/packages/rrdom/src/document.ts @@ -1,4 +1,4 @@ -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@highlight-run/rrweb-snapshot'; import { parseCSSText, camelize, toCSSText } from './style'; export interface IRRNode { parentElement: IRRNode | null; diff --git a/packages/rrdom/src/index.ts b/packages/rrdom/src/index.ts index da96b12d..694de7af 100644 --- a/packages/rrdom/src/index.ts +++ b/packages/rrdom/src/index.ts @@ -1,12 +1,12 @@ import { NodeType as RRNodeType, createMirror as createNodeMirror, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import type { Mirror as NodeMirror, IMirror, serializedNodeWithId, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import type { canvasMutationData, canvasEventWithTime, @@ -14,7 +14,7 @@ import type { scrollData, styleSheetRuleData, styleDeclarationData, -} from 'rrweb/src/types'; +} from '@highlight-run/rrweb/src/types'; import { BaseRRNode as RRNode, BaseRRCDATASectionImpl, diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 723b2a3e..e5d05b5c 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -8,16 +8,16 @@ import { serializedNodeWithId, createMirror, Mirror, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import type { IRRNode } from '../src/document'; -import { Replayer } from 'rrweb'; +import { Replayer } from '@highlight-run/rrweb'; import type { canvasMutationData, styleDeclarationData, styleSheetRuleData, -} from 'rrweb/src/types'; -import { EventType, IncrementalSource } from 'rrweb/src/types'; -import type { eventWithTime } from 'rrweb/typings/types'; +} from '@highlight-run/rrweb/src/types'; +import { EventType, IncrementalSource } from '@highlight-run/rrweb/src/types'; +import type { eventWithTime } from '@highlight-run/typings/types'; const elementSn = { type: RRNodeType.Element, diff --git a/packages/rrdom/test/document.test.ts b/packages/rrdom/test/document.test.ts index c9ee13c3..1b75e2aa 100644 --- a/packages/rrdom/test/document.test.ts +++ b/packages/rrdom/test/document.test.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import { NodeType as RRNodeType } from 'rrweb-snapshot'; +import { NodeType as RRNodeType } from '@highlight-run/rrweb-snapshot'; import { BaseRRDocumentImpl, BaseRRDocumentTypeImpl, diff --git a/packages/rrdom/test/virtual-dom.test.ts b/packages/rrdom/test/virtual-dom.test.ts index e414e230..2a9cfc37 100644 --- a/packages/rrdom/test/virtual-dom.test.ts +++ b/packages/rrdom/test/virtual-dom.test.ts @@ -18,7 +18,7 @@ import { NodeType, NodeType as RRNodeType, textNode, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import { buildFromDom, buildFromNode, diff --git a/packages/rrweb-player/LICENSE b/packages/rrweb-player/LICENSE new file mode 100644 index 00000000..fce28eb8 --- /dev/null +++ b/packages/rrweb-player/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb/graphs/contributors) and SmartX Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rrweb-player/package.json b/packages/rrweb-player/package.json index 9489effe..8ec492f0 100644 --- a/packages/rrweb-player/package.json +++ b/packages/rrweb-player/package.json @@ -1,6 +1,6 @@ { - "name": "rrweb-player", - "version": "1.0.0-alpha.3", + "name": "@highlight-run/rrweb-player", + "version": "1.0.1", "devDependencies": { "@rollup/plugin-commonjs": "^22.0.0", "@rollup/plugin-node-resolve": "^13.2.1", @@ -23,8 +23,8 @@ "typescript": "^4.7.3" }, "dependencies": { - "@tsconfig/svelte": "^1.0.0", - "rrweb": "^2.0.0-alpha.3" + "@highlight-run/rrweb": "2.1.10", + "@tsconfig/svelte": "^1.0.1" }, "scripts": { "build": "rollup -c", diff --git a/packages/rrweb-player/src/main.ts b/packages/rrweb-player/src/main.ts index 341e39aa..23847f5e 100644 --- a/packages/rrweb-player/src/main.ts +++ b/packages/rrweb-player/src/main.ts @@ -1,4 +1,4 @@ -import type { eventWithTime } from 'rrweb/typings/types'; +import type { eventWithTime } from '@highlight-run/rrweb/typings/types'; import _Player from './Player.svelte'; type PlayerProps = { diff --git a/packages/rrweb-player/typings/index.d.ts b/packages/rrweb-player/typings/index.d.ts index b6d980b0..5534898a 100644 --- a/packages/rrweb-player/typings/index.d.ts +++ b/packages/rrweb-player/typings/index.d.ts @@ -1,5 +1,5 @@ -import { eventWithTime, playerConfig } from 'rrweb/typings/types'; -import { Replayer, mirror } from 'rrweb'; +import { eventWithTime, playerConfig } from '@highlight-run/rrweb/typings/types'; +import { Replayer, mirror } from '@highlight-run/rrweb'; import { SvelteComponent } from 'svelte'; export type RRwebPlayerOptions = { diff --git a/packages/rrweb-snapshot/LICENSE b/packages/rrweb-snapshot/LICENSE new file mode 100644 index 00000000..fce28eb8 --- /dev/null +++ b/packages/rrweb-snapshot/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb/graphs/contributors) and SmartX Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rrweb-snapshot/jest.config.js b/packages/rrweb-snapshot/jest.config.js index 46cb05c3..9749329f 100644 --- a/packages/rrweb-snapshot/jest.config.js +++ b/packages/rrweb-snapshot/jest.config.js @@ -3,4 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/**.test.ts'], + globals: { + window: {} + } }; diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 9cc83921..361b8678 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -1,6 +1,6 @@ { - "name": "rrweb-snapshot", - "version": "2.0.0-alpha.3", + "name": "@highlight-run/rrweb-snapshot", + "version": "2.0.1", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "scripts": { "prepare": "npm run prepack", diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index a491122d..6792a217 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -63,9 +63,22 @@ function escapeRegExp(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } +declare global { + interface Window { + HIG_CONFIGURATION?: { + enableOnHoverClass?: boolean; + }; + } +} + const HOVER_SELECTOR = /([^\\]):hover/; const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); export function addHoverClass(cssText: string, cache: BuildCache): string { + /* Begin Highlight Code */ + if (!window?.HIG_CONFIGURATION?.enableOnHoverClass) { + return cssText; + } + /* End Highlight Code */ const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; @@ -181,6 +194,49 @@ function buildNode( const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText'; if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') { value = addHoverClass(value, cache); + + /** Start of Highlight Code */ + /** + * Find all remote fonts in the style tag. + * We need to find and replace the URLs with a proxy URL so we can bypass CORS. + */ + if (typeof value === 'string') { + const regex = /url\(\"https:\/\/\S*(.eot|.woff2|.ttf|.woff)\S*\"\)/gm; + let m; + const fontUrls: { originalUrl: string; proxyUrl: string }[] = []; + + const PROXY_URL = 'https://replay-cors-proxy.highlightrun.workers.dev' as const; + while ((m = regex.exec(value)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + // The result can be accessed through the `m`-variable. + m.forEach((match, groupIndex) => { + if (groupIndex === 0) { + // Trim the start and end + // example: url("https://app.boardgent.com/fonts/MaterialIcons-Regular.53354891.woff2") + // gets trimmed to https://app.boardgent.com/fonts/MaterialIcons-Regular.53354891.woff2 + const url = match.slice(5, match.length - 2); + + fontUrls.push({ + originalUrl: url, + proxyUrl: url.replace(url, `${PROXY_URL}?url=${url}`), + }); + } + }); + } + + // Replace all references to the old URL to our proxy URL in the stylesheet. + fontUrls.forEach((urlPair) => { + value = (value as string).replace( + urlPair.originalUrl, + urlPair.proxyUrl, + ); + }); + } + /** End of Highlight Code */ } if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') { const child = doc.createTextNode(value); diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 99a23ff7..cae9075f 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -18,6 +18,7 @@ import { isElement, isShadowRoot, maskInputValue, + obfuscateText, isNativeShadowDom, getCssRulesString, } from './utils'; @@ -426,6 +427,9 @@ function serializeNode( * `newlyAddedElement: true` skips scrollTop and scrollLeft check */ newlyAddedElement?: boolean; + /** Highlight Options Start */ + enableStrictPrivacy: boolean; + /** Highlight Options End */ }, ): serializedNode | false { const { @@ -444,6 +448,7 @@ function serializeNode( recordCanvas, keepIframeSrcFn, newlyAddedElement = false, + enableStrictPrivacy, } = options; // Only record root id when document object is not the base document const rootId = getRootId(doc, mirror); @@ -477,11 +482,13 @@ function serializeNode( inlineStylesheet, maskInputOptions, maskInputFn, + maskTextClass, dataURLOptions, inlineImages, recordCanvas, keepIframeSrcFn, newlyAddedElement, + enableStrictPrivacy, rootId, }); case n.TEXT_NODE: @@ -489,6 +496,7 @@ function serializeNode( maskTextClass, maskTextSelector, maskTextFn, + enableStrictPrivacy, rootId, }); case n.CDATA_SECTION_NODE: @@ -520,16 +528,25 @@ function serializeTextNode( maskTextClass: string | RegExp; maskTextSelector: string | null; maskTextFn: MaskTextFn | undefined; + enableStrictPrivacy: boolean; rootId: number | undefined; }, ): serializedNode { - const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options; + const { + maskTextClass, + maskTextSelector, + maskTextFn, + enableStrictPrivacy, + rootId, + } = options; // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName; let textContent = n.textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined; + /** Determines if this node has been handled already. */ + let textContentHandled = false; if (isStyle && textContent) { try { // try to read style sheet @@ -550,9 +567,14 @@ function serializeTextNode( ); } textContent = absoluteToStylesheet(textContent, getHref()); + textContentHandled = true; } if (isScript) { textContent = 'SCRIPT_PLACEHOLDER'; + textContentHandled = true; + } else if (parentTagName === 'NOSCRIPT') { + textContent = ''; + textContentHandled = true; } if ( !isStyle && @@ -565,6 +587,24 @@ function serializeTextNode( : textContent.replace(/[\S]/g, '*'); } + /* Start of Highlight */ + // Randomizes the text content to a string of the same length. + if (enableStrictPrivacy && !textContentHandled && parentTagName) { + const IGNORE_TAG_NAMES = new Set([ + 'HEAD', + 'TITLE', + 'STYLE', + 'SCRIPT', + 'HTML', + 'BODY', + 'NOSCRIPT', + ]); + if (!IGNORE_TAG_NAMES.has(parentTagName) && textContent) { + textContent = obfuscateText(textContent); + } + } + /* End of Highlight */ + return { type: NodeType.Text, textContent: textContent || '', @@ -582,6 +622,7 @@ function serializeElementNode( inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; maskInputFn: MaskInputFn | undefined; + maskTextClass: string | RegExp; dataURLOptions?: DataURLOptions; inlineImages: boolean; recordCanvas: boolean; @@ -590,6 +631,7 @@ function serializeElementNode( * `newlyAddedElement: true` skips scrollTop and scrollLeft check */ newlyAddedElement?: boolean; + enableStrictPrivacy: boolean; rootId: number | undefined; }, ): serializedNode | false { @@ -600,14 +642,17 @@ function serializeElementNode( inlineStylesheet, maskInputOptions = {}, maskInputFn, + maskTextClass, dataURLOptions = {}, inlineImages, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, + enableStrictPrivacy, rootId, } = options; - const needBlock = _isBlockedElement(n, blockClass, blockSelector); + let needBlock = _isBlockedElement(n, blockClass, blockSelector); + const needMask = _isBlockedElement(n, maskTextClass, null); const tagName = getValidTagName(n); let attributes: attributes = {}; const len = n.attributes.length; @@ -713,7 +758,13 @@ function serializeElementNode( } } // save image offline - if (tagName === 'img' && inlineImages) { + if ( + tagName === 'img' && + inlineImages && + !needBlock && + !needMask && + !enableStrictPrivacy + ) { if (!canvasService) { canvasService = doc.createElement('canvas'); canvasCtx = canvasService.getContext('2d'); @@ -764,13 +815,16 @@ function serializeElementNode( } } // block element - if (needBlock) { + if (needBlock || needMask || (tagName === 'img' && enableStrictPrivacy)) { const { width, height } = n.getBoundingClientRect(); attributes = { class: attributes.class, rr_width: `${width}px`, rr_height: `${height}px`, }; + if (enableStrictPrivacy) { + needBlock = true; + } } // iframe if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { @@ -789,6 +843,7 @@ function serializeElementNode( childNodes: [], isSVG: isSVGElement(n as Element) || undefined, needBlock, + needMask, rootId, }; } @@ -919,6 +974,7 @@ export function serializeNodeWithId( node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; + enableStrictPrivacy: boolean; onStylesheetLoad?: ( linkNode: HTMLLinkElement, node: serializedElementNodeWithId, @@ -949,6 +1005,7 @@ export function serializeNodeWithId( stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, newlyAddedElement = false, + enableStrictPrivacy, } = options; let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { @@ -967,6 +1024,7 @@ export function serializeNodeWithId( recordCanvas, keepIframeSrcFn, newlyAddedElement, + enableStrictPrivacy, }); if (!_serializedNode) { // TODO: dev only @@ -1002,10 +1060,26 @@ export function serializeNodeWithId( onSerialize(n); } let recordChild = !skipChild; + let strictPrivacy = enableStrictPrivacy; if (serializedNode.type === NodeType.Element) { recordChild = recordChild && !serializedNode.needBlock; - // this property was not needed in replay side + strictPrivacy = + enableStrictPrivacy || + !!serializedNode.needBlock || + !!serializedNode.needMask; + + /** Highlight Code Begin */ + // Remove the image's src if enableStrictPrivacy. + if (strictPrivacy && serializedNode.tagName === 'img') { + const clone = n.cloneNode(); + ((clone as unknown) as HTMLImageElement).src = ''; + mirror.add(clone, serializedNode); + } + /** Highlight Code End */ + + // these properties was not needed in replay side delete serializedNode.needBlock; + delete serializedNode.needMask; const shadowRoot = (n as HTMLElement).shadowRoot; if (shadowRoot && isNativeShadowDom(shadowRoot)) serializedNode.isShadowHost = true; @@ -1046,6 +1120,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + enableStrictPrivacy: strictPrivacy, }; for (const childN of Array.from(n.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); @@ -1106,6 +1181,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + enableStrictPrivacy, }); if (serializedIframeNode) { @@ -1153,6 +1229,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + enableStrictPrivacy, }); if (serializedLinkNode) { @@ -1199,13 +1276,14 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + enableStrictPrivacy: boolean; }, ): serializedNodeWithId | null { const { mirror = new Mirror(), - blockClass = 'rr-block', + blockClass = 'highlight-block', blockSelector = null, - maskTextClass = 'rr-mask', + maskTextClass = 'highlight-mask', maskTextSelector = null, inlineStylesheet = true, inlineImages = false, @@ -1222,6 +1300,7 @@ function snapshot( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn = () => false, + enableStrictPrivacy = false, } = options || {}; const maskInputOptions: MaskInputOptions = maskAllInputs === true @@ -1290,6 +1369,7 @@ function snapshot( stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, + enableStrictPrivacy, }); } diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index dcbf0439..01b59378 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -38,6 +38,7 @@ export type elementNode = { childNodes: serializedNodeWithId[]; isSVG?: true; needBlock?: boolean; + needMask?: boolean; }; export type textNode = { diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 8f63e44f..a44038cf 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -185,3 +185,21 @@ export function is2DCanvasBlank(canvas: HTMLCanvasElement): boolean { } return true; } + +/** Start of Highlight Code + * Returns a string of the same length that has been obfuscated. + */ +export function obfuscateText(text: string): string { + // We remove non-printing characters. + // For example: '‌' is a character that isn't shown visibly or takes up layout space on the screen. However if you take the length of the string, it's counted as 1. + // For example: "‌1"'s length is 2 but visually it's only taking up 1 character width. + // If we don't filter does out, our string obfuscation could have more characters than what was originally presented. + text = text.replace(/[^ -~]+/g, ''); + text = + text + ?.split(' ') + .map((word) => Math.random().toString(20).substr(2, word.length)) + .join(' ') || ''; + return text; +} +/* End of Highlight Code */ diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 0706eba1..64b8f30f 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -167,10 +167,10 @@ exports[`integration tests [html file]: block-element.html 1`] = ` Document -
+
record 2
-
-
+
+
" `; @@ -312,11 +312,11 @@ exports[`integration tests [html file]: mask-text.html 1`] = ` Document -

**** *

-
+

**** *

+
**** *
-
**** *
+
**** *
" `; diff --git a/packages/rrweb-snapshot/test/html/block-element.html b/packages/rrweb-snapshot/test/html/block-element.html index f9671d2a..573c4fa2 100644 --- a/packages/rrweb-snapshot/test/html/block-element.html +++ b/packages/rrweb-snapshot/test/html/block-element.html @@ -19,9 +19,9 @@ -
block 1
+
block 1
record 2
-
block 3
-
block 3
+
block 3
+
block 3
diff --git a/packages/rrweb-snapshot/test/html/mask-text.html b/packages/rrweb-snapshot/test/html/mask-text.html index fe177a61..e31eab8f 100644 --- a/packages/rrweb-snapshot/test/html/mask-text.html +++ b/packages/rrweb-snapshot/test/html/mask-text.html @@ -8,10 +8,10 @@ -

mask 1

-
+

mask 1

+
mask 2
-
mask 3
+
mask 3
diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 75d635e0..e23e6962 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -112,7 +112,7 @@ describe('absolute url to stylesheet', () => { describe('isBlockedElement()', () => { const subject = (html: string, opt: any = {}) => - _isBlockedElement(render(html), 'rr-block', opt.blockSelector); + _isBlockedElement(render(html), 'highlight-block', opt.blockSelector); const render = (html: string): HTMLElement => JSDOM.fragment(html).querySelector('div')!; @@ -122,16 +122,18 @@ describe('isBlockedElement()', () => { }); it('blocks prohibited className', () => { - expect(subject('
')).toEqual(true); + expect(subject('
')).toEqual(true); }); it('does not block random data selector', () => { - expect(subject('
')).toEqual(false); + expect(subject('
')).toEqual(false); }); it('blocks blocked selector', () => { expect( - subject('
', { blockSelector: '[data-rr-block]' }), + subject('
', { + blockSelector: '[data-highlight-block]', + }), ).toEqual(true); }); }); diff --git a/packages/rrweb/LICENSE b/packages/rrweb/LICENSE new file mode 100644 index 00000000..fce28eb8 --- /dev/null +++ b/packages/rrweb/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb/graphs/contributors) and SmartX Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index c83a9086..e35ff8b9 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -1,6 +1,6 @@ { - "name": "rrweb", - "version": "2.0.0-alpha.3", + "name": "@highlight-run/rrweb", + "version": "2.1.10", "description": "record and replay the web", "scripts": { "prepare": "npm run prepack", @@ -80,12 +80,12 @@ "typescript": "^4.7.3" }, "dependencies": { + "@highlight-run/rrdom": "0.1.17", + "@highlight-run/rrweb-snapshot": "1.1.31", "@types/css-font-loading-module": "0.0.7", "@xstate/fsm": "^1.4.0", "base64-arraybuffer": "^1.0.1", "fflate": "^0.4.4", - "mitt": "^3.0.0", - "rrdom": "^0.1.6", - "rrweb-snapshot": "^2.0.0-alpha.3" + "mitt": "^3.0.0" } } diff --git a/packages/rrweb/rollup.config.js b/packages/rrweb/rollup.config.js index 79f17e03..c8beb85c 100644 --- a/packages/rrweb/rollup.config.js +++ b/packages/rrweb/rollup.config.js @@ -119,7 +119,7 @@ const baseConfigs = [ let configs = []; function getPlugins(options = {}) { - const { minify = false, sourceMap = false } = options; + const { minify = true, sourceMap = false } = options; return [ resolve({ browser: true }), webWorkerLoader({ @@ -152,6 +152,7 @@ for (const c of baseConfigs) { postcss({ extract: false, inject: false, + minimize: true, }), ); // browser @@ -229,19 +230,38 @@ if (process.env.BROWSER_ONLY) { name: 'rrwebCanvasWebRTCReplay', pathFn: toPluginPath('canvas-webrtc', 'replay'), }, + { + input: './src/plugins/sequential-id/record/index.ts', + name: 'rrwebSequentialIdRecord', + pathFn: toPluginPath('sequential-id', 'record'), + }, ]; configs = []; + // browser record + replay, unminified (for profiling and performance testing) + configs.push({ + input: './src/index.ts', + plugins: getPlugins(), + output: [ + { + name: 'rrweb', + format: 'iife', + file: pkg.unpkg, + }, + ], + }); + for (const c of browserOnlyBaseConfigs) { configs.push({ input: c.input, - plugins: getPlugins(), + plugins: getPlugins({ sourceMap: true, minify: true }), output: [ { name: c.name, format: 'iife', file: c.pathFn(pkg.unpkg), + sourcemap: true, }, ], }); diff --git a/packages/rrweb/src/entries/all.ts b/packages/rrweb/src/entries/all.ts index d67ff924..43668271 100644 --- a/packages/rrweb/src/entries/all.ts +++ b/packages/rrweb/src/entries/all.ts @@ -2,3 +2,5 @@ export * from '../index'; export * from '../packer'; export * from '../plugins/console/record'; export * from '../plugins/console/replay'; +export { getRecordSequentialIdPlugin } from '../plugins/sequential-id/record'; +export { getReplaySequentialIdPlugin } from '../plugins/sequential-id/replay'; diff --git a/packages/rrweb/src/plugins/canvas-webrtc/record/index.ts b/packages/rrweb/src/plugins/canvas-webrtc/record/index.ts index 00315c8a..3a2993db 100644 --- a/packages/rrweb/src/plugins/canvas-webrtc/record/index.ts +++ b/packages/rrweb/src/plugins/canvas-webrtc/record/index.ts @@ -1,4 +1,4 @@ -import type { Mirror } from 'rrweb-snapshot'; +import type { Mirror } from '@highlight-run/rrweb-snapshot'; import SimplePeer from 'simple-peer-light'; import type CrossOriginIframeMirror from '../../../record/cross-origin-iframe-mirror'; import type { RecordPlugin } from '../../../types'; diff --git a/packages/rrweb/src/plugins/canvas-webrtc/replay/index.ts b/packages/rrweb/src/plugins/canvas-webrtc/replay/index.ts index 45a12970..c94f2ac5 100644 --- a/packages/rrweb/src/plugins/canvas-webrtc/replay/index.ts +++ b/packages/rrweb/src/plugins/canvas-webrtc/replay/index.ts @@ -1,5 +1,5 @@ -import type { RRNode } from 'rrdom'; -import type { Mirror } from 'rrweb-snapshot'; +import type { RRNode } from '@highlight-run/rrdom'; +import type { Mirror } from '@highlight-run/rrweb-snapshot'; import SimplePeer from 'simple-peer-light'; import type { Replayer } from '../../../replay'; import type { ReplayPlugin } from '../../../types'; diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 9df9a855..c09e5022 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -1,5 +1,5 @@ -import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; -import { genId } from 'rrweb-snapshot'; +import type { Mirror, serializedNodeWithId } from '@highlight-run/rrweb-snapshot'; +import { genId } from '@highlight-run/rrweb-snapshot'; import { CrossOriginIframeMessageEvent, EventType, diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 601604ce..4bab98ac 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -3,7 +3,7 @@ import { MaskInputOptions, SlimDOMOptions, createMirror, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import { initObservers, mutationBuffers } from './observer'; import { on, @@ -31,6 +31,7 @@ import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; import { StylesheetManager } from './stylesheet-manager'; +import { obfuscateText } from '@highlight-run/rrweb-snapshot'; function wrapEvent(e: event): eventWithTime { return { @@ -53,17 +54,17 @@ function record( emit, checkoutEveryNms, checkoutEveryNth, - blockClass = 'rr-block', + blockClass = 'highlight-block', blockSelector = null, - ignoreClass = 'rr-ignore', - maskTextClass = 'rr-mask', + ignoreClass = 'highlight-ignore', + maskTextClass = 'highlight-mask', maskTextSelector = null, inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, slimDOMOptions: _slimDOMOptions, maskInputFn, - maskTextFn, + maskTextFn = obfuscateText, hooks, packFn, sampling = {}, @@ -76,6 +77,7 @@ function record( inlineImages = false, plugins, keepIframeSrcFn = () => false, + enableStrictPrivacy = false, ignoreCSSAttributes = new Set([]), } = options; @@ -290,8 +292,11 @@ function record( blockClass, blockSelector, mirror, - sampling: sampling.canvas, + sampling: sampling?.canvas?.fps, dataURLOptions, + resizeQuality: sampling?.canvas?.resizeQuality, + resizeFactor: sampling?.canvas?.resizeFactor, + maxSnapshotDimension: sampling?.canvas?.maxSnapshotDimension, }); const shadowDomManager = new ShadowDomManager({ @@ -315,6 +320,7 @@ function record( stylesheetManager, canvasManager, keepIframeSrcFn, + enableStrictPrivacy, }, mirror, }); @@ -349,6 +355,7 @@ function record( dataURLOptions, recordCanvas, inlineImages, + enableStrictPrivacy, onSerialize: (n) => { if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); @@ -542,6 +549,7 @@ function record( shadowDomManager, canvasManager, ignoreCSSAttributes, + enableStrictPrivacy, plugins: plugins ?.filter((p) => p.observer) @@ -607,7 +615,9 @@ function record( record.addCustomEvent = (tag: string, payload: T) => { if (!recording) { - throw new Error('please add custom event after start recording'); + /* Highlight Code - disable this warning */ + // throw new Error('please add custom event after start recording'); + return; } wrappedEmit( wrapEvent({ diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 2a56a517..c508018d 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -5,9 +5,10 @@ import { isShadowRoot, needMaskingText, maskInputValue, + obfuscateText, Mirror, isNativeShadowDom, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import type { mutationRecord, textCursor, @@ -176,6 +177,7 @@ export default class MutationBuffer { private stylesheetManager: observerParam['stylesheetManager']; private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; + private enableStrictPrivacy: observerParam['enableStrictPrivacy']; public init(options: MutationBufferParam) { ([ @@ -199,6 +201,7 @@ export default class MutationBuffer { 'stylesheetManager', 'shadowDomManager', 'canvasManager', + 'enableStrictPrivacy', ] as const).forEach((key) => { // just a type trick, the runtime result is correct this[key] = options[key] as never; @@ -311,6 +314,7 @@ export default class MutationBuffer { dataURLOptions: this.dataURLOptions, recordCanvas: this.recordCanvas, inlineImages: this.inlineImages, + enableStrictPrivacy: this.enableStrictPrivacy, onSerialize: (currentN) => { if (isSerializedIframe(currentN, this.mirror)) { this.iframeManager.addIframe(currentN as HTMLIFrameElement); @@ -426,10 +430,18 @@ export default class MutationBuffer { const payload = { texts: this.texts - .map((text) => ({ - id: this.mirror.getId(text.node), - value: text.value, - })) + .map((text) => { + /* Begin Highlight Code */ + let value = text.value; + if (this.enableStrictPrivacy && value) { + value = obfuscateText(value); + } + return { + id: this.mirror.getId(text.node), + value, + }; + /* End Highlight Code */ + }) // text mutation's id was not in the mirror map means the target node has been removed .filter((text) => this.mirror.has(text.id)), attributes: this.attributes @@ -566,6 +578,16 @@ export default class MutationBuffer { } } } else { + /* Begin Highlight Code */ + const tagName = (m.target as HTMLElement).tagName; + if (tagName === 'INPUT') { + const node = m.target as HTMLInputElement; + if (node.type === 'password') { + item.attributes['value'] = '*'.repeat(node.value.length); + break; + } + } + /* End Highlight Code */ // overwrite attribute if the mutations was triggered in same time item.attributes[m.attributeName!] = transformAttribute( this.doc, diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 6fb2edad..3d496c80 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1,4 +1,4 @@ -import { MaskInputOptions, maskInputValue, Mirror } from 'rrweb-snapshot'; +import { MaskInputOptions, maskInputValue, Mirror } from '@highlight-run/rrweb-snapshot'; import type { FontFaceSet } from 'css-font-loading-module'; import { throttle, @@ -9,6 +9,7 @@ import { isBlocked, isTouchEvent, patch, + isCanvasNode, StyleSheetMirror, } from '../utils'; import { @@ -229,9 +230,15 @@ function initMouseInteractionObserver({ const getHandler = (eventKey: keyof typeof MouseInteractions) => { return (event: MouseEvent | TouchEvent) => { const target = getEventTarget(event) as Node; - if (isBlocked(target, blockClass, blockSelector, true)) { + /* Start of Highlight Code */ + if ( + isBlocked(target, blockClass, blockSelector, true) || + // We ignore canvas elements for rage click detection because we cannot infer what inside the canvas is getting interacted with. + isCanvasNode(target) + ) { return; } + /* End of Highlight Code */ const e = isTouchEvent(event) ? event.changedTouches[0] : event; if (!e) { return; diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index 8de2aa77..d6244e3f 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -1,4 +1,3 @@ -import type { Mirror } from 'rrweb-snapshot'; import { blockClass, CanvasContext, diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index 2974d2a4..8ff749f1 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -1,4 +1,4 @@ -import type { ICanvas, Mirror, DataURLOptions } from 'rrweb-snapshot'; +import type { ICanvas, Mirror, DataURLOptions } from '@highlight-run/rrweb-snapshot'; import type { blockClass, canvasManagerMutationCallback, @@ -64,6 +64,9 @@ export class CanvasManager { mirror: Mirror; sampling?: 'all' | number; dataURLOptions: DataURLOptions; + resizeQuality?: 'pixelated' | 'low' | 'medium' | 'high'; + resizeFactor?: number; + maxSnapshotDimension?: number; }) { const { sampling = 'all', @@ -81,7 +84,7 @@ export class CanvasManager { if (recordCanvas && typeof sampling === 'number') this.initCanvasFPSObserver(sampling, win, blockClass, blockSelector, { dataURLOptions, - }); + }, options.resizeQuality, options.resizeFactor, options.maxSnapshotDimension); } private processMutation: canvasManagerMutationCallback = ( @@ -109,11 +112,14 @@ export class CanvasManager { options: { dataURLOptions: DataURLOptions; }, + resizeQuality?: 'pixelated' | 'low' | 'medium' | 'high', + resizeFactor?: number, + maxSnapshotDimension?: number, ) { const canvasContextReset = initCanvasContextObserver( win, blockClass, - blockSelector, + blockSelector ); const snapshotInProgressMap: Map = new Map(); const worker = new ImageBitmapDataURLWorker() as ImageBitmapDataURLRequestWorker; @@ -123,14 +129,14 @@ export class CanvasManager { if (!('base64' in e.data)) return; - const { base64, type, width, height } = e.data; + const { base64, type, canvasWidth, canvasHeight } = e.data; this.mutationCb({ id, type: CanvasContext['2D'], commands: [ { property: 'clearRect', // wipe canvas - args: [0, 0, width, height], + args: [0, 0, canvasWidth, canvasHeight], }, { property: 'drawImage', // draws (semi-transparent) image @@ -147,6 +153,8 @@ export class CanvasManager { } as CanvasArg, 0, 0, + canvasWidth, + canvasHeight, ], }, ], @@ -202,13 +210,32 @@ export class CanvasManager { context?.clear(context.COLOR_BUFFER_BIT); } } - const bitmap = await createImageBitmap(canvas); + // canvas is not yet ready... this retry on the next sampling iteration. + // we don't want to crash the worker if the canvas is not yet rendered. + if (canvas.width === 0 || canvas.height === 0) { + return; + } + let scale = resizeFactor || 1; + if (maxSnapshotDimension) { + const maxDim = Math.max(canvas.width, canvas.height); + scale = Math.min(scale, maxSnapshotDimension / maxDim); + } + const width = canvas.width * scale; + const height = canvas.height * scale; + + const bitmap = await createImageBitmap(canvas, { + resizeQuality: resizeQuality || 'low', + resizeWidth: width, + resizeHeight: height, + }); worker.postMessage( { id, bitmap, - width: canvas.width, - height: canvas.height, + width, + height, + canvasWidth: canvas.width, + canvasHeight: canvas.height, dataURLOptions: options.dataURLOptions, }, [bitmap], diff --git a/packages/rrweb/src/record/observers/canvas/canvas.ts b/packages/rrweb/src/record/observers/canvas/canvas.ts index c12d93c1..844e1fa6 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas.ts @@ -1,4 +1,4 @@ -import type { ICanvas } from 'rrweb-snapshot'; +import type { ICanvas } from '@highlight-run/rrweb-snapshot'; import type { blockClass, IWindow, listenerHandler } from '../../../types'; import { isBlocked, patch } from '../../../utils'; diff --git a/packages/rrweb/src/record/observers/canvas/webgl.ts b/packages/rrweb/src/record/observers/canvas/webgl.ts index 9e8bfbf4..499a9e6c 100644 --- a/packages/rrweb/src/record/observers/canvas/webgl.ts +++ b/packages/rrweb/src/record/observers/canvas/webgl.ts @@ -1,4 +1,4 @@ -import type { Mirror } from 'rrweb-snapshot'; +import type { Mirror } from '@highlight-run/rrweb-snapshot'; import { blockClass, CanvasContext, diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 16f679c8..8eb3bb28 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -10,8 +10,8 @@ import { initAdoptedStyleSheetObserver, } from './observer'; import { patch } from '../utils'; -import type { Mirror } from 'rrweb-snapshot'; -import { isNativeShadowDom } from 'rrweb-snapshot'; +import type { Mirror } from '@highlight-run/rrweb-snapshot'; +import { isNativeShadowDom } from '@highlight-run/rrweb-snapshot'; type BypassOptions = Omit< MutationBufferParam, diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 35d8c513..ab1345b3 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -1,5 +1,5 @@ -import type { elementNode, serializedNodeWithId } from 'rrweb-snapshot'; -import { getCssRuleString } from 'rrweb-snapshot'; +import type { elementNode, serializedNodeWithId } from '@highlight-run/rrweb-snapshot'; +import { getCssRuleString } from '@highlight-run/rrweb-snapshot'; import type { adoptedStyleSheetCallback, adoptedStyleSheetParam, diff --git a/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts index 51e75dbe..13ffbe1e 100644 --- a/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts +++ b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts @@ -1,5 +1,5 @@ import { encode } from 'base64-arraybuffer'; -import type { DataURLOptions } from 'rrweb-snapshot'; +import type { DataURLOptions } from '@highlight-run/rrweb-snapshot'; import type { ImageBitmapDataURLWorkerParams, ImageBitmapDataURLWorkerResponse, @@ -49,7 +49,7 @@ const worker: ImageBitmapDataURLResponseWorker = self; // eslint-disable-next-line @typescript-eslint/no-misused-promises worker.onmessage = async function (e) { if ('OffscreenCanvas' in globalThis) { - const { id, bitmap, width, height, dataURLOptions } = e.data; + const { id, bitmap, width, height, canvasWidth, canvasHeight, dataURLOptions } = e.data; const transparentBase64 = getTransparentBlobFor( width, @@ -60,7 +60,7 @@ worker.onmessage = async function (e) { const offscreen = new OffscreenCanvas(width, height); const ctx = offscreen.getContext('2d')!; - ctx.drawImage(bitmap, 0, 0); + ctx.drawImage(bitmap, 0, 0, width, height); bitmap.close(); const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while const type = blob.type; @@ -81,6 +81,8 @@ worker.onmessage = async function (e) { base64, width, height, + canvasWidth, + canvasHeight, }); lastBlobMap.set(id, base64); } else { diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 3d59ea22..90031a54 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -8,7 +8,7 @@ import { createMirror, attributes, serializedElementNodeWithId, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import { RRDocument, createOrGetNode, @@ -16,7 +16,7 @@ import { buildFromDom, diff, getDefaultSN, -} from 'rrdom'; +} from '@highlight-run/rrdom'; import type { RRNode, RRElement, @@ -26,7 +26,7 @@ import type { RRCanvasElement, ReplayerHandler, Mirror as RRDOMMirror, -} from 'rrdom'; +} from '@highlight-run/rrdom'; import * as mittProxy from 'mitt'; import { polyfill as smoothscrollPolyfill } from './smoothscroll'; import { Timer } from './timer'; @@ -59,6 +59,7 @@ import { canvasMutationCommand, canvasMutationParam, canvasEventWithTime, + SessionInterval, selectionData, styleSheetRuleData, styleDeclarationData, @@ -84,6 +85,8 @@ import { deserializeArg } from './canvas/deserialize-args'; const SKIP_TIME_THRESHOLD = 10 * 1000; const SKIP_TIME_INTERVAL = 5 * 1000; +const SKIP_TIME_MIN = 1 * 1000; +const SKIP_DURATION_LIMIT = 60 * 60 * 1000; // https://github.com/rollup/rollup/issues/1267#issuecomment-296395734 const mitt = mittProxy.default || mittProxy; @@ -129,6 +132,8 @@ export class Replayer { private emitter: Emitter = mitt(); private nextUserInteractionEvent: eventWithTime | null; + private activityIntervals: Array = []; + private inactiveEndTimestamp: number | null; private legacy_missingNodeRetryMap: missingNodeMap = {}; @@ -177,7 +182,7 @@ export class Replayer { skipInactive: false, showWarning: true, showDebug: false, - blockClass: 'rr-block', + blockClass: 'highlight-block', liveMode: false, insertStyleRules: [], triggerFocus: true, @@ -185,6 +190,8 @@ export class Replayer { pauseAnimation: true, mouseTail: defaultMouseTailConfig, useVirtualDom: true, // Virtual-dom optimization is enabled by default. + inactiveThreshold: 0.02, + inactiveSkipTime: SKIP_TIME_INTERVAL, }; this.config = Object.assign({}, defaultConfig, config); @@ -425,6 +432,102 @@ export class Replayer { } } + /* Start Highlight Code */ + public getActivityIntervals(): Array { + if (this.activityIntervals.length == 0) { + // Preprocessing to get all active/inactive segments in a session + const allIntervals: Array = []; + const metadata = this.getMetaData(); + const userInteractionEvents = [ + { timestamp: metadata.startTime }, + ...this.service.state.context.events.filter((ev) => + this.isUserInteraction(ev), + ), + { timestamp: metadata.endTime }, + ]; + for (let i = 1; i < userInteractionEvents.length; i++) { + const currentInterval = userInteractionEvents[i - 1]; + const _event = userInteractionEvents[i]; + if ( + _event.timestamp! - currentInterval.timestamp! > + SKIP_TIME_THRESHOLD + ) { + allIntervals.push({ + startTime: currentInterval.timestamp!, + endTime: _event.timestamp!, + duration: _event.timestamp! - currentInterval.timestamp!, + active: false, + }); + } else { + allIntervals.push({ + startTime: currentInterval.timestamp!, + endTime: _event.timestamp!, + duration: _event.timestamp! - currentInterval.timestamp!, + active: true, + }); + } + } + // Merges continuous active/inactive ranges + const mergedIntervals: Array = []; + let currentInterval = allIntervals[0]; + for (let i = 1; i < allIntervals.length; i++) { + if (allIntervals[i].active != allIntervals[i - 1].active) { + mergedIntervals.push({ + startTime: currentInterval.startTime, + endTime: allIntervals[i - 1].endTime, + duration: allIntervals[i - 1].endTime - currentInterval.startTime, + active: allIntervals[i - 1].active, + }); + currentInterval = allIntervals[i]; + } + } + if (currentInterval && allIntervals.length > 0) { + mergedIntervals.push({ + startTime: currentInterval.startTime, + endTime: allIntervals[allIntervals.length - 1].endTime, + duration: + allIntervals[allIntervals.length - 1].endTime - + currentInterval.startTime, + active: allIntervals[allIntervals.length - 1].active, + }); + } + // Merges inactive segments that are less than a threshold into surrounding active sessions + // TODO: Change this from a 3n pass to n + currentInterval = mergedIntervals[0]; + for (let i = 1; i < mergedIntervals.length; i++) { + if ( + (!mergedIntervals[i].active && + mergedIntervals[i].duration > + this.config.inactiveThreshold * metadata.totalTime) || + (!mergedIntervals[i - 1].active && + mergedIntervals[i - 1].duration > + this.config.inactiveThreshold * metadata.totalTime) + ) { + this.activityIntervals.push({ + startTime: currentInterval.startTime, + endTime: mergedIntervals[i - 1].endTime, + duration: + mergedIntervals[i - 1].endTime - currentInterval.startTime, + active: mergedIntervals[i - 1].active, + }); + currentInterval = mergedIntervals[i]; + } + } + if (currentInterval && mergedIntervals.length > 0) { + this.activityIntervals.push({ + startTime: currentInterval.startTime, + endTime: mergedIntervals[mergedIntervals.length - 1].endTime, + duration: + mergedIntervals[mergedIntervals.length - 1].endTime - + currentInterval.startTime, + active: mergedIntervals[mergedIntervals.length - 1].active, + }); + } + } + return this.activityIntervals; + } + /* End Highlight Code */ + public getMetaData(): playerMetaData { const firstEvent = this.service.state.context.events[0]; const lastEvent = this.service.state.context.events[ @@ -520,6 +623,16 @@ export class Replayer { ); } + public replaceEvents(events: eventWithTime[]) { + for (const event of events) { + if (indicatesTouchDevice(event)) { + this.mouse.classList.add('touch-device'); + break; + } + } + this.service.send({ type: 'REPLACE_EVENTS', payload: { events } }); + } + public enableInteract() { this.iframe.setAttribute('scrolling', 'auto'); this.iframe.style.pointerEvents = 'auto'; @@ -658,6 +771,7 @@ export class Replayer { // do not check skip in sync return; } + this.handleInactivity(event.timestamp); if (event === this.nextUserInteractionEvent) { this.nextUserInteractionEvent = null; this.backToNormal(); @@ -739,6 +853,42 @@ export class Replayer { return wrappedCastFn; }; + /* Start of Highlight Code */ + private handleInactivity(timestamp: number, resetNext?: boolean) { + if (timestamp === this.inactiveEndTimestamp || resetNext) { + this.inactiveEndTimestamp = null; + this.backToNormal(); + } + if (this.config.skipInactive && !this.inactiveEndTimestamp) { + for (const interval of this.getActivityIntervals()) { + if ( + timestamp >= interval.startTime! && + timestamp < interval.endTime! && + !interval.active + ) { + this.inactiveEndTimestamp = interval.endTime; + break; + } + } + if (this.inactiveEndTimestamp) { + const skipTime = this.inactiveEndTimestamp! - timestamp!; + const payload = { + speed: + (skipTime / SKIP_DURATION_LIMIT) * this.config.inactiveSkipTime < + SKIP_TIME_MIN + ? skipTime / SKIP_TIME_MIN + : Math.round( + Math.max(skipTime, SKIP_DURATION_LIMIT) / + this.config.inactiveSkipTime, + ), + }; + this.speedService.send({ type: 'FAST_FORWARD', payload }); + this.emitter.emit(ReplayerEvents.SkipStart, payload); + } + } + } + /* End of Highlight Code */ + private rebuildFullSnapshot( event: fullSnapshotEvent & { timestamp: number }, isSync = false, diff --git a/packages/rrweb/src/replay/machine.ts b/packages/rrweb/src/replay/machine.ts index fd4ad7bf..69933ad6 100644 --- a/packages/rrweb/src/replay/machine.ts +++ b/packages/rrweb/src/replay/machine.ts @@ -38,6 +38,10 @@ export type PlayerEvent = event: eventWithTime; }; } + | { + type: 'REPLACE_EVENTS'; + payload: { events: eventWithTime[]; }; + } | { type: 'END'; }; @@ -107,6 +111,10 @@ export function createPlayerService( target: 'playing', actions: ['addEvent'], }, + REPLACE_EVENTS: { + target: 'playing', + actions: ['replaceEvents'], + }, }, }, paused: { @@ -127,6 +135,10 @@ export function createPlayerService( target: 'paused', actions: ['addEvent'], }, + REPLACE_EVENTS: { + target: 'paused', + actions: ['replaceEvents'], + }, }, }, live: { @@ -232,6 +244,34 @@ export function createPlayerService( return Date.now(); }, }), + /* Highlight Code Start */ + replaceEvents: assign((ctx, machineEvent) => { + const { events: curEvents, timer, baselineTime } = ctx; + if (machineEvent.type === 'REPLACE_EVENTS') { + const { events: newEvents } = machineEvent.payload; + curEvents.length = 0; + const actions: actionWithDelay[] = []; + for (const event of newEvents) { + addDelay(event, baselineTime); + curEvents.push(event); + if (event.timestamp >= timer.timeOffset + baselineTime) { + const castFn = getCastFn(event, false); + actions.push({ + doAction: () => { + castFn(); + }, + delay: event.delay!, + }); + } + } + + if (timer.isActive()) { + timer.replaceActions(actions); + } + } + return { ...ctx, events: curEvents }; + }), + /* Highlight Code End */ addEvent: assign((ctx, machineEvent) => { const { baselineTime, timer, events } = ctx; if (machineEvent.type === 'ADD_EVENT') { diff --git a/packages/rrweb/src/replay/styles/inject-style.ts b/packages/rrweb/src/replay/styles/inject-style.ts index f5018561..2ba28dfb 100644 --- a/packages/rrweb/src/replay/styles/inject-style.ts +++ b/packages/rrweb/src/replay/styles/inject-style.ts @@ -1,6 +1,7 @@ const rules: (blockClass: string) => string[] = (blockClass: string) => [ - `.${blockClass} { background: currentColor }`, 'noscript { display: none !important; }', + `.${blockClass} { background: currentColor; border-radius: 5px; }`, + `.${blockClass}:hover::after {content: 'Redacted'; color: white; background: black; text-align: center; width: 100%; display: block;}`, ]; export default rules; diff --git a/packages/rrweb/src/replay/styles/style.css b/packages/rrweb/src/replay/styles/style.css index b459e515..5a394de4 100644 --- a/packages/rrweb/src/replay/styles/style.css +++ b/packages/rrweb/src/replay/styles/style.css @@ -77,3 +77,15 @@ height: 10px; } } + +.rr-player { + position: relative; + background: white; + float: left; + border-radius: 5px; + box-shadow: 0 24px 48px rgba(17, 16, 62, 0.12); +} + +.rr-player__frame { + overflow: hidden; +} diff --git a/packages/rrweb/src/replay/timer.ts b/packages/rrweb/src/replay/timer.ts index 89d66d4b..60a89b37 100644 --- a/packages/rrweb/src/replay/timer.ts +++ b/packages/rrweb/src/replay/timer.ts @@ -41,6 +41,20 @@ export class Timer { this.actions.splice(index, 0, action); } + /* Begin Highlight Code */ + /** + * Add all actions before the timer starts + */ + public addActions(actions: actionWithDelay[]) { + this.actions = this.actions.concat(actions); + } + + public replaceActions(actions: actionWithDelay[]) { + this.actions.length = 0; + this.actions.splice(0, 0, ...actions); + } + /* End Highlight Code */ + public start() { this.timeOffset = 0; let lastTimestamp = performance.now(); diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index f16780c9..b4887b3a 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -7,12 +7,12 @@ import type { MaskInputFn, MaskTextFn, DataURLOptions, -} from 'rrweb-snapshot'; +} from '@highlight-run/rrweb-snapshot'; import type { PackFn, UnpackFn } from './packer/base'; import type { IframeManager } from './record/iframe-manager'; import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; -import type { RRNode } from 'rrdom'; +import type { RRNode } from '@highlight-run/rrdom'; import type { CanvasManager } from './record/observers/canvas/canvas-manager'; import type { StylesheetManager } from './record/stylesheet-manager'; import type CrossOriginIframeMirror from './record/cross-origin-iframe-mirror'; @@ -27,6 +27,13 @@ export enum EventType { Plugin, } +export type SessionInterval = { + startTime: number; + endTime: number; + duration: number; + active: boolean; +}; + export type domContentLoadedEvent = { type: EventType.DomContentLoaded; data: unknown; @@ -192,6 +199,32 @@ export type blockClass = string | RegExp; export type maskTextClass = string | RegExp; +export type CanvasSamplingStrategy = Partial<{ + /** + * 'all' will record every single canvas call + * number between 1 and 60, will record an image snapshots in a web-worker a (maximum) number of times per second. + * Number only supported where [`OffscreenCanvas`](http://mdn.io/offscreencanvas) is supported. + */ + fps: 'all' | number; + /** + * A scaling to apply to canvas shapshotting. Adjusts the resolution at which + * canvases are recorded by this multiple. + */ + resizeFactor: number; + /** + * The quality of canvas snapshots + */ + resizeQuality: 'pixelated' | 'low' | 'medium' | 'high'; + /** + * The maximum dimension to take canvas snapshots at. + * This setting takes precedence over resizeFactor if the resulting image size + * from the resizeFactor calculation is larger than this value. + * Eg: set to 600 to ensure that the canvas is saved with images no larger than 600px + * in either dimension (while preserving the original canvas aspect ratio). + */ + maxSnapshotDimension: number; +}>; + export type SamplingStrategy = Partial<{ /** * false means not to record mouse/touch move events @@ -220,12 +253,8 @@ export type SamplingStrategy = Partial<{ * 'last' will only record the last input value while input a sequence of chars */ input: 'all' | 'last'; - /** - * 'all' will record every single canvas call - * number between 1 and 60, will record an image snapshots in a web-worker a (maximum) number of times per second. - * Number only supported where [`OffscreenCanvas`](http://mdn.io/offscreencanvas) is supported. - */ - canvas: 'all' | number; + + canvas: CanvasSamplingStrategy; }>; export type RecordPlugin = { @@ -273,6 +302,11 @@ export type recordOptions = { // departed, please use sampling options mousemoveWait?: number; keepIframeSrcFn?: KeepIframeSrcFn; + /** + * Enabling this will disable recording of text data on the page. This is useful if you do not want to record personally identifiable information. + * Text will be randomized. Instead of seeing "Hello World" in a recording, you will see "1fds1 j59a0". + */ + enableStrictPrivacy?: boolean; }; export type observerParam = { @@ -311,6 +345,7 @@ export type observerParam = { stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; + enableStrictPrivacy: boolean; ignoreCSSAttributes: Set; plugins: Array<{ observer: ( @@ -345,6 +380,7 @@ export type MutationBufferParam = Pick< | 'stylesheetManager' | 'shadowDomManager' | 'canvasManager' + | 'enableStrictPrivacy' >; export type hooksParam = { @@ -598,6 +634,8 @@ export type ImageBitmapDataURLWorkerParams = { width: number; height: number; dataURLOptions: DataURLOptions; + canvasWidth: number; + canvasHeight: number; }; export type ImageBitmapDataURLWorkerResponse = @@ -610,6 +648,8 @@ export type ImageBitmapDataURLWorkerResponse = base64: string; width: number; height: number; + canvasWidth: number; + canvasHeight: number; }; export type fontParam = { @@ -738,6 +778,8 @@ export type playerConfig = { unpackFn?: UnpackFn; useVirtualDom: boolean; plugins?: ReplayPlugin[]; + inactiveThreshold: number; + inactiveSkipTime: number; }; export type playerMetaData = { diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 3b6b3fea..5900642d 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -9,9 +9,9 @@ import type { DeprecatedMirror, textMutation, } from './types'; -import type { IMirror, Mirror } from 'rrweb-snapshot'; -import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot'; -import type { RRNode, RRIFrameElement } from 'rrdom'; +import type { IMirror, Mirror } from '@highlight-run/rrweb-snapshot'; +import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from '@highlight-run/rrweb-snapshot'; +import type { RRNode, RRIFrameElement } from '@highlight-run/rrdom'; export function on( type: string, @@ -183,6 +183,23 @@ export function getWindowWidth(): number { ); } +/** + * Start of Highlight Code + */ +export const isCanvasNode = (node: Node | null): boolean => { + try { + if (node instanceof HTMLElement) { + return node.tagName === 'CANVAS'; + } + } catch { + return false; + } + return false; +}; +/** + * End of Highlight Code + */ + /** * Checks if the given element set to be blocked by rrweb * @param node - node to check diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index f2d86c86..e68bae8f 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -755,7 +755,7 @@ exports[`record integration tests can mask character data mutations 1`] = ` { \\"id\\": 7, \\"attributes\\": { - \\"class\\": \\"rr-mask\\" + \\"class\\": \\"highlight-mask\\" } } ], @@ -773,7 +773,7 @@ exports[`record integration tests can mask character data mutations 1`] = ` \\"type\\": 2, \\"tagName\\": \\"li\\", \\"attributes\\": { - \\"class\\": \\"rr-mask\\" + \\"class\\": \\"highlight-mask\\" }, \\"childNodes\\": [], \\"id\\": 20 @@ -4194,7 +4194,7 @@ exports[`record integration tests mutations should work when blocked class is un \\"type\\": 2, \\"tagName\\": \\"div\\", \\"attributes\\": { - \\"class\\": \\"rr-block\\", + \\"class\\": \\"highlight-block\\", \\"rr_width\\": \\"200px\\", \\"rr_height\\": \\"33px\\" }, @@ -4366,7 +4366,7 @@ exports[`record integration tests mutations should work when blocked class is un \\"type\\": 2, \\"tagName\\": \\"div\\", \\"attributes\\": { - \\"class\\": \\"rr-block\\", + \\"class\\": \\"highlight-block\\", \\"rr_width\\": \\"200px\\", \\"rr_height\\": \\"33px\\" }, @@ -4855,7 +4855,7 @@ exports[`record integration tests should mask texts 1`] = ` \\"type\\": 2, \\"tagName\\": \\"p\\", \\"attributes\\": { - \\"class\\": \\"rr-mask\\" + \\"class\\": \\"highlight-mask\\" }, \\"childNodes\\": [ { @@ -4875,7 +4875,7 @@ exports[`record integration tests should mask texts 1`] = ` \\"type\\": 2, \\"tagName\\": \\"div\\", \\"attributes\\": { - \\"class\\": \\"rr-mask\\" + \\"class\\": \\"highlight-mask\\" }, \\"childNodes\\": [ { @@ -5133,7 +5133,7 @@ exports[`record integration tests should mask texts using maskTextFn 1`] = ` \\"type\\": 2, \\"tagName\\": \\"p\\", \\"attributes\\": { - \\"class\\": \\"rr-mask\\" + \\"class\\": \\"highlight-mask\\" }, \\"childNodes\\": [ { @@ -5153,7 +5153,7 @@ exports[`record integration tests should mask texts using maskTextFn 1`] = ` \\"type\\": 2, \\"tagName\\": \\"div\\", \\"attributes\\": { - \\"class\\": \\"rr-mask\\" + \\"class\\": \\"highlight-mask\\" }, \\"childNodes\\": [ { @@ -6531,7 +6531,7 @@ exports[`record integration tests should not record blocked elements and its chi \\"type\\": 2, \\"tagName\\": \\"div\\", \\"attributes\\": { - \\"class\\": \\"rr-block\\", + \\"class\\": \\"highlight-block\\", \\"rr_width\\": \\"50px\\", \\"rr_height\\": \\"50px\\" }, @@ -6711,7 +6711,7 @@ exports[`record integration tests should not record blocked elements dynamically \\"type\\": 2, \\"tagName\\": \\"div\\", \\"attributes\\": { - \\"class\\": \\"rr-block\\", + \\"class\\": \\"highlight-block\\", \\"rr_width\\": \\"50px\\", \\"rr_height\\": \\"50px\\" }, @@ -6771,7 +6771,7 @@ exports[`record integration tests should not record blocked elements dynamically \\"type\\": 2, \\"tagName\\": \\"button\\", \\"attributes\\": { - \\"class\\": \\"rr-block\\", + \\"class\\": \\"highlight-block\\", \\"rr_width\\": \\"100px\\", \\"rr_height\\": \\"100px\\" }, @@ -6940,7 +6940,7 @@ exports[`record integration tests should not record input events on ignored elem \\"tagName\\": \\"input\\", \\"attributes\\": { \\"type\\": \\"text\\", - \\"class\\": \\"rr-ignore\\" + \\"class\\": \\"highlight-ignore\\" }, \\"childNodes\\": [], \\"id\\": 22 diff --git a/packages/rrweb/test/__snapshots__/replayer.test.ts.snap b/packages/rrweb/test/__snapshots__/replayer.test.ts.snap index 66234059..ec4cca57 100644 --- a/packages/rrweb/test/__snapshots__/replayer.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/replayer.test.ts.snap @@ -46,7 +46,7 @@ file-frame-5 file-cid-0 @charset \\"utf-8\\"; -.rr-block { background: currentcolor; } +.highlight-block { background: currentcolor; } noscript { display: none !important; } @@ -126,7 +126,7 @@ file-frame-5 file-cid-0 @charset \\"utf-8\\"; -.rr-block { background: currentcolor; } +.highlight-block { background: currentcolor; } noscript { display: none !important; } @@ -198,7 +198,7 @@ file-frame-2 file-cid-0 @charset \\"utf-8\\"; -.rr-block { background: currentcolor; } +.highlight-block { background: currentcolor; } noscript { display: none !important; } @@ -249,7 +249,7 @@ file-frame-2 file-cid-0 @charset \\"utf-8\\"; -.rr-block { background: currentcolor; } +.highlight-block { background: currentcolor; } noscript { display: none !important; } @@ -300,7 +300,7 @@ file-frame-2 file-cid-0 @charset \\"utf-8\\"; -.rr-block { background: currentcolor; } +.highlight-block { background: currentcolor; } noscript { display: none !important; } diff --git a/packages/rrweb/test/html/block.html b/packages/rrweb/test/html/block.html index 6fee77f7..4cef3cb6 100644 --- a/packages/rrweb/test/html/block.html +++ b/packages/rrweb/test/html/block.html @@ -7,7 +7,7 @@ Block record -
+
diff --git a/packages/rrweb/test/html/blocked-unblocked.html b/packages/rrweb/test/html/blocked-unblocked.html index 5d82c6cd..38d26696 100644 --- a/packages/rrweb/test/html/blocked-unblocked.html +++ b/packages/rrweb/test/html/blocked-unblocked.html @@ -71,7 +71,7 @@

Verify that block class bugs are fixed




-
+



@@ -83,7 +83,7 @@

Verify that block class bugs are fixed




-
+



diff --git a/packages/rrweb/test/html/ignore.html b/packages/rrweb/test/html/ignore.html index f46c2efd..30c4a29c 100644 --- a/packages/rrweb/test/html/ignore.html +++ b/packages/rrweb/test/html/ignore.html @@ -9,7 +9,7 @@
- +
diff --git a/packages/rrweb/test/html/mask-text.html b/packages/rrweb/test/html/mask-text.html index 2abaaaa5..f27e656d 100644 --- a/packages/rrweb/test/html/mask-text.html +++ b/packages/rrweb/test/html/mask-text.html @@ -7,8 +7,8 @@ Mask text -

mask1

-
+

mask1

+
mask2
diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index cdabd48b..056aec6c 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -17,7 +17,7 @@ import { EventType, RecordPlugin, } from '../src/types'; -import { visitSnapshot, NodeType } from 'rrweb-snapshot'; +import { visitSnapshot, NodeType } from '@highlight-run/rrweb-snapshot'; describe('record integration tests', function (this: ISuite) { jest.setTimeout(10_000); @@ -205,7 +205,7 @@ describe('record integration tests', function (this: ISuite) { await page.goto('about:blank'); await page.setContent(getHtml.call(this, 'ignore.html')); - await page.type('.rr-ignore', 'secret'); + await page.type('.highlight-ignore', 'secret'); const snapshots = (await page.evaluate( 'window.snapshots', @@ -320,12 +320,12 @@ describe('record integration tests', function (this: ISuite) { await page.evaluate(() => { const el = document.createElement('button'); - el.className = 'rr-block'; + el.className = 'highlight-block'; el.style.width = '100px'; el.style.height = '100px'; el.innerText = 'Should not be recorded'; - const nextElement = document.querySelector('.rr-block')!; + const nextElement = document.querySelector('.highlight-block')!; nextElement.parentNode!.insertBefore(el, nextElement); }); @@ -506,7 +506,8 @@ describe('record integration tests', function (this: ISuite) { await page.goto('about:blank'); await page.setContent( getHtml('log.html', { - plugins: ('[rrwebConsoleRecord.getRecordConsolePlugin()]' as unknown) as RecordPlugin[], + plugins: + '[rrwebConsoleRecord.getRecordConsolePlugin()]' as unknown as RecordPlugin[], }), ); @@ -681,8 +682,8 @@ describe('record integration tests', function (this: ISuite) { await page.evaluate(() => { // get contentDocument of iframe five - const contentDocument1 = document.querySelector('iframe')! - .contentDocument!; + const contentDocument1 = + document.querySelector('iframe')!.contentDocument!; // create shadow dom #1 contentDocument1.body.attachShadow({ mode: 'open' }); contentDocument1.body.shadowRoot!.appendChild( @@ -863,7 +864,7 @@ describe('record integration tests', function (this: ISuite) { const ul = document.querySelector('ul') as HTMLUListElement; const p = document.querySelector('p') as HTMLParagraphElement; [li, p].forEach((element) => { - element.className = 'rr-mask'; + element.className = 'highlight-mask'; }); ul.appendChild(li); li.innerText = 'new list item'; diff --git a/packages/rrweb/test/record/webgl.test.ts b/packages/rrweb/test/record/webgl.test.ts index 8b7d18a0..4c17b5cf 100644 --- a/packages/rrweb/test/record/webgl.test.ts +++ b/packages/rrweb/test/record/webgl.test.ts @@ -15,7 +15,7 @@ import { stripBase64, waitForRAF, } from '../utils'; -import type { ICanvas } from 'rrweb-snapshot'; +import type { ICanvas } from '@highlight-run/rrweb-snapshot'; interface ISuite { code: string; diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 1eff8ce1..59893087 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -1,4 +1,4 @@ -import { NodeType } from 'rrweb-snapshot'; +import { NodeType } from '@highlight-run/rrweb-snapshot'; import { EventType, IncrementalSource,