diff --git a/.changeset/config.json b/.changeset/config.json index 7906b2130c..89ad04bbc7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,6 +14,7 @@ "@rrweb/record", "@rrweb/types", "@rrweb/packer", + "@rrweb/utils", "@rrweb/web-extension", "rrvideo", "@rrweb/rrweb-plugin-console-record", 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 0b43fe489a..1585b1122f 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/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 80bf64902f..7464feb4fa 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -54,6 +54,7 @@ }, "homepage": "https://github.com/rrweb-io/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..59b49c467d 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 '@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..c2a0016201 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 '@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..67a5bdab7d 100644 --- a/packages/rrweb-snapshot/tsconfig.json +++ b/packages/rrweb-snapshot/tsconfig.json @@ -6,5 +6,9 @@ "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, - "references": [] + "references": [ + { + "path": "../utils" + } + ] } diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index 8c7a81e102..cca5fc234a 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -80,6 +80,7 @@ }, "dependencies": { "@rrweb/types": "^2.0.0-alpha.16", + "@rrweb/utils": "^2.0.0-alpha.16", "@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 36300d0595..1308c378a6 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 '@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 b7b9c88cef..3ab7f19170 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -32,6 +32,7 @@ import { getShadowHost, closestElementOfNode, } from '../utils'; +import dom from '@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 8b2f2c6370..7e3aab3fff 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -52,15 +52,7 @@ import type { } from '@rrweb/types'; import MutationBuffer from './mutation'; import { callbackWrapper } from './error-handler'; - -type WindowWithStoredMutationObserver = IWindow & { - __rrMutationObserver?: MutationObserver; -}; -type WindowWithAngularZone = IWindow & { - Zone?: { - __symbol__?: (key: string) => string; - }; -}; +import dom, { mutationObserverCtor } from '@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 || @@ -902,7 +870,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 169c77216a..ab257e4003 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -12,6 +12,7 @@ import { import { patch, inDom } from '../utils'; import type { Mirror } from 'rrweb-snapshot'; import { isNativeShadowDom } from 'rrweb-snapshot'; +import dom from '@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/utils.ts b/packages/rrweb/src/utils.ts index b7fa12506e..f216819fa3 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -11,7 +11,8 @@ import type { } from '@rrweb/types'; import type { IMirror, Mirror, SlimDOMOptions } from 'rrweb-snapshot'; import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot'; -import type { RRNode, RRIFrameElement } from 'rrdom'; +import { RRNode, RRIFrameElement, BaseRRNode } from 'rrdom'; +import dom from '@rrweb/utils'; export function on( type: string, @@ -184,8 +185,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 @@ -193,7 +194,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, }; @@ -228,7 +229,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; } @@ -300,17 +301,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( @@ -331,24 +330,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 = { @@ -474,7 +455,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( @@ -566,10 +551,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; } @@ -591,11 +577,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/record/cross-origin-iframes.test.ts b/packages/rrweb/test/record/cross-origin-iframes.test.ts index 3a2ff90e89..a0bb6bd18e 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/tsconfig.json b/packages/rrweb/tsconfig.json index 6a8a5ffb3d..d27cd53179 100644 --- a/packages/rrweb/tsconfig.json +++ b/packages/rrweb/tsconfig.json @@ -9,7 +9,6 @@ "vite/client", "@types/dom-mediacapture-transform", "@types/offscreencanvas", - // rrweb specific: /* * @see https://vitest.dev/config/#globals @@ -18,7 +17,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 @@ -27,6 +25,9 @@ { "path": "../types" }, + { + "path": "../utils" + }, { "path": "../rrdom" }, 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..ea4d978237 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,53 @@ +{ + "name": "@rrweb/utils", + "version": "2.0.0-alpha.16", + "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/utils.umd.cjs", + "module": "./dist/utils.js", + "unpkg": "./dist/utils.umd.cjs", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/utils.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/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..c4ad2ac6bb 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" @@ -3665,7 +3670,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 +3790,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 +4683,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 +4956,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 +5703,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== @@ -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==