Files
rrweb/packages/rrweb/src/record/index.ts
Pravin Tiwari 7dca0f37c4 fix the statement which is getting changed by Microbundle (#1156)
* fix the statement which is getting changed by microbundle

* Create chatty-cherries-train.md

* fix formatting.

* fix position of comment

---------

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
2026-04-01 12:00:00 +08:00

648 lines
16 KiB
TypeScript

import {
snapshot,
MaskInputOptions,
SlimDOMOptions,
createMirror,
} from 'rrweb-snapshot';
import {
initObservers,
mutationBuffers,
processedNodeManager,
} from './observer';
import {
on,
getWindowWidth,
getWindowHeight,
getWindowScroll,
polyfill,
hasShadowRoot,
isSerializedIframe,
isSerializedStylesheet,
} from '../utils';
import type { recordOptions } from '../types';
import {
EventType,
event,
eventWithTime,
IncrementalSource,
listenerHandler,
mutationCallbackParam,
scrollCallback,
canvasMutationParam,
adoptedStyleSheetParam,
} from '@rrweb/types';
import type { CrossOriginIframeMessageEventContent } from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
import { StylesheetManager } from './stylesheet-manager';
function wrapEvent(e: event): eventWithTime {
return {
...e,
timestamp: Date.now(),
};
}
let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void;
let takeFullSnapshot!: (isCheckout?: boolean) => void;
let canvasManager!: CanvasManager;
let recording = false;
const mirror = createMirror();
function record<T = eventWithTime>(
options: recordOptions<T> = {},
): listenerHandler | undefined {
const {
emit,
checkoutEveryNms,
checkoutEveryNth,
blockClass = 'rr-block',
blockSelector = null,
ignoreClass = 'rr-ignore',
maskTextClass = 'rr-mask',
maskTextSelector = null,
inlineStylesheet = true,
maskAllInputs,
maskInputOptions: _maskInputOptions,
slimDOMOptions: _slimDOMOptions,
maskInputFn,
maskTextFn,
hooks,
packFn,
sampling = {},
dataURLOptions = {},
mousemoveWait,
recordCanvas = false,
recordCrossOriginIframes = false,
recordAfter = options.recordAfter === 'DOMContentLoaded'
? options.recordAfter
: 'load',
userTriggeredOnInput = false,
collectFonts = false,
inlineImages = false,
plugins,
keepIframeSrcFn = () => false,
ignoreCSSAttributes = new Set([]),
} = options;
const inEmittingFrame = recordCrossOriginIframes
? window.parent === window
: true;
let passEmitsToParent = false;
if (!inEmittingFrame) {
try {
// throws if parent is cross-origin
if (window.parent.document) {
passEmitsToParent = false; // if parent is same origin we collect iframe events from the parent
}
} catch (e) {
passEmitsToParent = true;
}
}
// runtime checks for user options
if (inEmittingFrame && !emit) {
throw new Error('emit function is required');
}
// move departed options to new options
if (mousemoveWait !== undefined && sampling.mousemove === undefined) {
sampling.mousemove = mousemoveWait;
}
// reset mirror in case `record` this was called earlier
mirror.reset();
const maskInputOptions: MaskInputOptions =
maskAllInputs === true
? {
color: true,
date: true,
'datetime-local': true,
email: true,
month: true,
number: true,
range: true,
search: true,
tel: true,
text: true,
time: true,
url: true,
week: true,
textarea: true,
select: true,
password: true,
}
: _maskInputOptions !== undefined
? _maskInputOptions
: { password: true };
const slimDOMOptions: SlimDOMOptions =
_slimDOMOptions === true || _slimDOMOptions === 'all'
? {
script: true,
comment: true,
headFavicon: true,
headWhitespace: true,
headMetaSocial: true,
headMetaRobots: true,
headMetaHttpEquiv: true,
headMetaVerification: true,
// the following are off for slimDOMOptions === true,
// as they destroy some (hidden) info:
headMetaAuthorship: _slimDOMOptions === 'all',
headMetaDescKeywords: _slimDOMOptions === 'all',
}
: _slimDOMOptions
? _slimDOMOptions
: {};
polyfill();
let lastFullSnapshotEvent: eventWithTime;
let incrementalSnapshotCount = 0;
const eventProcessor = (e: eventWithTime): T => {
for (const plugin of plugins || []) {
if (plugin.eventProcessor) {
e = plugin.eventProcessor(e);
}
}
if (
packFn &&
// Disable packing events which will be emitted to parent frames.
!passEmitsToParent
) {
e = packFn(e) as unknown as eventWithTime;
}
return e as unknown as T;
};
wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => {
if (
mutationBuffers[0]?.isFrozen() &&
e.type !== EventType.FullSnapshot &&
!(
e.type === EventType.IncrementalSnapshot &&
e.data.source === IncrementalSource.Mutation
)
) {
// we've got a user initiated event so first we need to apply
// all DOM changes that have been buffering during paused state
mutationBuffers.forEach((buf) => buf.unfreeze());
}
if (inEmittingFrame) {
emit?.(eventProcessor(e), isCheckout);
} else if (passEmitsToParent) {
const message: CrossOriginIframeMessageEventContent<T> = {
type: 'rrweb',
event: eventProcessor(e),
origin: window.location.origin,
isCheckout,
};
window.parent.postMessage(message, '*');
}
if (e.type === EventType.FullSnapshot) {
lastFullSnapshotEvent = e;
incrementalSnapshotCount = 0;
} else if (e.type === EventType.IncrementalSnapshot) {
// attach iframe should be considered as full snapshot
if (
e.data.source === IncrementalSource.Mutation &&
e.data.isAttachIframe
) {
return;
}
incrementalSnapshotCount++;
const exceedCount =
checkoutEveryNth && incrementalSnapshotCount >= checkoutEveryNth;
const exceedTime =
checkoutEveryNms &&
e.timestamp - lastFullSnapshotEvent.timestamp > checkoutEveryNms;
if (exceedCount || exceedTime) {
takeFullSnapshot(true);
}
}
};
const wrappedMutationEmit = (m: mutationCallbackParam) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
...m,
},
}),
);
};
const wrappedScrollEmit: scrollCallback = (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Scroll,
...p,
},
}),
);
const wrappedCanvasMutationEmit = (p: canvasMutationParam) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
...p,
},
}),
);
const wrappedAdoptedStyleSheetEmit = (a: adoptedStyleSheetParam) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
...a,
},
}),
);
const stylesheetManager = new StylesheetManager({
mutationCb: wrappedMutationEmit,
adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit,
});
const iframeManager = new IframeManager({
mirror,
mutationCb: wrappedMutationEmit,
stylesheetManager: stylesheetManager,
recordCrossOriginIframes,
wrappedEmit,
});
/**
* Exposes mirror to the plugins
*/
for (const plugin of plugins || []) {
if (plugin.getMirror)
plugin.getMirror({
nodeMirror: mirror,
crossOriginIframeMirror: iframeManager.crossOriginIframeMirror,
crossOriginIframeStyleMirror:
iframeManager.crossOriginIframeStyleMirror,
});
}
canvasManager = new CanvasManager({
recordCanvas,
mutationCb: wrappedCanvasMutationEmit,
win: window,
blockClass,
blockSelector,
mirror,
sampling: sampling.canvas,
dataURLOptions,
});
const shadowDomManager = new ShadowDomManager({
mutationCb: wrappedMutationEmit,
scrollCb: wrappedScrollEmit,
bypassOptions: {
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskInputOptions,
dataURLOptions,
maskTextFn,
maskInputFn,
recordCanvas,
inlineImages,
sampling,
slimDOMOptions,
iframeManager,
stylesheetManager,
canvasManager,
keepIframeSrcFn,
processedNodeManager,
},
mirror,
});
takeFullSnapshot = (isCheckout = false) => {
wrappedEmit(
wrapEvent({
type: EventType.Meta,
data: {
href: window.location.href,
width: getWindowWidth(),
height: getWindowHeight(),
},
}),
isCheckout,
);
// When we take a full snapshot, old tracked StyleSheets need to be removed.
stylesheetManager.reset();
shadowDomManager.init();
mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting
const node = snapshot(document, {
mirror,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskAllInputs: maskInputOptions,
maskTextFn,
slimDOM: slimDOMOptions,
dataURLOptions,
recordCanvas,
inlineImages,
onSerialize: (n) => {
if (isSerializedIframe(n, mirror)) {
iframeManager.addIframe(n as HTMLIFrameElement);
}
if (isSerializedStylesheet(n, mirror)) {
stylesheetManager.trackLinkElement(n as HTMLLinkElement);
}
if (hasShadowRoot(n)) {
shadowDomManager.addShadowRoot(n.shadowRoot, document);
}
},
onIframeLoad: (iframe, childSn) => {
iframeManager.attachIframe(iframe, childSn);
shadowDomManager.observeAttachShadow(iframe);
},
onStylesheetLoad: (linkEl, childSn) => {
stylesheetManager.attachLinkElement(linkEl, childSn);
},
keepIframeSrcFn,
});
if (!node) {
return console.warn('Failed to snapshot the document');
}
wrappedEmit(
wrapEvent({
type: EventType.FullSnapshot,
data: {
node,
initialOffset: getWindowScroll(window),
},
}),
isCheckout,
);
mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror
// Some old browsers don't support adoptedStyleSheets.
if (document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0)
stylesheetManager.adoptStyleSheets(
document.adoptedStyleSheets,
mirror.getId(document),
);
};
try {
const handlers: listenerHandler[] = [];
const observe = (doc: Document) => {
return initObservers(
{
mutationCb: wrappedMutationEmit,
mousemoveCb: (positions, source) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source,
positions,
},
}),
),
mouseInteractionCb: (d) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MouseInteraction,
...d,
},
}),
),
scrollCb: wrappedScrollEmit,
viewportResizeCb: (d) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.ViewportResize,
...d,
},
}),
),
inputCb: (v) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Input,
...v,
},
}),
),
mediaInteractionCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MediaInteraction,
...p,
},
}),
),
styleSheetRuleCb: (r) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
...r,
},
}),
),
styleDeclarationCb: (r) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleDeclaration,
...r,
},
}),
),
canvasMutationCb: wrappedCanvasMutationEmit,
fontCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Font,
...p,
},
}),
),
selectionCb: (p) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Selection,
...p,
},
}),
);
},
blockClass,
ignoreClass,
maskTextClass,
maskTextSelector,
maskInputOptions,
inlineStylesheet,
sampling,
recordCanvas,
inlineImages,
userTriggeredOnInput,
collectFonts,
doc,
maskInputFn,
maskTextFn,
keepIframeSrcFn,
blockSelector,
slimDOMOptions,
dataURLOptions,
mirror,
iframeManager,
stylesheetManager,
shadowDomManager,
processedNodeManager,
canvasManager,
ignoreCSSAttributes,
plugins:
plugins
?.filter((p) => p.observer)
?.map((p) => ({
observer: p.observer!,
options: p.options,
callback: (payload: object) =>
wrappedEmit(
wrapEvent({
type: EventType.Plugin,
data: {
plugin: p.name,
payload,
},
}),
),
})) || [],
},
hooks,
);
};
iframeManager.addLoadListener((iframeEl) => {
try {
handlers.push(observe(iframeEl.contentDocument!));
} catch (error) {
// TODO: handle internal error
console.warn(error);
}
});
const init = () => {
takeFullSnapshot();
handlers.push(observe(document));
recording = true;
};
if (
document.readyState === 'interactive' ||
document.readyState === 'complete'
) {
init();
} else {
handlers.push(
on('DOMContentLoaded', () => {
wrappedEmit(
wrapEvent({
type: EventType.DomContentLoaded,
data: {},
}),
);
if (recordAfter === 'DOMContentLoaded') init();
}),
);
handlers.push(
on(
'load',
() => {
wrappedEmit(
wrapEvent({
type: EventType.Load,
data: {},
}),
);
if (recordAfter === 'load') init();
},
window,
),
);
}
return () => {
handlers.forEach((h) => h());
recording = false;
};
} catch (error) {
// TODO: handle internal error
console.warn(error);
}
}
record.addCustomEvent = <T>(tag: string, payload: T) => {
if (!recording) {
throw new Error('please add custom event after start recording');
}
wrappedEmit(
wrapEvent({
type: EventType.Custom,
data: {
tag,
payload,
},
}),
);
};
record.freezePage = () => {
mutationBuffers.forEach((buf) => buf.freeze());
};
record.takeFullSnapshot = (isCheckout?: boolean) => {
if (!recording) {
throw new Error('please take full snapshot after start recording');
}
takeFullSnapshot(isCheckout);
};
record.mirror = mirror;
export default record;