Files
rrweb/packages/rrdom/src/index.ts
Justin Halsall 5a85ddf013 Chore: Migrate build to vite (#1033)
* Chore: Add move most types from rrweb to @rrweb/types package

* Split off type imports

* Split off type import to its own line

* Get vite to generate type definitions

* Apply formatting changes

* noEmit not allowed in tsconfig, moved it to build step

* Migrate rrdom-nodejs build to vite

* Apply formatting changes

* Migrate rrweb-snapshot to vite

* Unify configs

* Chore: Migrate rrdom to vite

Turns out what we where doing by overwriting `public textContent: string | undefined` as a getter in a subclass is something that isn't allowed in typescript. Because we where using `// @ts-ignore`  to hide this error our bundler chose to allow the overwrite. Vite choses to disallow the overwrite making all subclasses' `textContent` undefined.
To mitigate this we're using an abstract class, which does allow sub classes to decide if they wan't to use getters or not.

* Chore: Migrate rrweb to vite WIP

* build:browser was removed (for now)

* BREAKING: moved rrweb-plugin-console to its own npm module

This removes console from rrweb-all.js

* Support cjs files in startServer

* Move canvas-webrtc plugin to its own package

* Chore: move sequential-id plugin to its own package

* Chore: Configure rrweb's vite bundling

* `Id` had lowercase `d` before, making it lowercase again

* Test: Move console tests to their own package

* remove unused utils from rrdom

* pull in latest version of master
something when wrong earlier when resolving merge conflicts, this should be correct

* Fix type casting issue in diff.ts

* Fix typo

* Fix duplicate entries in package.json and tsconfig.json

* Apply formatting changes

* Update dependencies in package.json files

* Update dependencies to use Vite 5.2.8 in package.json files

* Get tests passing for rrdom

`apply virtual style rules to node` tests need to be moved to rrweb to avoid circular dependencies

* Fix image loading issue in integration tests

* Move pack/unpack to its own @rrweb/packer module

* Get tests to work in rrdom-nodejs

* Port tests in rrweb-snapshot to vitest and fix them

* Fix tests for rrweb-plugin-console-record

* Add @rrweb/all package

* Fix publint and attw errors for rrdom and @rrweb/types

* Use shared vitest.config.ts in rrweb-snapshot package

* Fix publint and attw issues for rrweb-snapshot

* Export `ReplayPlugin` type directly from rrweb

* Fix publint and attw issues for packages

* Fix publint & attw issue.

I was bumping into this issue: 3729bc2a3c/docs/problems/NoResolution.md

And had to choose one of these three methods described here:
https://github.com/andrewbranch/example-subpath-exports-ts-compat?tab=readme-ov-file#typescript-friendly-strategies-for-packagejson-subpath-exports-compatibility
And I ended up going for the method described here:
1ffe3425b0/examples/node_modules/package-json-redirects (package-json-redirects)

The redirect method seemed the least invasive and most effective.

* Fix publint & attw issue.

I was bumping into this issue: 3729bc2a3c/docs/problems/NoResolution.md

And had to choose one of these three methods described here:
https://github.com/andrewbranch/example-subpath-exports-ts-compat?tab=readme-ov-file#typescript-friendly-strategies-for-packagejson-subpath-exports-compatibility
And I ended up going for the method described here:
1ffe3425b0/examples/node_modules/package-json-redirects (package-json-redirects)

The redirect method seemed the least invasive and most effective.

* move some rrdom tests that require rrweb to rrweb package

* Use pre-jest 29 syntax for snapshotting

* get rrweb passing publint and attw

* const enum does not work with isolated modules flag

* Fix script tag type in webgl.test.ts.snap and update rrweb.umd.cjs path in webgl.test.ts

* Fix paths

* Move tests for console record plugin and fix bundle path

* Fix tests for rrweb

* pack integration tests were moved to @rrweb/all

* Update rrweb bundle path in test files

* Fix flaky scroll emit from test

* Migrate rrweb's tests over to vitest and make them pass

* Make sure benchmarks & updating tests work

* Remove jest from rrweb

* Fix paths

* always use rrweb's own cssom

* Update tsconfig.json for rrweb-plugin-sequential-id-record

Fixes this error:
Error: @rrweb/rrweb-plugin-sequential-id-record:prepublish: tsconfig.json(9,5): error TS6377: Cannot write file '/home/runner/work/rrweb/rrweb/tsconfig.tsbuildinfo' because it will overwrite '.tsbuildinfo' file generated by referenced project '/home/runner/work/rrweb/rrweb/packages/rrweb'

* Add tsbuildinfo config to extended tsconfig files

* Move rrdom over to vitest

* Apply formatting changes

* Update rrweb imports to use the new package structure

* extend rrweb-snapshot's tsconfig from monorepo base config

* extend @rrweb/types's tsconfig from monorepo base config

* extend rrdom's tsconfig from monorepo base config

* extend rrdom-nodejs's tsconfig from monorepo base config

* extend web-extension's tsconfig from monorepo base config

* unify tsconfigs

* Continue when tests fail

* Add stricter type checking

* Add check-types global command

* remove jest

* Remove unused code

* Add check-types command to build script

* Fix linting issues

* Add setup Chrome action for CI/CD workflow

* Update puppeteer version in package.json for rrweb

* Update Chrome setup in CI/CD workflow

* Update Chrome setup in CI/CD workflow

* Add Chrome setup and test cache location

* Update CI/CD workflow to test chrome cache location

* Add chrome installation step to CI/CD workflow

* Update Puppeteer configuration for headless testing

* Update dependencies and workflow configuration

* Use same version of chrome on CI as is run locally

* Use version of chrome that seems to work with rrdom tests

* Try using puppeteerrc to define chrome version

* Add .cache directory to .gitignore

* Move global flag to vitest config

* Update puppeteer version to 20.9.0

* Update console log messages in rrweb-plugin-console-record for new puppeteer version

* Remove redundant Chrome setup from CI/CD workflow

* Add minification and umd for all built files

* Update import paths for rrweb dist files

* Add @rrweb/replay and @rrweb/record

* Add script to lint packages

* Apply formatting changes

* exclude styles export from typescript package type checking

* WIP Move rrweb-player over to vite

* Apply formatting changes

* chore: Update rrweb plugin import paths

* Remove rollup from rrweb-player

* Fix typing issues

* Fix typing issues

* chore: Update rrweb-player to use vite for build process

* Apply formatting changes

* chore: Export Player class in rrweb-player/src/main.ts

Makes attw happy

* Apply formatting changes

* Gets wiped by yarn workspaces-to-typescript-project-references

* Add .eslintignore and .eslintrc.cjs files for rrweb-player package

* Apply formatting changes

* Update dependencies in rrweb-player/package.json

* Apply formatting changes

* chore: Update eslint configuration for rrweb-player package

* Apply formatting changes

* chore: Remove unused files from rrweb-player package

* Apply formatting changes

* chore: Update rrweb-player import path to use rrweb-player.cjs

* chore: Update addEventListener signature in rrweb-player

* Apply formatting changes

* Add .eslintignore and update .gitignore files for to root

* Apply formatting changes

* Update documentation

* Update @rrweb/types package description

* Apply formatting changes

* Update build and run commands in CONTRIBUTING.md

* Apply formatting changes

* Update package versions to 2.0.0-alpha.13

* Apply formatting changes

* Apply formatting changes

* Fix import statement in media/index.ts

* Apply formatting changes

* chore: Update .gitignore to exclude build and dist directories

* Apply formatting changes

* Apply formatting changes

* Migrate setTimeout to vitest

* Apply formatting changes

* Apply formatting changes

* Fix isNativeShadowDom function signature in utils.ts

* try out jsr

* Apply formatting changes

* Update package versions to 2.0.0-alpha.14

* Apply formatting changes

* Fix name of rrwebSnapshot object

* Apply formatting changes

* Remove unused lock files

* Apply formatting changes

* Update rrweb bundle path to use umd.cjs format

* Apply formatting changes

* Trigger tests to run again

* Rename snapshots for vitest

* Apply formatting changes

* Ping CI

* Apply formatting changes

* Ping CI

* Apply formatting changes

* Ignore files generated by svelte-kit for prettier

* Correct Player object
2026-04-01 12:00:00 +08:00

488 lines
13 KiB
TypeScript

import {
NodeType as RRNodeType,
createMirror as createNodeMirror,
} from 'rrweb-snapshot';
import type {
Mirror as NodeMirror,
IMirror,
serializedNodeWithId,
} from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
inputData,
scrollData,
styleSheetRuleData,
styleDeclarationData,
} from '@rrweb/types';
import {
BaseRRNode as RRNode,
BaseRRCDATASection,
BaseRRComment,
BaseRRDocument,
BaseRRDocumentType,
BaseRRElement,
BaseRRMediaElement,
BaseRRText,
type IRRDocument,
type IRRElement,
type IRRNode,
NodeType,
type IRRDocumentType,
type IRRText,
type IRRComment,
} from './document';
export class RRDocument extends BaseRRDocument {
private UNSERIALIZED_STARTING_ID = -2;
// In the rrweb replayer, there are some unserialized nodes like the element that stores the injected style rules.
// These unserialized nodes may interfere the execution of the diff algorithm.
// The id of serialized node is larger than 0. So this value less than 0 is used as id for these unserialized nodes.
private _unserializedId = this.UNSERIALIZED_STARTING_ID;
/**
* Every time the id is used, it will minus 1 automatically to avoid collisions.
*/
public get unserializedId(): number {
return this._unserializedId--;
}
public mirror: Mirror = createMirror();
public scrollData: scrollData | null = null;
constructor(mirror?: Mirror) {
super();
if (mirror) {
this.mirror = mirror;
}
}
createDocument(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_namespace: string | null,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_qualifiedName: string | null,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_doctype?: DocumentType | null,
) {
return new RRDocument();
}
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
) {
const documentTypeNode = new RRDocumentType(
qualifiedName,
publicId,
systemId,
);
documentTypeNode.ownerDocument = this;
return documentTypeNode;
}
createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
): RRElementType<K>;
createElement(tagName: string): RRElement;
createElement(tagName: string) {
const upperTagName = tagName.toUpperCase();
let element;
switch (upperTagName) {
case 'AUDIO':
case 'VIDEO':
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIFrameElement(upperTagName, this.mirror);
break;
case 'CANVAS':
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
break;
}
element.ownerDocument = this;
return element;
}
createComment(data: string) {
const commentNode = new RRComment(data);
commentNode.ownerDocument = this;
return commentNode;
}
createCDATASection(data: string) {
const sectionNode = new RRCDATASection(data);
sectionNode.ownerDocument = this;
return sectionNode;
}
createTextNode(data: string) {
const textNode = new RRText(data);
textNode.ownerDocument = this;
return textNode;
}
destroyTree() {
this.firstChild = null;
this.lastChild = null;
this.mirror.reset();
}
open() {
super.open();
this._unserializedId = this.UNSERIALIZED_STARTING_ID;
}
}
export const RRDocumentType = BaseRRDocumentType;
export class RRElement extends BaseRRElement {
inputData: inputData | null = null;
scrollData: scrollData | null = null;
}
export class RRMediaElement extends BaseRRMediaElement {}
export class RRCanvasElement extends RRElement implements IRRElement {
public rr_dataURL: string | null = null;
public canvasMutations: {
event: canvasEventWithTime;
mutation: canvasMutationData;
}[] = [];
/**
* This is a dummy implementation to distinguish RRCanvasElement from real HTMLCanvasElement.
*/
getContext(): RenderingContext | null {
return null;
}
}
export class RRStyleElement extends RRElement {
public rules: (styleSheetRuleData | styleDeclarationData)[] = [];
}
export class RRIFrameElement extends RRElement {
contentDocument: RRDocument = new RRDocument();
constructor(upperTagName: string, mirror: Mirror) {
super(upperTagName);
this.contentDocument.mirror = mirror;
}
}
export const RRText = BaseRRText;
export type RRText = typeof RRText;
export const RRComment = BaseRRComment;
export type RRComment = typeof RRComment;
export const RRCDATASection = BaseRRCDATASection;
export type RRCDATASection = typeof RRCDATASection;
interface RRElementTagNameMap {
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<K extends keyof HTMLElementTagNameMap> =
K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
function getValidTagName(element: HTMLElement): string {
// https://github.com/rrweb-io/rrweb-snapshot/issues/56
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase();
}
/**
* Build a RRNode from a real Node.
* @param node - the real Node
* @param rrdom - the RRDocument
* @param domMirror - the NodeMirror that records the real document tree
* @returns the built RRNode
*/
export function buildFromNode(
node: Node,
rrdom: IRRDocument,
domMirror: NodeMirror,
parentRRNode?: IRRNode | null,
): IRRNode | null {
let rrNode: IRRNode;
switch (node.nodeType) {
case NodeType.DOCUMENT_NODE:
if (parentRRNode && parentRRNode.nodeName === 'IFRAME')
rrNode = (parentRRNode as RRIFrameElement).contentDocument;
else {
rrNode = rrdom;
(rrNode as IRRDocument).compatMode = (node as Document).compatMode as
| 'BackCompat'
| 'CSS1Compat';
}
break;
case NodeType.DOCUMENT_TYPE_NODE: {
const documentType = node as DocumentType;
rrNode = rrdom.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
}
case NodeType.ELEMENT_NODE: {
const elementNode = node as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = rrdom.createElement(tagName);
const rrElement = rrNode as IRRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
elementNode.scrollLeft && (rrElement.scrollLeft = elementNode.scrollLeft);
elementNode.scrollTop && (rrElement.scrollTop = elementNode.scrollTop);
/**
* We don't have to record special values of input elements at the beginning.
* Because if these values are changed later, the mutation will be applied through the batched input events on its RRElement after the diff algorithm is executed.
*/
break;
}
case NodeType.TEXT_NODE:
rrNode = rrdom.createTextNode((node as Text).textContent || '');
break;
case NodeType.CDATA_SECTION_NODE:
rrNode = rrdom.createCDATASection((node as CDATASection).data);
break;
case NodeType.COMMENT_NODE:
rrNode = rrdom.createComment((node as Comment).textContent || '');
break;
// if node is a shadow root
case NodeType.DOCUMENT_FRAGMENT_NODE:
rrNode = (parentRRNode as IRRElement).attachShadow({ mode: 'open' });
break;
default:
return null;
}
let sn: serializedNodeWithId | null = domMirror.getMeta(node);
if (rrdom instanceof RRDocument) {
if (!sn) {
sn = getDefaultSN(rrNode, rrdom.unserializedId);
domMirror.add(node, sn);
}
rrdom.mirror.add(rrNode, { ...sn });
}
return rrNode;
}
/**
* Build a RRDocument from a real document tree.
* @param dom - the real document tree
* @param domMirror - the NodeMirror that records the real document tree
* @param rrdom - the rrdom object to be constructed
* @returns the build rrdom
*/
export function buildFromDom(
dom: Document,
domMirror: NodeMirror = createNodeMirror(),
rrdom: IRRDocument = new RRDocument(),
) {
function walk(node: Node, parentRRNode: IRRNode | null) {
const rrNode = buildFromNode(node, rrdom, domMirror, parentRRNode);
if (rrNode === null) return;
if (
// if the parentRRNode isn't a RRIFrameElement
parentRRNode?.nodeName !== 'IFRAME' &&
// if node isn't a shadow root
node.nodeType !== NodeType.DOCUMENT_FRAGMENT_NODE
) {
parentRRNode?.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement = parentRRNode as RRElement;
}
if (node.nodeName === 'IFRAME') {
const iframeDoc = (node as HTMLIFrameElement).contentDocument;
iframeDoc && walk(iframeDoc, rrNode);
} else if (
node.nodeType === NodeType.DOCUMENT_NODE ||
node.nodeType === NodeType.ELEMENT_NODE ||
node.nodeType === NodeType.DOCUMENT_FRAGMENT_NODE
) {
// if the node is a shadow dom
if (
node.nodeType === NodeType.ELEMENT_NODE &&
(node as HTMLElement).shadowRoot
)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
walk((node as HTMLElement).shadowRoot!, rrNode);
node.childNodes.forEach((childNode) => walk(childNode, rrNode));
}
}
walk(dom, null);
return rrdom;
}
export function createMirror(): Mirror {
return new Mirror();
}
// based on Mirror from rrweb-snapshots
export class Mirror implements IMirror<RRNode> {
private idNodeMap: Map<number, RRNode> = new Map();
private nodeMetaMap: WeakMap<RRNode, serializedNodeWithId> = new WeakMap();
getId(n: RRNode | 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): RRNode | null {
return this.idNodeMap.get(id) || null;
}
getIds(): number[] {
return Array.from(this.idNodeMap.keys());
}
getMeta(n: RRNode): serializedNodeWithId | null {
return this.nodeMetaMap.get(n) || null;
}
// removes the node from idNodeMap
// doesn't remove the node from nodeMetaMap
removeNodeFromMap(n: RRNode) {
const id = this.getId(n);
this.idNodeMap.delete(id);
if (n.childNodes) {
n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode));
}
}
has(id: number): boolean {
return this.idNodeMap.has(id);
}
hasNode(node: RRNode): boolean {
return this.nodeMetaMap.has(node);
}
add(n: RRNode, meta: serializedNodeWithId) {
const id = meta.id;
this.idNodeMap.set(id, n);
this.nodeMetaMap.set(n, meta);
}
replace(id: number, n: RRNode) {
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();
}
}
/**
* Get a default serializedNodeWithId value for a RRNode.
* @param id - the serialized id to assign
*/
export function getDefaultSN(node: IRRNode, id: number): serializedNodeWithId {
switch (node.RRNodeType) {
case RRNodeType.Document:
return {
id,
type: node.RRNodeType,
childNodes: [],
};
case RRNodeType.DocumentType: {
const doctype = node as IRRDocumentType;
return {
id,
type: node.RRNodeType,
name: doctype.name,
publicId: doctype.publicId,
systemId: doctype.systemId,
};
}
case RRNodeType.Element:
return {
id,
type: node.RRNodeType,
tagName: (node as IRRElement).tagName.toLowerCase(), // In rrweb data, all tagNames are lowercase.
attributes: {},
childNodes: [],
};
case RRNodeType.Text:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRText).textContent || '',
};
case RRNodeType.Comment:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRComment).textContent || '',
};
case RRNodeType.CDATA:
return {
id,
type: node.RRNodeType,
textContent: '',
};
}
}
/**
* Print the RRDom as a string.
* @param rootNode - the root node of the RRDom tree
* @param mirror - a rrweb or rrdom Mirror
* @returns printed string
*/
export function printRRDom(rootNode: IRRNode, mirror: IMirror<IRRNode>) {
return walk(rootNode, mirror, '');
}
function walk(node: IRRNode, mirror: IMirror<IRRNode>, blankSpace: string) {
let printText = `${blankSpace}${mirror.getId(node)} ${node.toString()}\n`;
if (node.RRNodeType === RRNodeType.Element) {
const element = node as IRRElement;
if (element.shadowRoot)
printText += walk(element.shadowRoot, mirror, blankSpace + ' ');
}
for (const child of node.childNodes)
printText += walk(child, mirror, blankSpace + ' ');
if (node.nodeName === 'IFRAME')
printText += walk(
(node as RRIFrameElement).contentDocument,
mirror,
blankSpace + ' ',
);
return printText;
}
export { RRNode };
export { diff, createOrGetNode, type ReplayerHandler } from './diff';
export * from './document';