diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index 1908a0d9d9..0218c36bb7 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -18,6 +18,8 @@ import rebuild, { rebuildIntoSandboxedIframe, } from './rebuild'; export * from './types'; +// Legacy broad export kept for compatibility. New internal imports should +// prefer snapshot-utils.ts / rebuild-utils.ts domain entrypoints. export * from './utils'; export { diff --git a/packages/rrweb-snapshot/src/rebuild-utils.ts b/packages/rrweb-snapshot/src/rebuild-utils.ts new file mode 100644 index 0000000000..a1519b1ae8 --- /dev/null +++ b/packages/rrweb-snapshot/src/rebuild-utils.ts @@ -0,0 +1,37 @@ +import { NodeType } from '@rrweb/types'; +import type { + documentNode, + documentTypeNode, + elementNode, + serializedNode, + textNode, +} from '@rrweb/types'; + +export { isElement, Mirror, extractFileExtension } from './shared-utils'; + +export function isNodeMetaEqual(a: serializedNode, b: serializedNode): boolean { + if (!a || !b || a.type !== b.type) return false; + if (a.type === NodeType.Document) + return a.compatMode === (b as documentNode).compatMode; + else if (a.type === NodeType.DocumentType) + return ( + a.name === (b as documentTypeNode).name && + a.publicId === (b as documentTypeNode).publicId && + a.systemId === (b as documentTypeNode).systemId + ); + else if ( + a.type === NodeType.Comment || + a.type === NodeType.Text || + a.type === NodeType.CDATA + ) + return a.textContent === (b as textNode).textContent; + else if (a.type === NodeType.Element) + return ( + a.tagName === (b as elementNode).tagName && + JSON.stringify(a.attributes) === + JSON.stringify((b as elementNode).attributes) && + a.isSVG === (b as elementNode).isSVG && + a.needBlock === (b as elementNode).needBlock + ); + return false; +} diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 0ee9b0f983..a6ef85f01b 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -12,7 +12,7 @@ import { Mirror, isNodeMetaEqual, extractFileExtension, -} from './utils'; +} from './rebuild-utils'; import postcss from 'postcss'; const tagMap: tagMap = { diff --git a/packages/rrweb-snapshot/src/shared-utils.ts b/packages/rrweb-snapshot/src/shared-utils.ts new file mode 100644 index 0000000000..02db716e17 --- /dev/null +++ b/packages/rrweb-snapshot/src/shared-utils.ts @@ -0,0 +1,97 @@ +import type { idNodeMap, nodeMetaMap } from './types'; +import type { IMirror, serializedNodeWithId } from '@rrweb/types'; + +export function isElement(n: Node): n is Element { + return n.nodeType === n.ELEMENT_NODE; +} + +export class Mirror implements IMirror { + private idNodeMap: idNodeMap = new Map(); + private nodeMetaMap: nodeMetaMap = new WeakMap(); + + getId(n: Node | undefined | null): number { + if (!n) return -1; + + const id = this.getMeta(n)?.id; + + // if n is not a serialized Node, use -1 as its id. + return id ?? -1; + } + + getNode(id: number): Node | null { + return this.idNodeMap.get(id) || null; + } + + getIds(): number[] { + return Array.from(this.idNodeMap.keys()); + } + + getMeta(n: Node): serializedNodeWithId | null { + return this.nodeMetaMap.get(n) || null; + } + + // removes the node from idNodeMap + // doesn't remove the node from nodeMetaMap + removeNodeFromMap(n: Node) { + const id = this.getId(n); + this.idNodeMap.delete(id); + + if (n.childNodes) { + n.childNodes.forEach((childNode) => + this.removeNodeFromMap(childNode as unknown as Node), + ); + } + } + + has(id: number): boolean { + return this.idNodeMap.has(id); + } + + hasNode(node: Node): boolean { + return this.nodeMetaMap.has(node); + } + + add(n: Node, meta: serializedNodeWithId) { + const id = meta.id; + this.idNodeMap.set(id, n); + this.nodeMetaMap.set(n, meta); + } + + replace(id: number, n: Node) { + const oldNode = this.getNode(id); + if (oldNode) { + const meta = this.nodeMetaMap.get(oldNode); + if (meta) this.nodeMetaMap.set(n, meta); + } + this.idNodeMap.set(id, n); + } + + reset() { + this.idNodeMap = new Map(); + this.nodeMetaMap = new WeakMap(); + } +} + +export function createMirror(): Mirror { + return new Mirror(); +} + +/** + * Extracts the file extension from an a path, considering search parameters and fragments. + * @param path - Path to file + * @param baseURL - [optional] Base URL of the page, used to resolve relative paths. Defaults to current page URL. + */ +export function extractFileExtension( + path: string, + baseURL?: string, +): string | null { + let url; + try { + url = new URL(path, baseURL ?? window.location.href); + } catch (err) { + return null; + } + const regex = /\.([0-9a-z]+)(?:$)/i; + const match = url.pathname.match(regex); + return match?.[1] ?? null; +} diff --git a/packages/rrweb-snapshot/src/snapshot-utils.ts b/packages/rrweb-snapshot/src/snapshot-utils.ts new file mode 100644 index 0000000000..f0bfefcd1b --- /dev/null +++ b/packages/rrweb-snapshot/src/snapshot-utils.ts @@ -0,0 +1,461 @@ +import type { MaskInputFn, MaskInputOptions } from './types'; +import dom from '@rrweb/utils'; + +export { Mirror, isElement, extractFileExtension } from './shared-utils'; + +export function isShadowRoot(n: Node): n is ShadowRoot { + 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, + ); +} + +/** + * To fix the issue https://github.com/rrweb-io/rrweb/issues/933. + * Some websites use polyfilled shadow dom and this function is used to detect this situation. + */ +export function isNativeShadowDom(shadowRoot: ShadowRoot): boolean { + return Object.prototype.toString.call(shadowRoot) === '[object ShadowRoot]'; +} + +/** + * Browsers sometimes destructively modify the css rules they receive. + * This function tries to rectify the modifications the browser made to make it more cross platform compatible. + * @param cssText - output of `CSSStyleRule.cssText` + * @returns `cssText` with browser inconsistencies fixed. + */ +function fixBrowserCompatibilityIssuesInCSS(cssText: string): string { + /** + * Chrome outputs `-webkit-background-clip` as `background-clip` in `CSSStyleRule.cssText`. + * But then Chrome ignores `background-clip` as css input. + * Re-introduce `-webkit-background-clip` to fix this issue. + */ + if ( + cssText.includes(' background-clip: text;') && + !cssText.includes(' -webkit-background-clip: text;') + ) { + cssText = cssText.replace( + /\sbackground-clip:\s*text;/g, + ' -webkit-background-clip: text; background-clip: text;', + ); + } + return cssText; +} + +// Remove this declaration once typescript has added `CSSImportRule.supportsText` to the lib. +declare interface CSSImportRule extends CSSRule { + readonly href: string; + readonly layerName: string | null; + readonly media: MediaList; + readonly styleSheet: CSSStyleSheet; + /** + * experimental API, currently only supported in firefox + * https://developer.mozilla.org/en-US/docs/Web/API/CSSImportRule/supportsText + */ + readonly supportsText?: string | null; +} + +/** + * Browsers sometimes incorrectly escape `@import` on `.cssText` statements. + * This function tries to correct the escaping. + * more info: https://bugs.chromium.org/p/chromium/issues/detail?id=1472259 + * @param cssImportRule + * @returns `cssText` with browser inconsistencies fixed, or null if not applicable. + */ +export function escapeImportStatement(rule: CSSImportRule): string { + const { cssText } = rule; + if (cssText.split('"').length < 3) return cssText; + + const statement = ['@import', `url(${JSON.stringify(rule.href)})`]; + if (rule.layerName === '') { + statement.push(`layer`); + } else if (rule.layerName) { + statement.push(`layer(${rule.layerName})`); + } + if (rule.supportsText) { + statement.push(`supports(${rule.supportsText})`); + } + if (rule.media.length) { + statement.push(rule.media.mediaText); + } + return statement.join(' ') + ';'; +} + +/* + * serialize the css rules from the .sheet property + * for elements, this is the only way of getting the rules without a FETCH + * for