From 9e9226fc00031dc6c2012dedcd53ec41db86b975 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 2 Aug 2024 10:55:05 +0200 Subject: [PATCH] Reverse monkey patch built in methods to support LWC (#1509) * Get around monkey patched Nodes * inlineImages: Setting of `image.crossOrigin` is not always necessary (#1468) Setting of the `crossorigin` attribute is not necessary for same-origin images, and causes an immediate image reload (albeit from cache) necessitating the use of a load event listener which subsequently mutates the snapshot. This change allows us to avoid the mutation of the snapshot for the same-origin case. * Modify inlineImages test to remove delay and show that we can inline images without mutation * Add an explicit test for when the `image.crossOrigin = 'anonymous';` method is necessary. Uses a combination of about:blank and our test server to simulate a cross-origin context * Other test changes: there were some spurious rrweb mutations being generated by the addition of the crossorigin attribute that are now elimnated from the rrweb/__snapshots__/integration.test.ts.snap after this PR - this is good * Move `childNodes` to @rrweb/utils * Use non-monkey patched versions of the `childNodes`, `parentNode` `parentElement` `textContent` accessors * Add getRootNode and contains, and add comprehensive todo list * chore: Update turbo.json tasks for better build process * Update caniuse-lite * chore: Update eslint-plugin-compat to version 5.0.0 * chore: Bump @rrweb/utils version to 2.0.0-alpha.15 * delete unused yarn.lock files * Set correct @rrweb/utils version in package.json * Migrate over some accessors to reverse-monkey-patched version * Add missing functions * Fix illegal invocation error * Revert closer to what it was. This feels incorrect to me (Justin Halsall), but some of the tests break without it so I'm restoring this to be closer to its original here: https://github.com/rrweb-io/rrweb/blame/cfd686d488a9b88dba6b6f8880b5e4375dd8062c/packages/rrweb-snapshot/src/snapshot.ts#L1011 * Reverse monkey patch all methods LWC hijacks * Make tests more stable * Safely handle rrdom nodes in hasShadowRoot * Remove duplicated test * Use variable `serverURL` in test * Use monorepo default browserlist * Fix typing issue for new typescript * Remove unused package * Remove unused code * Add prefix to reverse-monkey-patched methods to make them more explicit * Add default exports to @rrweb/utils --------- Co-authored-by: Eoghan Murray --- .changeset/config.json | 15 +- .changeset/unlucky-mirrors-invite.md | 7 + .vscode/rrweb-monorepo.code-workspace | 5 + guide.md | 1 + guide.zh_CN.md | 3 +- package.json | 4 +- .../test/index.test.ts | 2 +- packages/rrdom/test/diff/dialog.test.ts | 2 +- packages/rrweb-snapshot/package.json | 1 + packages/rrweb-snapshot/src/snapshot.ts | 63 +++-- packages/rrweb-snapshot/src/utils.ts | 10 +- .../__snapshots__/integration.test.ts.snap | 212 +++++++++++++++++ .../test/html/monkey-patched-elements.html | 45 ++++ .../rrweb-snapshot/test/integration.test.ts | 33 ++- packages/rrweb-snapshot/tsconfig.json | 10 +- packages/rrweb/package.json | 1 + packages/rrweb/src/record/index.ts | 4 +- packages/rrweb/src/record/mutation.ts | 66 +++--- packages/rrweb/src/record/observer.ts | 40 +--- .../rrweb/src/record/shadow-dom-manager.ts | 12 +- packages/rrweb/src/replay/dialog/index.ts | 4 +- packages/rrweb/src/utils.ts | 56 ++--- packages/rrweb/test/events/bad-style.ts | 4 +- packages/rrweb/test/events/dialog-playback.ts | 2 +- .../test/record/cross-origin-iframes.test.ts | 6 +- packages/rrweb/test/record/dialog.test.ts | 2 +- packages/rrweb/tsconfig.json | 9 +- packages/utils/Readme.md | 178 ++++++++++++++ packages/utils/package.json | 53 +++++ packages/utils/src/index.ts | 221 ++++++++++++++++++ packages/utils/tsconfig.json | 10 + packages/utils/vite.config.js | 4 + turbo.json | 2 +- yarn.lock | 56 +++-- 34 files changed, 942 insertions(+), 201 deletions(-) create mode 100644 .changeset/unlucky-mirrors-invite.md create mode 100644 packages/rrweb-snapshot/test/html/monkey-patched-elements.html create mode 100644 packages/utils/Readme.md create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/tsconfig.json create mode 100644 packages/utils/vite.config.js diff --git a/.changeset/config.json b/.changeset/config.json index 298e61e2b3..c5b46ea681 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,9 +9,20 @@ "@amplitude/rrdom", "@amplitude/rrdom-nodejs", "@amplitude/rrweb-player", + "@amplitude/rrweb-all", + "@amplitude/rrweb-replay", + "@amplitude/rrweb-record", "@amplitude/rrweb-types", - "@amplitude/rrweb-web-extension", - "@amplitude/rrvideo" + "@amplitude/rrweb-packer", + "@amplitude/rrweb-utils", + "@amplitude/web-extension", + "@amplitude/rrvideo", + "@amplitude/rrweb-plugin-console-record", + "@amplitude/rrweb-plugin-console-replay", + "@amplitude/rrweb-plugin-sequential-id-record", + "@amplitude/rrweb-plugin-sequential-id-replay", + "@amplitude/rrweb-plugin-canvas-webrtc-record", + "@amplitude/rrweb-plugin-canvas-webrtc-replay" ] ], "linked": [], diff --git a/.changeset/unlucky-mirrors-invite.md b/.changeset/unlucky-mirrors-invite.md new file mode 100644 index 0000000000..40901bef4d --- /dev/null +++ b/.changeset/unlucky-mirrors-invite.md @@ -0,0 +1,7 @@ +--- +"rrweb-snapshot": patch +"rrweb": patch +"@rrweb/utils": patch +--- + +Reverse monkey patch built in methods to support LWC (and other frameworks like angular which monkey patch built in methods). diff --git a/.vscode/rrweb-monorepo.code-workspace b/.vscode/rrweb-monorepo.code-workspace index d10c5e621e..ecb1672def 100644 --- a/.vscode/rrweb-monorepo.code-workspace +++ b/.vscode/rrweb-monorepo.code-workspace @@ -40,6 +40,10 @@ "name": "@rrweb/types", "path": "../packages/types" }, + { + "name": "@rrweb/utils", + "path": "../packages/utils" + }, { "name": "@rrweb/packer", "path": "../packages/packer" @@ -88,6 +92,7 @@ "@rrweb/record", "@rrweb/replay", "@rrweb/types", + "@rrweb/utils", "@rrweb/packer", "@rrweb/rrweb-plugin-console-record", "@rrweb/rrweb-plugin-console-replay", diff --git a/guide.md b/guide.md index bfdb2d14fd..764e359fb4 100644 --- a/guide.md +++ b/guide.md @@ -47,6 +47,7 @@ Besides the `rrweb` and `@rrweb/record` packages, rrweb also provides other pack - [@rrweb/replay](packages/replay): A package for replaying rrweb sessions. - [@rrweb/packer](packages/packer): A package for packing and unpacking rrweb data. - [@rrweb/types](packages/types): Contains types shared across rrweb packages. +- [@rrweb/utils](packages/utils): Contains utility functions shared across rrweb packages. - [web-extension](packages/web-extension): A web extension for rrweb. - [rrvideo](packages/rrvideo): A package for handling video operations in rrweb. - [@rrweb/rrweb-plugin-console-record](packages/plugins/rrweb-plugin-console-record): A plugin for recording console logs. diff --git a/guide.zh_CN.md b/guide.zh_CN.md index 1d56998d74..4078cb2b6a 100644 --- a/guide.zh_CN.md +++ b/guide.zh_CN.md @@ -43,7 +43,8 @@ rrweb 代码分为录制和回放两部分,大多数时候用户在被录制 - [@rrweb/record](packages/record):一个用于录制 rrweb 会话的包。 - [@rrweb/replay](packages/replay):一个用于回放 rrweb 会话的包。 - [@rrweb/packer](packages/packer):一个用于打包和解包 rrweb 数据的包。 -- [@rrweb/types](packages/types):包含 rrweb 中使用的类型定义。 +- [@rrweb/types](packages/types):包含 rrweb 包中共享的类型定义。 +- [@rrweb/utils](packages/utils):包含 rrweb 包中共享的工具函数。 - [web-extension](packages/web-extension):rrweb 的网页扩展。 - [rrvideo](packages/rrvideo):一个用于处理 rrweb 中视频操作的包。 - [@rrweb/rrweb-plugin-console-record](packages/plugins/rrweb-plugin-console-record):一个用于记录控制台日志的插件。 diff --git a/package.json b/package.json index 6d56ce1fef..1ba92d8467 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "cross-env": "^7.0.3", "esbuild-plugin-umd-wrapper": "^2.0.0", "eslint": "^8.53.0", - "eslint-plugin-compat": "^4.2.0", + "eslint-plugin-compat": "^5.0.0", "eslint-plugin-jest": "^27.6.0", "eslint-plugin-tsdoc": "^0.2.17", "markdownlint": "^0.25.1", @@ -49,7 +49,7 @@ "check-types": "yarn turbo run check-types --continue", "format": "yarn prettier --write '**/*.{ts,md}'", "format:head": "git diff --name-only HEAD^ |grep '\\.ts$\\|\\.md$' |xargs yarn prettier --write", - "dev": "yarn turbo run dev --concurrency=17", + "dev": "yarn turbo run dev --concurrency=18", "repl": "cd packages/rrweb && npm run repl", "live-stream": "cd packages/rrweb && yarn live-stream", "lint": "yarn run concurrently --success=all -r -m=1 'yarn run markdownlint docs' 'yarn eslint packages/*/src --ext .ts,.tsx,.js,.jsx,.svelte'", diff --git a/packages/plugins/rrweb-plugin-console-record/test/index.test.ts b/packages/plugins/rrweb-plugin-console-record/test/index.test.ts index b133a39ffc..8684042feb 100644 --- a/packages/plugins/rrweb-plugin-console-record/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-console-record/test/index.test.ts @@ -3,7 +3,7 @@ import { stringifySnapshots } from '../../../rrweb/test/utils'; import { createServer, ViteDevServer } from 'vite'; import * as puppeteer from 'puppeteer'; import type { Browser, Page } from 'puppeteer'; -import type { eventWithTime } from '@rrweb/types'; +import type { eventWithTime } from '@amplitude/rrweb-types'; export async function launchPuppeteer( options?: Parameters<(typeof puppeteer)['launch']>[0], diff --git a/packages/rrdom/test/diff/dialog.test.ts b/packages/rrdom/test/diff/dialog.test.ts index 11a80e6ec5..fdceb69146 100644 --- a/packages/rrdom/test/diff/dialog.test.ts +++ b/packages/rrdom/test/diff/dialog.test.ts @@ -7,7 +7,7 @@ import { createMirror, Mirror as NodeMirror, serializedNodeWithId, -} from 'rrweb-snapshot'; +} from '@amplitude/rrweb-snapshot'; import { RRDocument } from '../../src'; import { diff, ReplayerHandler } from '../../src/diff'; diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index dada018331..21a5d2293f 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -54,6 +54,7 @@ }, "homepage": "https://github.com/amplitude/rrweb/tree/master/packages/rrweb-snapshot#readme", "devDependencies": { + "@rrweb/utils": "^2.0.0-alpha.16", "@types/jsdom": "^20.0.0", "@types/node": "^18.15.11", "@types/puppeteer": "^5.4.4", diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index da284ed963..5d8edf072d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -28,6 +28,7 @@ import { extractFileExtension, absolutifyURLs, } from './utils'; +import dom from '@amplitude/rrweb-utils'; let _id = 1; const tagNameRegex = new RegExp('[^a-z0-9-_:]'); @@ -247,7 +248,7 @@ export function classMatchesRegex( if (!node) return false; if (node.nodeType !== node.ELEMENT_NODE) { if (!checkAncestors) return false; - return classMatchesRegex(node.parentNode, regex, checkAncestors); + return classMatchesRegex(dom.parentNode(node), regex, checkAncestors); } for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) { @@ -257,7 +258,7 @@ export function classMatchesRegex( } } if (!checkAncestors) return false; - return classMatchesRegex(node.parentNode, regex, checkAncestors); + return classMatchesRegex(dom.parentNode(node), regex, checkAncestors); } export function needMaskingText( @@ -269,16 +270,16 @@ export function needMaskingText( let el: Element; if (isElement(node)) { el = node; - if (!el.childNodes.length) { + if (!dom.childNodes(el).length) { // optimisation: we can avoid any of the below checks on leaf elements // as masking is applied to child text nodes only return false; } - } else if (node.parentElement === null) { + } else if (dom.parentElement(node) === null) { // should warn? maybe a text node isn't attached to a parent node yet? return false; } else { - el = node.parentElement; + el = dom.parentElement(node)!; } try { if (typeof maskTextClass === 'string') { @@ -475,7 +476,7 @@ function serializeNode( case n.COMMENT_NODE: return { type: NodeType.Comment, - textContent: (n as Comment).textContent || '', + textContent: dom.textContent(n as Comment) || '', rootId, }; default: @@ -501,11 +502,12 @@ function serializeTextNode( const { needsMask, maskTextFn, 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 parent = dom.parentNode(n); + const parentTagName = parent && (parent as HTMLElement).tagName; + let text = dom.textContent(n); const isStyle = parentTagName === 'STYLE' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined; - if (isStyle && textContent) { + if (isStyle && text) { try { // try to read style sheet if (n.nextSibling || n.previousSibling) { @@ -513,10 +515,8 @@ function serializeTextNode( // We can't read all of the sheet's .cssRules and expect them // to _only_ include the current rule(s) added by the text node. // So we'll be conservative and keep textContent as-is. - } else if ((n.parentNode as HTMLStyleElement).sheet?.cssRules) { - textContent = stringifyStylesheet( - (n.parentNode as HTMLStyleElement).sheet!, - ); + } else if ((parent as HTMLStyleElement).sheet?.cssRules) { + text = stringifyStylesheet((parent as HTMLStyleElement).sheet!); } } catch (err) { console.warn( @@ -524,20 +524,20 @@ function serializeTextNode( n, ); } - textContent = absolutifyURLs(textContent, getHref(options.doc)); + text = absolutifyURLs(text, getHref(options.doc)); } if (isScript) { - textContent = 'SCRIPT_PLACEHOLDER'; + text = 'SCRIPT_PLACEHOLDER'; } - if (!isStyle && !isScript && textContent && needsMask) { - textContent = maskTextFn - ? maskTextFn(textContent, n.parentElement) - : textContent.replace(/[\S]/g, '*'); + if (!isStyle && !isScript && text && needsMask) { + text = maskTextFn + ? maskTextFn(text, dom.parentElement(n)) + : text.replace(/[\S]/g, '*'); } return { type: NodeType.Text, - textContent: textContent || '', + textContent: text || '', isStyle, rootId, }; @@ -594,6 +594,7 @@ function serializeElementNode( } // remote css if (tagName === 'link' && inlineStylesheet) { + //TODO: maybe replace this `.styleSheets` with original one const stylesheet = Array.from(doc.styleSheets).find((s) => { return s.href === (n as HTMLLinkElement).href; }); @@ -612,7 +613,7 @@ function serializeElementNode( tagName === 'style' && (n as HTMLStyleElement).sheet && // TODO: Currently we only try to get dynamic stylesheet when it is an empty style element - !(n.innerText || n.textContent || '').trim().length + !(n.innerText || dom.textContent(n) || '').trim().length ) { const cssText = stringifyStylesheet( (n as HTMLStyleElement).sheet as CSSStyleSheet, @@ -1030,8 +1031,8 @@ export function serializeNodeWithId( recordChild = recordChild && !serializedNode.needBlock; // this property was not needed in replay side delete serializedNode.needBlock; - const shadowRoot = (n as HTMLElement).shadowRoot; - if (shadowRoot && isNativeShadowDom(shadowRoot)) + const shadowRootEl = dom.shadowRoot(n); + if (shadowRootEl && isNativeShadowDom(shadowRootEl)) serializedNode.isShadowHost = true; } if ( @@ -1080,7 +1081,7 @@ export function serializeNodeWithId( ) { // value parameter in DOM reflects the correct value, so ignore childNode } else { - for (const childN of Array.from(n.childNodes)) { + for (const childN of Array.from(dom.childNodes(n))) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); @@ -1088,11 +1089,12 @@ export function serializeNodeWithId( } } - if (isElement(n) && n.shadowRoot) { - for (const childN of Array.from(n.shadowRoot.childNodes)) { + let shadowRootEl: ShadowRoot | null = null; + if (isElement(n) && (shadowRootEl = dom.shadowRoot(n))) { + for (const childN of Array.from(dom.childNodes(shadowRootEl))) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { - isNativeShadowDom(n.shadowRoot) && + isNativeShadowDom(shadowRootEl) && (serializedChildNode.isShadow = true); serializedNode.childNodes.push(serializedChildNode); } @@ -1100,11 +1102,8 @@ export function serializeNodeWithId( } } - if ( - n.parentNode && - isShadowRoot(n.parentNode) && - isNativeShadowDom(n.parentNode) - ) { + const parent = dom.parentNode(n); + if (parent && isShadowRoot(parent) && isNativeShadowDom(parent)) { serializedNode.isShadow = true; } diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 79139ba0dc..064432b532 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -11,6 +11,7 @@ import type { textNode, elementNode, } from './types'; +import dom from '@amplitude/rrweb-utils'; import { NodeType } from './types'; export function isElement(n: Node): n is Element { @@ -18,8 +19,13 @@ export function isElement(n: Node): n is Element { } export function isShadowRoot(n: Node): n is ShadowRoot { - const host: Element | null = (n as ShadowRoot)?.host; - return Boolean(host?.shadowRoot === n); + const hostEl: Element | null = + // anchor and textarea elements also have a `host` property + // but only shadow roots have a `mode` property + (n && 'host' in n && 'mode' in n && dom.host(n as ShadowRoot)) || null; + return Boolean( + hostEl && 'shadowRoot' in hostEl && dom.shadowRoot(hostEl) === n, + ); } /** diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 1a55cc7556..9cc720a2e6 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -630,6 +630,218 @@ exports[`integration tests > [html file]: with-style-sheet-with-import.html 1`] " `; +exports[`integration tests > should be able to record elements even when .childNodes has been monkey patched 1`] = ` +"{ + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Document\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 14 + } + ], + \\"id\\": 13 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 16 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"ul\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 20 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"a\\", + \\"id\\": 22 + } + ], + \\"id\\": 21 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 23 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"b\\", + \\"id\\": 25 + } + ], + \\"id\\": 24 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"c\\", + \\"id\\": 28 + } + ], + \\"id\\": 27 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"d\\", + \\"id\\": 31 + } + ], + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 32 + } + ], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"id\\": 33 + } + ], + \\"id\\": 17 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 +}" +`; + exports[`shadow DOM integration tests > snapshot shadow DOM 1`] = ` "{ \\"type\\": 0, diff --git a/packages/rrweb-snapshot/test/html/monkey-patched-elements.html b/packages/rrweb-snapshot/test/html/monkey-patched-elements.html new file mode 100644 index 0000000000..a48b8fd328 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/monkey-patched-elements.html @@ -0,0 +1,45 @@ + + + + + + Document + + + + + + diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index 0f122e5e55..9fa04baf65 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -120,6 +120,9 @@ describe('integration tests', function (this: ISuite) { if (html.filePath.substring(html.filePath.length - 1) === '~') { continue; } + // monkey patching breaks rebuild code + if (html.filePath.includes('monkey-patched-elements.html')) continue; + const title = '[html file]: ' + html.filePath; it(title, async () => { const page: puppeteer.Page = await browser.newPage(); @@ -255,7 +258,6 @@ iframe.contentDocument.querySelector('center').clientHeight it('correctly saves cross-origin images offline', async () => { const page: puppeteer.Page = await browser.newPage(); - await page.goto('about:blank', { waitUntil: 'load', }); @@ -368,7 +370,7 @@ iframe.contentDocument.querySelector('center').clientHeight it('should save background-clip: text; as the more compatible -webkit-background-clip: test;', async () => { const page: puppeteer.Page = await browser.newPage(); - await page.goto(`http://localhost:3030/html/background-clip-text.html`, { + await page.goto(`${serverURL}/html/background-clip-text.html`, { waitUntil: 'load', }); await waitForRAF(page); // wait for page to render @@ -386,13 +388,10 @@ iframe.contentDocument.querySelector('center').clientHeight it('images with inline onload should work', async () => { const page: puppeteer.Page = await browser.newPage(); - await page.goto( - 'http://localhost:3030/html/picture-with-inline-onload.html', - { - waitUntil: 'load', - }, - ); - await page.waitForSelector('img', { timeout: 1000 }); + await page.goto(`${serverURL}/html/picture-with-inline-onload.html`, { + waitUntil: 'load', + }); + await page.waitForSelector('img', { timeout: 2000 }); await page.evaluate(`${code}`); await page.evaluate(` var snapshot = rrwebSnapshot.snapshot(document, { @@ -406,6 +405,22 @@ iframe.contentDocument.querySelector('center').clientHeight )) as string; assert(fnName === 'onload'); }); + + it('should be able to record elements even when .childNodes has been monkey patched', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto(`${serverURL}/html/monkey-patched-elements.html`, { + waitUntil: 'load', + }); + await waitForRAF(page); // wait for page to render + const snapshotResult = JSON.stringify( + await page.evaluate(`${code}; + rrwebSnapshot.snapshot(document); + `), + null, + 2, + ); + expect(snapshotResult).toMatchSnapshot(); + }); }); describe('iframe integration tests', function (this: ISuite) { diff --git a/packages/rrweb-snapshot/tsconfig.json b/packages/rrweb-snapshot/tsconfig.json index 82d5cc086b..cd2eb36538 100644 --- a/packages/rrweb-snapshot/tsconfig.json +++ b/packages/rrweb-snapshot/tsconfig.json @@ -1,7 +1,13 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"], - "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "include": [ + "src" + ], + "exclude": [ + "vite.config.ts", + "vitest.config.ts", + "test" + ], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index 768793f5f1..359aead963 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -82,6 +82,7 @@ "@amplitude/rrdom": "^2.0.0-alpha.24", "@amplitude/rrweb-types": "^2.0.0-alpha.24", "@amplitude/rrweb-snapshot": "^2.0.0-alpha.24", + "@amplitude/rrweb-utils": "^2.0.0-alpha.24", "@types/css-font-loading-module": "0.0.7", "@xstate/fsm": "^1.4.0", "base64-arraybuffer": "^1.0.1", diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 057304946d..d0f4919004 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -39,6 +39,7 @@ import { registerErrorHandler, unregisterErrorHandler, } from './error-handler'; +import dom from '@amplitude/rrweb-utils'; let wrappedEmit!: (e: eventWithoutTime, isCheckout?: boolean) => void; @@ -396,7 +397,8 @@ function record( stylesheetManager.trackLinkElement(n as HTMLLinkElement); } if (hasShadowRoot(n)) { - shadowDomManager.addShadowRoot(n.shadowRoot, document); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + shadowDomManager.addShadowRoot(dom.shadowRoot(n as Node)!, document); } }, onIframeLoad: (iframe, childSn) => { diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 9aaf259712..74d6e8f874 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -32,6 +32,7 @@ import { isSerializedIframe, isSerializedStylesheet, } from '../utils'; +import dom from '@amplitude/rrweb-utils'; type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; @@ -285,16 +286,13 @@ export default class MutationBuffer { return nextId; }; const pushAdd = (n: Node) => { - if ( - !n.parentNode || - !inDom(n) || - (n.parentNode as Element).tagName === 'TEXTAREA' - ) { + const parent = dom.parentNode(n); + if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') { return; } - const parentId = isShadowRoot(n.parentNode) + const parentId = isShadowRoot(parent) ? this.mirror.getId(getShadowHost(n)) - : this.mirror.getId(n.parentNode); + : this.mirror.getId(parent); const nextId = getNextId(n); if (parentId === -1 || nextId === -1) { return addList.addNode(n); @@ -326,7 +324,8 @@ export default class MutationBuffer { ); } if (hasShadowRoot(n)) { - this.shadowDomManager.addShadowRoot(n.shadowRoot, this.doc); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.shadowDomManager.addShadowRoot(dom.shadowRoot(n)!, this.doc); } }, onIframeLoad: (iframe, childSn) => { @@ -354,7 +353,7 @@ export default class MutationBuffer { for (const n of this.movedSet) { if ( isParentRemoved(this.removes, n, this.mirror) && - !this.movedSet.has(n.parentNode!) + !this.movedSet.has(dom.parentNode(n)!) ) { continue; } @@ -378,7 +377,7 @@ export default class MutationBuffer { while (addList.length) { let node: DoubleLinkedListNode | null = null; if (candidate) { - const parentId = this.mirror.getId(candidate.value.parentNode); + const parentId = this.mirror.getId(dom.parentNode(candidate.value)); const nextId = getNextId(candidate.value); if (parentId !== -1 && nextId !== -1) { node = candidate; @@ -391,7 +390,7 @@ export default class MutationBuffer { tailNode = tailNode.previous; // ensure _node is defined before attempting to find value if (_node) { - const parentId = this.mirror.getId(_node.value.parentNode); + const parentId = this.mirror.getId(dom.parentNode(_node.value)); const nextId = getNextId(_node.value); if (nextId === -1) continue; @@ -403,14 +402,10 @@ export default class MutationBuffer { // nextId !== -1 && parentId === -1 This branch can happen if the node is the child of shadow root else { const unhandledNode = _node.value; + const parent = dom.parentNode(unhandledNode); // If the node is the direct child of a shadow root, we treat the shadow host as its parent node. - if ( - unhandledNode.parentNode && - unhandledNode.parentNode.nodeType === - Node.DOCUMENT_FRAGMENT_NODE - ) { - const shadowHost = (unhandledNode.parentNode as ShadowRoot) - .host; + if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + const shadowHost = dom.host(parent as ShadowRoot); const parentId = this.mirror.getId(shadowHost); if (parentId !== -1) { node = _node; @@ -441,12 +436,10 @@ export default class MutationBuffer { texts: this.texts .map((text) => { const n = text.node; - if ( - n.parentNode && - (n.parentNode as Element).tagName === 'TEXTAREA' - ) { + const parent = dom.parentNode(n); + if (parent && (parent as Element).tagName === 'TEXTAREA') { // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea - this.genTextAreaValueMutation(n.parentNode as HTMLTextAreaElement); + this.genTextAreaValueMutation(parent as HTMLTextAreaElement); } return { id: this.mirror.getId(n), @@ -524,8 +517,8 @@ export default class MutationBuffer { this.attributeMap.set(textarea, item); } item.attributes.value = Array.from( - textarea.childNodes, - (cn) => cn.textContent || '', + dom.childNodes(textarea), + (cn) => dom.textContent(cn) || '', ).join(''); }; @@ -535,7 +528,7 @@ export default class MutationBuffer { } switch (m.type) { case 'characterData': { - const value = m.target.textContent; + const value = dom.textContent(m.target); if ( !isBlocked(m.target, this.blockClass, this.blockSelector, false) && @@ -690,7 +683,7 @@ export default class MutationBuffer { m.removedNodes.forEach((n) => { const nodeId = this.mirror.getId(n); const parentId = isShadowRoot(m.target) - ? this.mirror.getId(m.target.host) + ? this.mirror.getId(dom.host(m.target)) : this.mirror.getId(m.target); if ( isBlocked(m.target, this.blockClass, this.blockSelector, false) || @@ -772,9 +765,10 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too if (!isBlocked(n, this.blockClass, this.blockSelector, false)) { - n.childNodes.forEach((childN) => this.genAdds(childN)); + dom.childNodes(n).forEach((childN) => this.genAdds(childN)); if (hasShadowRoot(n)) { - n.shadowRoot.childNodes.forEach((childN) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + dom.childNodes(dom.shadowRoot(n)!).forEach((childN) => { this.processedNodeManager.add(childN, this); this.genAdds(childN, n); }); @@ -791,7 +785,7 @@ export default class MutationBuffer { */ function deepDelete(addsSet: Set, n: Node) { addsSet.delete(n); - n.childNodes.forEach((childN) => deepDelete(addsSet, childN)); + dom.childNodes(n).forEach((childN) => deepDelete(addsSet, childN)); } function isParentRemoved( @@ -808,13 +802,13 @@ function _isParentRemoved( n: Node, mirror: Mirror, ): boolean { - let node: ParentNode | null = n.parentNode; + let node: ParentNode | null = dom.parentNode(n); while (node) { const parentId = mirror.getId(node); if (removes.some((r) => r.id === parentId)) { return true; } - node = node.parentNode; + node = dom.parentNode(node); } return false; } @@ -825,12 +819,12 @@ function isAncestorInSet(set: Set, n: Node): boolean { } function _isAncestorInSet(set: Set, n: Node): boolean { - const { parentNode } = n; - if (!parentNode) { + const parent = dom.parentNode(n); + if (!parent) { return false; } - if (set.has(parentNode)) { + if (set.has(parent)) { return true; } - return _isAncestorInSet(set, parentNode); + return _isAncestorInSet(set, parent); } diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index ab1f958589..518d9b6002 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -52,15 +52,7 @@ import type { } from '@amplitude/rrweb-types'; import MutationBuffer from './mutation'; import { callbackWrapper, externalFunctionWrapper } from './error-handler'; - -type WindowWithStoredMutationObserver = IWindow & { - __rrMutationObserver?: MutationObserver; -}; -type WindowWithAngularZone = IWindow & { - Zone?: { - __symbol__?: (key: string) => string; - }; -}; +import dom, { mutationObserverCtor } from '@amplitude/rrweb-utils'; export const mutationBuffers: MutationBuffer[] = []; @@ -94,31 +86,7 @@ export function initMutationObserver( mutationBuffers.push(mutationBuffer); // see mutation.ts for details mutationBuffer.init(options); - let mutationObserverCtor = - window.MutationObserver || - /** - * Some websites may disable MutationObserver by removing it from the window object. - * If someone is using rrweb to build a browser extention or things like it, they - * could not change the website's code but can have an opportunity to inject some - * code before the website executing its JS logic. - * Then they can do this to store the native MutationObserver: - * window.__rrMutationObserver = MutationObserver - */ - (window as WindowWithStoredMutationObserver).__rrMutationObserver; - const angularZoneSymbol = ( - window as WindowWithAngularZone - )?.Zone?.__symbol__?.('MutationObserver'); - if ( - angularZoneSymbol && - (window as unknown as Record)[ - angularZoneSymbol - ] - ) { - mutationObserverCtor = ( - window as unknown as Record - )[angularZoneSymbol]; - } - const observer = new (mutationObserverCtor as new ( + const observer = new (mutationObserverCtor() as new ( callback: MutationCallback, ) => MutationObserver)( callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)), @@ -433,7 +401,7 @@ function initInputObserver({ * We can treat this change as a value change of the select element the current target belongs to. */ if (target && tagName === 'OPTION') { - target = target.parentElement; + target = dom.parentElement(target); } if ( !target || @@ -906,7 +874,7 @@ export function initAdoptedStyleSheetObserver( // host of adoptedStyleSheets is outermost document or IFrame's document if (host.nodeName === '#document') hostId = mirror.getId(host); // The host is a ShadowRoot. - else hostId = mirror.getId((host as ShadowRoot).host); + else hostId = mirror.getId(dom.host(host as ShadowRoot)); const patchTarget = host.nodeName === '#document' diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index bc4b987747..52eed1d75b 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -12,6 +12,7 @@ import { initMutationObserver, initScrollObserver, } from './observer'; +import dom from '@amplitude/rrweb-utils'; type BypassOptions = Omit< MutationBufferParam, @@ -81,7 +82,7 @@ export class ShadowDomManager { ) this.bypassOptions.stylesheetManager.adoptStyleSheets( shadowRoot.adoptedStyleSheets, - this.mirror.getId(shadowRoot.host), + this.mirror.getId(dom.host(shadowRoot)), ); this.restoreHandlers.push( initAdoptedStyleSheetObserver( @@ -128,13 +129,14 @@ export class ShadowDomManager { 'attachShadow', function (original: (init: ShadowRootInit) => ShadowRoot) { return function (this: Element, option: ShadowRootInit) { - const shadowRoot = original.call(this, option); + const sRoot = original.call(this, option); // For the shadow dom elements in the document, monitor their dom mutations. // For shadow dom elements that aren't in the document yet, // we start monitoring them once their shadow dom host is appended to the document. - if (this.shadowRoot && inDom(this)) - manager.addShadowRoot(this.shadowRoot, doc); - return shadowRoot; + const shadowRootEl = dom.shadowRoot(this); + if (shadowRootEl && inDom(this)) + manager.addShadowRoot(shadowRootEl, doc); + return sRoot; }; }, ), diff --git a/packages/rrweb/src/replay/dialog/index.ts b/packages/rrweb/src/replay/dialog/index.ts index 9fc57d4523..fb1bca653a 100644 --- a/packages/rrweb/src/replay/dialog/index.ts +++ b/packages/rrweb/src/replay/dialog/index.ts @@ -1,5 +1,5 @@ -import type { attributeMutation } from '@rrweb/types'; -import { RRNode } from 'rrdom'; +import type { attributeMutation } from '@amplitude/rrweb-types'; +import { RRNode } from '@amplitude/rrdom'; /** * Checks if the dialog is a top level dialog and applies the dialog to the top level diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index de771d965b..e1506ba934 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -1,4 +1,4 @@ -import type { RRIFrameElement, RRNode } from '@amplitude/rrdom'; +import { RRNode, RRIFrameElement, BaseRRNode } from '@amplitude/rrdom'; import type { IMirror, Mirror, SlimDOMOptions } from '@amplitude/rrweb-snapshot'; import { IGNORED_NODE, @@ -16,6 +16,7 @@ import type { textMutation, throttleOptions, } from '@amplitude/rrweb-types'; +import dom from '@amplitude/rrweb-utils'; export function on( type: string, @@ -188,8 +189,8 @@ export function getWindowScroll(win: Window) { ? doc.scrollingElement.scrollLeft : win.pageXOffset !== undefined ? win.pageXOffset - : doc?.documentElement.scrollLeft || - doc?.body?.parentElement?.scrollLeft || + : doc.documentElement.scrollLeft || + (doc?.body && dom.parentElement(doc.body)?.scrollLeft) || doc?.body?.scrollLeft || 0, top: doc.scrollingElement @@ -197,7 +198,7 @@ export function getWindowScroll(win: Window) { : win.pageYOffset !== undefined ? win.pageYOffset : doc?.documentElement.scrollTop || - doc?.body?.parentElement?.scrollTop || + (doc?.body && dom.parentElement(doc.body)?.scrollTop) || doc?.body?.scrollTop || 0, }; @@ -232,7 +233,7 @@ export function closestElementOfNode(node: Node | null): HTMLElement | null { const el: HTMLElement | null = node.nodeType === node.ELEMENT_NODE ? (node as HTMLElement) - : node.parentElement; + : dom.parentElement(node); return el; } @@ -304,17 +305,15 @@ export function isAncestorRemoved(target: Node, mirror: Mirror): boolean { if (!mirror.has(id)) { return true; } - if ( - target.parentNode && - target.parentNode.nodeType === target.DOCUMENT_NODE - ) { + const parent = dom.parentNode(target); + if (parent && parent.nodeType === target.DOCUMENT_NODE) { return false; } // if the root is not document, it means the node is not in the DOM tree anymore - if (!target.parentNode) { + if (!parent) { return true; } - return isAncestorRemoved(target.parentNode, mirror); + return isAncestorRemoved(parent, mirror); } export function legacy_isTouchEvent( @@ -335,24 +334,6 @@ export function polyfill(win = window) { win.DOMTokenList.prototype.forEach = Array.prototype .forEach as unknown as DOMTokenList['forEach']; } - - // https://github.com/Financial-Times/polyfill-service/pull/183 - if (!Node.prototype.contains) { - Node.prototype.contains = (...args: unknown[]) => { - let node = args[0] as Node | null; - if (!(0 in args)) { - throw new TypeError('1 argument is required'); - } - - do { - if (this === node) { - return true; - } - } while ((node = node && node.parentNode)); - - return false; - }; - } } type ResolveTree = { @@ -478,7 +459,11 @@ export function getBaseDimension( export function hasShadowRoot( n: T, ): n is T & { shadowRoot: ShadowRoot } { - return Boolean((n as unknown as Element)?.shadowRoot); + if (!n) return false; + if (n instanceof BaseRRNode && 'shadowRoot' in n) { + return Boolean(n.shadowRoot); + } + return Boolean(dom.shadowRoot(n as unknown as Element)); } export function getNestedRule( @@ -570,10 +555,11 @@ export class StyleSheetMirror { export function getShadowHost(n: Node): Element | null { let shadowHost: Element | null = null; if ( - n.getRootNode?.()?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && - (n.getRootNode() as ShadowRoot).host + 'getRootNode' in n && + dom.getRootNode(n)?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && + dom.host(dom.getRootNode(n) as ShadowRoot) ) - shadowHost = (n.getRootNode() as ShadowRoot).host; + shadowHost = dom.host(dom.getRootNode(n) as ShadowRoot); return shadowHost; } @@ -595,11 +581,11 @@ export function shadowHostInDom(n: Node): boolean { const doc = n.ownerDocument; if (!doc) return false; const shadowHost = getRootShadowHost(n); - return doc.contains(shadowHost); + return dom.contains(doc, shadowHost); } export function inDom(n: Node): boolean { const doc = n.ownerDocument; if (!doc) return false; - return doc.contains(n) || shadowHostInDom(n); + return dom.contains(doc, n) || shadowHostInDom(n); } diff --git a/packages/rrweb/test/events/bad-style.ts b/packages/rrweb/test/events/bad-style.ts index 67d2a35fb1..fa3b76e159 100644 --- a/packages/rrweb/test/events/bad-style.ts +++ b/packages/rrweb/test/events/bad-style.ts @@ -1,5 +1,5 @@ -import { EventType, IncrementalSource } from '@rrweb/types'; -import type { eventWithTime } from '@rrweb/types'; +import { EventType, IncrementalSource } from '@amplitude/rrweb-types'; +import type { eventWithTime } from '@amplitude/rrweb-types'; /** * https://github.com/rrweb-io/rrweb/pull/1417 diff --git a/packages/rrweb/test/events/dialog-playback.ts b/packages/rrweb/test/events/dialog-playback.ts index add86aca9d..fea4f5620b 100644 --- a/packages/rrweb/test/events/dialog-playback.ts +++ b/packages/rrweb/test/events/dialog-playback.ts @@ -1,4 +1,4 @@ -import { eventWithTime, IncrementalSource } from '@rrweb/types'; +import { eventWithTime, IncrementalSource } from '@amplitude/rrweb-types'; const startTime = 1900000000; export const closedFullSnapshotTime = 132; diff --git a/packages/rrweb/test/record/cross-origin-iframes.test.ts b/packages/rrweb/test/record/cross-origin-iframes.test.ts index 4a455c308c..a8f7d28f23 100644 --- a/packages/rrweb/test/record/cross-origin-iframes.test.ts +++ b/packages/rrweb/test/record/cross-origin-iframes.test.ts @@ -53,7 +53,11 @@ async function injectRecordScript( } catch (e) { // we get this error: `Protocol error (DOM.resolveNode): Node with given id does not belong to the document` // then the page wasn't loaded yet and we try again - if (!e.message.includes('DOM.resolveNode')) throw e; + if ( + !e.message.includes('DOM.resolveNode') || + !e.message.includes('DOM.describeNode') + ) + throw e; await injectRecordScript(frame, options); return; } diff --git a/packages/rrweb/test/record/dialog.test.ts b/packages/rrweb/test/record/dialog.test.ts index ab6542b547..dd79c4dbb3 100644 --- a/packages/rrweb/test/record/dialog.test.ts +++ b/packages/rrweb/test/record/dialog.test.ts @@ -15,7 +15,7 @@ import { EventType, eventWithTime, listenerHandler, -} from '@rrweb/types'; +} from '@amplitude/rrweb-types'; import { recordOptions } from '../../src/types'; interface IWindow extends Window { diff --git a/packages/rrweb/tsconfig.json b/packages/rrweb/tsconfig.json index d5c978ce08..0381b7b424 100644 --- a/packages/rrweb/tsconfig.json +++ b/packages/rrweb/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"], + "include": [ + "src" + ], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo", @@ -9,7 +11,6 @@ "vite/client", "@types/dom-mediacapture-transform", "@types/offscreencanvas", - // rrweb specific: /* * @see https://vitest.dev/config/#globals @@ -18,7 +19,6 @@ */ "vitest/globals" ], - // TODO: enable me in the future, this is quite a large project // at time of writing (April 2024) there are over 100 errors in rrweb "strict": false @@ -32,6 +32,9 @@ }, { "path": "../rrweb-snapshot" + }, + { + "path": "../utils" } ] } diff --git a/packages/utils/Readme.md b/packages/utils/Readme.md new file mode 100644 index 0000000000..2107a2f228 --- /dev/null +++ b/packages/utils/Readme.md @@ -0,0 +1,178 @@ +# @rrweb/utils + +This package contains the shared utility functions used across rrweb packages. +See the [guide](../../guide.md) for more info on rrweb. + +## Sponsors + +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Backers + + + +## Core Team Members + + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
+ +## Who's using rrweb? + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + Smart screen recording for SaaS + +
+ + The first ever UX automation tool + + + + Remote Access & Co-Browsing + + + + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + +
+ + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + + + + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000000..2ec0d5fca8 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,53 @@ +{ + "name": "@amplitude/rrweb-utils", + "version": "2.0.0-alpha.24", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "rrweb", + "@rrweb/utils" + ], + "scripts": { + "dev": "vite build --watch", + "build": "tsc -noEmit && vite build", + "check-types": "tsc -noEmit", + "prepublish": "npm run build", + "lint": "yarn eslint src/**/*.ts" + }, + "homepage": "https://github.com/rrweb-io/rrweb/tree/main/packages/@rrweb/utils#readme", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "license": "MIT", + "type": "module", + "main": "./dist/rrweb-utils.umd.cjs", + "module": "./dist/rrweb-utils.js", + "unpkg": "./dist/rrweb-utils.umd.cjs", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-utils.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-utils.umd.cjs" + } + } + }, + "files": [ + "dist", + "package.json" + ], + "devDependencies": { + "vite": "^5.2.8", + "vite-plugin-dts": "^3.8.1" + }, + "dependencies": {} +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000000..b88d7f452e --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,221 @@ +type PrototypeOwner = Node | ShadowRoot | MutationObserver | Element; +type TypeofPrototypeOwner = + | typeof Node + | typeof ShadowRoot + | typeof MutationObserver + | typeof Element; + +type BasePrototypeCache = { + Node: typeof Node.prototype; + ShadowRoot: typeof ShadowRoot.prototype; + MutationObserver: typeof MutationObserver.prototype; + Element: typeof Element.prototype; +}; + +const testableAccessors = { + Node: ['childNodes', 'parentNode', 'parentElement', 'textContent'] as const, + ShadowRoot: ['host', 'styleSheets'] as const, + Element: ['shadowRoot', 'querySelector', 'querySelectorAll'] as const, + MutationObserver: [] as const, +} as const; + +const testableMethods = { + Node: ['contains', 'getRootNode'] as const, + ShadowRoot: ['getSelection'], + Element: [], + MutationObserver: ['constructor'], +} as const; + +const untaintedBasePrototype: Partial = {}; + +export function getUntaintedPrototype( + key: T, +): BasePrototypeCache[T] { + if (untaintedBasePrototype[key]) + return untaintedBasePrototype[key] as BasePrototypeCache[T]; + + const defaultObj = globalThis[key] as TypeofPrototypeOwner; + const defaultPrototype = defaultObj.prototype as BasePrototypeCache[T]; + + // use list of testable accessors to check if the prototype is tainted + const accessorNames = + key in testableAccessors ? testableAccessors[key] : undefined; + const isUntaintedAccessors = Boolean( + accessorNames && + // @ts-expect-error 2345 + accessorNames.every((accessor: keyof typeof defaultPrototype) => + Boolean( + Object.getOwnPropertyDescriptor(defaultPrototype, accessor) + ?.get?.toString() + .includes('[native code]'), + ), + ), + ); + + const methodNames = key in testableMethods ? testableMethods[key] : undefined; + const isUntaintedMethods = Boolean( + methodNames && + methodNames.every( + // @ts-expect-error 2345 + (method: keyof typeof defaultPrototype) => + typeof defaultPrototype[method] === 'function' && + defaultPrototype[method]?.toString().includes('[native code]'), + ), + ); + + if (isUntaintedAccessors && isUntaintedMethods) { + untaintedBasePrototype[key] = defaultObj.prototype as BasePrototypeCache[T]; + return defaultObj.prototype as BasePrototypeCache[T]; + } + + try { + const iframeEl = document.createElement('iframe'); + document.body.appendChild(iframeEl); + const win = iframeEl.contentWindow; + if (!win) return defaultObj.prototype as BasePrototypeCache[T]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + const untaintedObject = (win as any)[key] + .prototype as BasePrototypeCache[T]; + // cleanup + document.body.removeChild(iframeEl); + + if (!untaintedObject) return defaultPrototype; + + return (untaintedBasePrototype[key] = untaintedObject); + } catch { + return defaultPrototype; + } +} + +const untaintedAccessorCache: Record< + string, + (this: PrototypeOwner, ...args: unknown[]) => unknown +> = {}; + +export function getUntaintedAccessor< + K extends keyof BasePrototypeCache, + T extends keyof BasePrototypeCache[K], +>( + key: K, + instance: BasePrototypeCache[K], + accessor: T, +): BasePrototypeCache[K][T] { + const cacheKey = `${key}.${String(accessor)}`; + if (untaintedAccessorCache[cacheKey]) + return untaintedAccessorCache[cacheKey].call( + instance, + ) as BasePrototypeCache[K][T]; + + const untaintedPrototype = getUntaintedPrototype(key); + // eslint-disable-next-line @typescript-eslint/unbound-method + const untaintedAccessor = Object.getOwnPropertyDescriptor( + untaintedPrototype, + accessor, + )?.get; + + if (!untaintedAccessor) return instance[accessor]; + + untaintedAccessorCache[cacheKey] = untaintedAccessor; + + return untaintedAccessor.call(instance) as BasePrototypeCache[K][T]; +} + +type BaseMethod = ( + this: BasePrototypeCache[K], + ...args: unknown[] +) => unknown; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const untaintedMethodCache: Record> = {}; +export function getUntaintedMethod< + K extends keyof BasePrototypeCache, + T extends keyof BasePrototypeCache[K], +>( + key: K, + instance: BasePrototypeCache[K], + method: T, +): BasePrototypeCache[K][T] { + const cacheKey = `${key}.${String(method)}`; + if (untaintedMethodCache[cacheKey]) + return untaintedMethodCache[cacheKey].bind( + instance, + ) as BasePrototypeCache[K][T]; + + const untaintedPrototype = getUntaintedPrototype(key); + const untaintedMethod = untaintedPrototype[method]; + + if (typeof untaintedMethod !== 'function') return instance[method]; + + untaintedMethodCache[cacheKey] = untaintedMethod as BaseMethod; + + return untaintedMethod.bind(instance) as BasePrototypeCache[K][T]; +} + +export function childNodes(n: Node): NodeListOf { + return getUntaintedAccessor('Node', n, 'childNodes'); +} + +export function parentNode(n: Node): ParentNode | null { + return getUntaintedAccessor('Node', n, 'parentNode'); +} + +export function parentElement(n: Node): HTMLElement | null { + return getUntaintedAccessor('Node', n, 'parentElement'); +} + +export function textContent(n: Node): string | null { + return getUntaintedAccessor('Node', n, 'textContent'); +} + +export function contains(n: Node, other: Node): boolean { + return getUntaintedMethod('Node', n, 'contains')(other); +} + +export function getRootNode(n: Node): Node { + return getUntaintedMethod('Node', n, 'getRootNode')(); +} + +export function host(n: ShadowRoot): Element | null { + if (!n || !('host' in n)) return null; + return getUntaintedAccessor('ShadowRoot', n, 'host'); +} + +export function styleSheets(n: ShadowRoot): StyleSheetList { + return n.styleSheets; +} + +export function shadowRoot(n: Node): ShadowRoot | null { + if (!n || !('shadowRoot' in n)) return null; + return getUntaintedAccessor('Element', n as Element, 'shadowRoot'); +} + +export function querySelector(n: Element, selectors: string): Element | null { + return getUntaintedAccessor('Element', n, 'querySelector')(selectors); +} + +export function querySelectorAll( + n: Element, + selectors: string, +): NodeListOf { + return getUntaintedAccessor('Element', n, 'querySelectorAll')(selectors); +} + +export function mutationObserverCtor(): (typeof MutationObserver)['prototype']['constructor'] { + return getUntaintedPrototype('MutationObserver').constructor; +} + +export default { + childNodes, + parentNode, + parentElement, + textContent, + contains, + getRootNode, + host, + styleSheets, + shadowRoot, + querySelector, + querySelectorAll, + mutationObserver: mutationObserverCtor, +}; diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000000..1902007d56 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [] +} diff --git a/packages/utils/vite.config.js b/packages/utils/vite.config.js new file mode 100644 index 0000000000..854f2b9ef0 --- /dev/null +++ b/packages/utils/vite.config.js @@ -0,0 +1,4 @@ +import path from 'path'; +import config from '../../vite.config.default'; + +export default config(path.resolve(__dirname, 'src/index.ts'), 'rrwebUtils'); diff --git a/turbo.json b/turbo.json index d9f6b25a11..54bd1b278f 100644 --- a/turbo.json +++ b/turbo.json @@ -37,7 +37,7 @@ }, "lint": {}, "check-types": { - "dependsOn": ["//#references:update"] + "dependsOn": ["^prepublish"] }, "//#references:update": { "inputs": ["packages/*/package.json", "packages/plugins/*/package.json"], diff --git a/yarn.lock b/yarn.lock index cc14e9d9c4..7f3f10a427 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2071,11 +2071,16 @@ globby "^11.0.0" read-yaml-file "^1.1.0" -"@mdn/browser-compat-data@^5.2.34", "@mdn/browser-compat-data@^5.3.13": +"@mdn/browser-compat-data@^5.2.34": version "5.5.32" resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.5.32.tgz#8bece89fdc5478550c8a5a3c00b3bd4d4d720358" integrity sha512-viN2VaUd1Hj2VpTDtKVT6LYfBnxzUgXJ+LSQfzuzaHa5mZBlvR3wSxMyUqbfywBbnIWHyKNwz6Yrcdpa4zEOZw== +"@mdn/browser-compat-data@^5.5.19": + version "5.5.33" + resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.5.33.tgz#c1177469bc4d39fa24c2cd3df317039e2b465b4c" + integrity sha512-uO4uIBFn9D4UNyUmaueIWnE/IJhBlSJ7W1rANvDdaawhTX8CSgqUX8tj9/6a+1WjpL9Bgirf67d//S2VwDsfig== + "@microsoft/api-extractor-model@7.28.13": version "7.28.13" resolved "https://registry.yarnpkg.com/@microsoft/api-extractor-model/-/api-extractor-model-7.28.13.tgz#96fbc52155e0d07e0eabbd9699065b77702fe33a" @@ -2370,6 +2375,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4" integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g== +"@rrweb/utils@^2.0.0-alpha.16": + version "2.0.0-alpha.17" + resolved "https://registry.yarnpkg.com/@rrweb/utils/-/utils-2.0.0-alpha.17.tgz#d13a7326af0311e0f54551e223ace987608eaed5" + integrity sha512-HCsasPERBwOS9/LQeOytO2ETKTCqRj1wORBuxiy3t41hKhmi225DdrUPiWnyDdTQm1GdVbOymMRknJVPnZaSXw== + "@rushstack/node-core-library@4.0.2": version "4.0.2" resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz#e26854a3314b279d57e8abdb4acce7797d02f554" @@ -3665,7 +3675,7 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== -browserslist@^4.21.10, browserslist@^4.22.1, browserslist@^4.22.2: +browserslist@^4.22.1, browserslist@^4.22.2, browserslist@^4.23.0: version "4.23.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.1.tgz#ce4af0534b3d37db5c1a4ca98b9080f985041e96" integrity sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw== @@ -3785,10 +3795,10 @@ camelcase@^7.0.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048" integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw== -caniuse-lite@^1.0.30001524, caniuse-lite@^1.0.30001629: - version "1.0.30001632" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001632.tgz#964207b7cba5851701afb4c8afaf1448db3884b6" - integrity sha512-udx3o7yHJfUxMLkGohMlVHCvFvWmirKh9JAH/d7WOLPetlH+LTL5cocMZ0t7oZx/mdlOWXti97xLZWc8uURRHg== +caniuse-lite@^1.0.30001605, caniuse-lite@^1.0.30001629: + version "1.0.30001633" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001633.tgz" + integrity sha512-6sT0yf/z5jqf8tISAgpJDrmwOpLsrpnyCdD/lOZKvKkkJK4Dn0X5i7KF7THEZhOq+30bmhwBlNEaqPUiHiKtZg== chai@^4.3.10: version "4.4.1" @@ -4678,9 +4688,9 @@ ee-first@1.1.1: integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== electron-to-chromium@^1.4.796: - version "1.4.796" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.796.tgz#48dd6ff634b7f7df6313bd27aaa713f3af4a2b29" - integrity sha512-NglN/xprcM+SHD2XCli4oC6bWe6kHoytcyLKCWXmRL854F0qhPhaYgUswUsglnPxYaNQIg2uMY4BvaomIf3kLA== + version "1.4.802" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.802.tgz#49b397eadc95a49b1ac33eebee146b8e5a93773f" + integrity sha512-TnTMUATbgNdPXVSHsxvNVSG0uEd6cSZsANjm8c9HbvflZVVn1yTRcmVXYT1Ma95/ssB/Dcd30AHweH2TE+dNpA== emittery@^0.8.1: version "0.8.1" @@ -4951,18 +4961,19 @@ eslint-compat-utils@^0.5.0: dependencies: semver "^7.5.4" -eslint-plugin-compat@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-compat/-/eslint-plugin-compat-4.2.0.tgz#eeaf80daa1afe495c88a47e9281295acae45c0aa" - integrity sha512-RDKSYD0maWy5r7zb5cWQS+uSPc26mgOzdORJ8hxILmWM7S/Ncwky7BcAtXVY5iRbKjBdHsWU8Yg7hfoZjtkv7w== +eslint-plugin-compat@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-compat/-/eslint-plugin-compat-5.0.0.tgz#d09dff02397d81c9f5b1ac740ef45b39538aa21d" + integrity sha512-29KNWyFkUbNVf6TIKVe9SVCGCtHjML3HnUg9C8LG2GsXf7miAeBOgdMc1n2B5n0sHUzg1/A4IFly7Jyf1gSbgQ== dependencies: - "@mdn/browser-compat-data" "^5.3.13" + "@mdn/browser-compat-data" "^5.5.19" ast-metadata-inferer "^0.8.0" - browserslist "^4.21.10" - caniuse-lite "^1.0.30001524" + browserslist "^4.23.0" + caniuse-lite "^1.0.30001605" find-up "^5.0.0" + globals "^13.24.0" lodash.memoize "^4.1.2" - semver "^7.5.4" + semver "^7.6.0" eslint-plugin-jest@^27.6.0: version "27.9.0" @@ -5697,7 +5708,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.19.0: +globals@^13.19.0, globals@^13.24.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== @@ -7821,11 +7832,6 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" -nwsapi@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" - integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== - nwsapi@^2.2.0: version "2.2.10" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.10.tgz#0b77a68e21a0b483db70b11fad055906e867cda8" @@ -10243,7 +10249,7 @@ vite-node@1.6.0: picocolors "^1.0.0" vite "^5.0.0" -vite-plugin-dts@^3.9.1: +vite-plugin-dts@^3.8.1, vite-plugin-dts@^3.9.1: version "3.9.1" resolved "https://registry.yarnpkg.com/vite-plugin-dts/-/vite-plugin-dts-3.9.1.tgz#625ad388ec3956708ccec7960550a7b0a8e8909e" integrity sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg== @@ -10292,7 +10298,7 @@ vite@^5.0.0, "vite@^5.0.0 || ^4.1.4": optionalDependencies: fsevents "~2.3.3" -vite@^5.3.1: +vite@^5.2.8, vite@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.1.tgz#bb2ca6b5fd7483249d3e86b25026e27ba8a663e6" integrity sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==