diff --git a/.changeset/shy-garlics-brush.md b/.changeset/shy-garlics-brush.md new file mode 100644 index 0000000000..89cc18208a --- /dev/null +++ b/.changeset/shy-garlics-brush.md @@ -0,0 +1,5 @@ +--- +"rrweb": patch +--- + +feat: Add support for merging events, you can choose any time range in replay and generate a new clip of the rrweb events which can be played independently even if there are no fullsnapshots in this time range. diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index a03e326b6f..abf7348601 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -5,6 +5,7 @@ import type { MaskInputFn, MaskTextFn, } from 'rrweb-snapshot'; +import { snapshot } from 'rrweb-snapshot'; import type { IframeManager } from './record/iframe-manager'; import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; @@ -224,3 +225,16 @@ export type CrossOriginIframeMessageEvent = MessageEvent; export type ErrorHandler = (error: unknown) => void | boolean; + +export type MergeOptions = { + // row events to deal with + events: eventWithTime[]; + // options for take fullsnapshot, and mirror is required + snapshotOptions: Parameters[1] & { mirror: Mirror }; + // startTimeStamp to generate from + startTimeStamp: number; + // endTimeStamp to generate end, default: last timestamp in events + endTimeStamp?: number; + // replay iframe generated by Replayer, default .replayer-wrapper > iframe + iframe?: HTMLIFrameElement | null; +}; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index ecf72d05ea..ad4e7bf833 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -9,11 +9,20 @@ import type { DeprecatedMirror, textMutation, IMirror, + serializedNodeWithId, + eventWithTime, } from '@rrweb/types'; -import type { Mirror, SlimDOMOptions } from 'rrweb-snapshot'; -import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot'; +import { EventType } from '@rrweb/types'; +import { Mirror, type SlimDOMOptions } from 'rrweb-snapshot'; +import { + isShadowRoot, + IGNORED_NODE, + classMatchesRegex, + snapshot, +} from 'rrweb-snapshot'; import { RRNode, RRIFrameElement, BaseRRNode } from 'rrdom'; import dom from '@rrweb/utils'; +import type { MergeOptions } from './types'; export function on( type: string, @@ -586,3 +595,94 @@ export function inDom(n: Node): boolean { if (!doc) return false; return dom.contains(doc, n) || shadowHostInDom(n); } + +/** + * You can choose any time range in Replay and generate a new clip of the rrweb events + * which can be played independently EVEN IF there are no fullsnapshots in this time range. + * @param options - merge options + * @returns merged events which can be played independently + */ +export function mergeEvents(options: MergeOptions) { + const { events, startTimeStamp, endTimeStamp, snapshotOptions } = options; + + // check events + if (!events || !Array.isArray(events) || !events.length) { + console.warn('row events is required and not empty.'); + return; + } + + // check mirror + if (!snapshotOptions?.mirror) { + console.warn('mirror in snapshotOptions is required.'); + return; + } + + // check iframe + let iframe = options.iframe; + if (!iframe) { + const wrapper = document.querySelector('.replayer-wrapper'); + if (wrapper) { + iframe = wrapper.querySelector('iframe'); + } + } + if (!iframe) { + console.warn('iframe created by Replayer not found.'); + return; + } + const doc = iframe.contentDocument; + const win = iframe.contentWindow; + if (!doc || !win) { + console.warn('contentDocument or contentWindow in iframe not found.'); + return; + } + + // take the fullsnapshot with the snapshotOptions + let node: serializedNodeWithId | null; + try { + // TODO iframe onSerialize, onIframeLoad, onStylesheetLoad, keepIframeSrcFn + node = snapshot(doc, snapshotOptions); + } catch (error) { + console.warn(error); + return; + } + + const result: eventWithTime[] = []; + let lastMeta: eventWithTime | undefined; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + // record the last meta event + if (event.type === EventType.Meta) { + lastMeta = event; + } + if (event.timestamp < startTimeStamp) { + continue; + } else if (endTimeStamp && event.timestamp > endTimeStamp) { + break; + } else { + if (!result.length) { + if (!lastMeta) { + console.warn('Meta event not found in events'); + return; + } + // append meta event + result.push({ + ...lastMeta, + timestamp: event.timestamp - 2, + }); + // append fullsnapshot event + result.push({ + type: EventType.FullSnapshot, + data: { + node, + initialOffset: getWindowScroll(win), + }, + timestamp: event.timestamp - 1, + } as eventWithTime); + } + // append remains event + result.push(event); + } + } + return result; +} diff --git a/packages/rrweb/test/events/merge-events.ts b/packages/rrweb/test/events/merge-events.ts new file mode 100644 index 0000000000..1f6e795978 --- /dev/null +++ b/packages/rrweb/test/events/merge-events.ts @@ -0,0 +1,357 @@ +import { EventType, IncrementalSource } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; + +const events: eventWithTime[] = [ + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 417, + height: 1318, + }, + timestamp: 1734952276181, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + id: 2, + }, + { + type: 2, + tagName: 'html', + attributes: { + lang: '', + }, + 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: 'link', + attributes: { + rel: 'stylesheet', + href: '', + }, + childNodes: [], + id: 8, + }, + { + type: 3, + textContent: '\n ', + id: 9, + }, + { + type: 2, + tagName: 'script', + attributes: { + src: '', + }, + childNodes: [], + id: 10, + }, + { + type: 3, + textContent: '\n ', + id: 11, + }, + ], + id: 4, + }, + { + type: 3, + textContent: '\n ', + id: 12, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n ', + id: 14, + }, + { + type: 2, + tagName: 'select', + attributes: { + name: 'pets', + id: 'pet-select', + }, + childNodes: [ + { + type: 3, + textContent: '\n ', + id: 16, + }, + { + type: 2, + tagName: 'option', + attributes: { + value: '', + selected: true, + }, + childNodes: [ + { + type: 3, + textContent: '--Please choose an option--', + id: 18, + }, + ], + id: 17, + }, + { + type: 3, + textContent: '\n ', + id: 19, + }, + { + type: 2, + tagName: 'option', + attributes: { + value: 'dog', + }, + childNodes: [ + { + type: 3, + textContent: 'Dog', + id: 21, + }, + ], + id: 20, + }, + { + type: 3, + textContent: '\n ', + id: 22, + }, + { + type: 2, + tagName: 'option', + attributes: { + value: 'cat', + }, + childNodes: [ + { + type: 3, + textContent: 'Cat', + id: 24, + }, + ], + id: 23, + }, + { + type: 3, + textContent: '\n ', + id: 25, + }, + ], + id: 15, + }, + { + type: 3, + textContent: '\n ', + id: 26, + }, + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\n ', + id: 28, + }, + { + type: 2, + tagName: 'input', + attributes: { + type: 'checkbox', + id: 'scales', + name: 'scales', + checked: true, + }, + childNodes: [], + id: 29, + }, + { + type: 3, + textContent: '\n ', + id: 30, + }, + { + type: 2, + tagName: 'label', + attributes: { + for: 'scales', + }, + childNodes: [ + { + type: 3, + textContent: 'Scales', + id: 32, + }, + ], + id: 31, + }, + { + type: 3, + textContent: '\n ', + id: 33, + }, + ], + id: 27, + }, + { + type: 3, + textContent: '\n ', + id: 34, + }, + { + type: 2, + tagName: 'button', + attributes: { + onclick: 'onStartRecord()', + }, + childNodes: [ + { + type: 3, + textContent: 'StartRecord', + id: 36, + }, + ], + id: 35, + }, + { + type: 3, + textContent: '\n ', + id: 37, + }, + { + type: 2, + tagName: 'button', + attributes: { + onclick: 'onStopRecord()', + }, + childNodes: [ + { + type: 3, + textContent: 'StopRecord', + id: 39, + }, + ], + id: 38, + }, + { + type: 3, + textContent: '\n ', + id: 40, + }, + { + type: 2, + tagName: 'script', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'SCRIPT_PLACEHOLDER', + id: 42, + }, + ], + id: 41, + }, + { + type: 3, + textContent: '\n \n\n', + id: 43, + }, + ], + id: 13, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + timestamp: 1734952276197, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Input, + text: 'dog', + isChecked: false, + id: 15, + }, + timestamp: 1734952277674, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Input, + text: 'cat', + isChecked: false, + id: 15, + }, + timestamp: 1734952279091, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Input, + text: 'on', + isChecked: false, + id: 29, + }, + timestamp: 1734952280046, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Input, + text: 'on', + isChecked: true, + id: 29, + }, + timestamp: 1734952280897, + }, +]; +export default events; diff --git a/packages/rrweb/test/util.test.ts b/packages/rrweb/test/util.test.ts index fda0030b67..2c784d5d42 100644 --- a/packages/rrweb/test/util.test.ts +++ b/packages/rrweb/test/util.test.ts @@ -8,6 +8,12 @@ import { shadowHostInDom, getShadowHost, } from '../src/utils'; +import { vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ISuite, launchPuppeteer } from './utils'; +import events from './events/merge-events'; +import type { eventWithTime } from '@rrweb/types'; describe('Utilities for other modules', () => { describe('StyleSheetMirror', () => { @@ -143,4 +149,122 @@ describe('Utilities for other modules', () => { expect(inDom(a.childNodes[0])).toBeTruthy(); }); }); + + describe('mergeEvents()', () => { + vi.setConfig({ testTimeout: 10_000 }); + + let code: ISuite['code']; + let browser: ISuite['browser']; + let page: ISuite['page']; + + beforeAll(async () => { + browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../dist/rrweb.umd.cjs'); + code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto('about:blank'); + await page.evaluate(code); + await page.evaluate(`var events = ${JSON.stringify(events)}`); + page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + }); + + afterEach(async () => { + await page.close(); + }); + + afterAll(async () => { + await browser.close(); + }); + + const startTimeStamp = 1734952277674; + it('Merge existing timestamp', async () => { + // merge events + const mergedEvents = (await page.evaluate(` + const { Replayer, utils } = rrweb; + let replayer = new Replayer(events); + const startTimeStamp = ${startTimeStamp}; + replayer.pause(startTimeStamp - events[0].timestamp); + const mirror = replayer.getMirror(); + utils.mergeEvents({ + events: events, + startTimeStamp, + snapshotOptions: { + mirror, + }, + }); + `)) as eventWithTime[]; + + // expect timestamp + const [meta, fullsnapshot, targetEvent] = mergedEvents; + expect(targetEvent.timestamp).toEqual(startTimeStamp); + expect(fullsnapshot.timestamp).toEqual(startTimeStamp - 1); + expect(meta.timestamp).toEqual(startTimeStamp - 2); + expect(mergedEvents.length).toEqual(6); + + // will start actions when play + const actionLength1 = await page.evaluate(` + replayer.destroy(); + replayer = new Replayer(${JSON.stringify(mergedEvents)}); + replayer.play(); + replayer['timer']['actions'].length; + `); + expect(actionLength1).toEqual(mergedEvents.length); + + // can play at any time offset + const actionLength2 = await page.evaluate(` + replayer.destroy(); + replayer = new Replayer(${JSON.stringify(mergedEvents)}); + replayer.play(1500); + replayer['timer']['actions'].length; + `); + expect(actionLength2).toEqual( + mergedEvents.filter((e) => e.timestamp - startTimeStamp >= 1500).length, + ); + }); + + it('Merge non-existing timestamp from start to the end', async () => { + const newStartTimeStamp = startTimeStamp + 1; + const newEndTimeStamp = events[events.length - 1].timestamp - 1; + + // merge events + const mergedEvents = (await page.evaluate(` + const { Replayer, utils } = rrweb; + let replayer = new Replayer(events); + const startTimeStamp = ${newStartTimeStamp}; + const endTimeStamp = ${newEndTimeStamp}; + replayer.pause(startTimeStamp - events[0].timestamp); + const mirror = replayer.getMirror(); + utils.mergeEvents({ + events: events, + startTimeStamp, + endTimeStamp, + snapshotOptions: { + mirror, + }, + }); + `)) as eventWithTime[]; + + // expect timestamp + const [meta, fullsnapshot, targetEvent] = mergedEvents; + const target = events.find((item) => item.timestamp > newStartTimeStamp); + const targetTimestamp = target?.timestamp || 0; + expect(targetEvent.timestamp).toEqual(targetTimestamp); + expect(fullsnapshot.timestamp).toEqual(targetTimestamp - 1); + expect(meta.timestamp).toEqual(targetTimestamp - 2); + expect(mergedEvents.length).toEqual(4); + + // will start actions when play + const actionLength1 = await page.evaluate(` + replayer.destroy(); + replayer = new Replayer(${JSON.stringify(mergedEvents)}); + replayer.play(); + replayer['timer']['actions'].length; + `); + expect(actionLength1).toEqual(mergedEvents.length); + }); + }); });