diff --git a/src/record.ts b/src/record.ts deleted file mode 100644 index 6b0cc41d..00000000 --- a/src/record.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { snapshot } from 'rrweb-snapshot'; -import { EventType, event } from './types'; - -function on( - type: string, - fn: EventListenerOrEventListenerObject, - target = document, -) { - target.addEventListener(type, fn); -} - -function createEvent(type: EventType, data: any): event { - return { - type, - data, - timestamp: Date.now(), - }; -} - -function emit(e: event) {} - -function record() { - on('DOMContentLoaded', () => { - emit( - createEvent(EventType.DomContentLoaded, { href: window.location.href }), - ); - }); - on('load', () => { - emit(createEvent(EventType.Load, null)); - const node = snapshot(document); - emit(createEvent(EventType.FullSnapshot, { node })); - }); -} - -export default record; diff --git a/src/record/index.ts b/src/record/index.ts new file mode 100644 index 00000000..3040ee4b --- /dev/null +++ b/src/record/index.ts @@ -0,0 +1,70 @@ +import { snapshot } from 'rrweb-snapshot'; +import initObservers from './observer'; +import { mirror } from '../utils'; +import { + EventType, + event, + eventWithTime, + recordOptions, + IncrementalSource, +} from '../types'; + +function on( + type: string, + fn: EventListenerOrEventListenerObject, + target = document, +) { + target.addEventListener(type, fn, { capture: true, passive: true }); +} + +function wrapEvent(e: event): eventWithTime { + return { + ...e, + timestamp: Date.now(), + }; +} + +function record(options: recordOptions) { + try { + const { emit } = options; + // runtime checks for user options + if (!emit) { + throw new Error('emit function is required'); + } + on('DOMContentLoaded', () => { + emit( + wrapEvent({ + type: EventType.DomContentLoaded, + data: { + href: window.location.href, + }, + }), + ); + }); + on('load', () => { + emit(wrapEvent({ type: EventType.Load, data: {} })); + const [node, idNodeMap] = snapshot(document); + if (!node) { + return console.warn('Failed to snapshot the document'); + } + mirror.map = idNodeMap; + emit(wrapEvent({ type: EventType.FullSnapshot, data: { node } })); + initObservers({ + mutationCb: m => + emit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + ...m, + }, + }), + ), + }); + }); + } catch (error) { + // TODO: handle internal error + } +} + +export default record; diff --git a/src/record/observer.ts b/src/record/observer.ts new file mode 100644 index 00000000..0c851a1b --- /dev/null +++ b/src/record/observer.ts @@ -0,0 +1,107 @@ +import { INode } from 'rrweb-snapshot'; +import { mirror } from '../utils'; +import { + mutationCallBack, + textMutation, + attributeMutation, + removedNodeMutation, + addedNodeMutation, + observerParam, +} from '../types'; + +function initMutationObserver(cb: mutationCallBack): MutationObserver { + const observer = new MutationObserver(mutations => { + const texts: textMutation[] = []; + const attributes: attributeMutation[] = []; + const removes: removedNodeMutation[] = []; + const adds: addedNodeMutation[] = []; + mutations.forEach(mutation => { + const { + type, + target, + oldValue, + addedNodes, + removedNodes, + attributeName, + nextSibling, + previousSibling, + } = mutation; + const id = mirror.getId(target as INode); + switch (type) { + case 'characterData': { + const value = target.textContent; + if (value !== oldValue) { + texts.push({ + id, + value, + }); + } + break; + } + case 'attributes': { + const value = (target as HTMLElement).getAttribute(attributeName!); + if (value === oldValue) { + return; + } + let item: attributeMutation | undefined = attributes.find( + a => a.id === id, + ); + if (!item) { + item = { + id, + attributes: {}, + }; + attributes.push(item); + } + // overwrite attribute if the mutations was triggered in same time + item.attributes[attributeName!] = value; + } + case 'childList': { + removedNodes.forEach(n => { + removes.push({ + parentId: id, + id: mirror.getId(n as INode), + }); + }); + addedNodes.forEach(n => { + adds.push({ + parentId: id, + previousId: !previousSibling + ? previousSibling + : mirror.getId(previousSibling as INode), + nextId: !nextSibling + ? nextSibling + : mirror.getId(nextSibling as INode), + id: mirror.getId(n as INode), + }); + }); + break; + } + default: + break; + } + }); + cb({ + texts, + attributes, + removes, + adds, + }); + }); + observer.observe(document, { + attributes: true, + attributeOldValue: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + }); + return observer; +} + +export default function initObservers(o: observerParam) { + const mutationObserver = initMutationObserver(o.mutationCb); + return { + mutationObserver, + }; +} diff --git a/src/types.ts b/src/types.ts index 4fb5e716..949cc5d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { serializedNodeWithId, idNodeMap, INode } from 'rrweb-snapshot'; + export enum EventType { DomContentLoaded, Load, @@ -5,8 +7,93 @@ export enum EventType { IncrementalSnapshot, } -export type event = { - type: EventType; - timestamp: number; - data: any; +export type domContentLoadedEvent = { + type: EventType.DomContentLoaded; + data: { + href: string; + }; +}; + +export type loadedEvent = { + type: EventType.Load; + data: {}; +}; + +export type fullSnapshotEvent = { + type: EventType.FullSnapshot; + data: { + node: serializedNodeWithId; + }; +}; + +export enum IncrementalSource { + Mutation, +} + +export type incrementalSnapshotEvent = { + type: EventType.IncrementalSnapshot; + data: incrementalData; +}; + +export type mutationData = { + source: IncrementalSource.Mutation; +} & mutationCallbackParam; + +export type incrementalData = mutationData; + +export type event = + | domContentLoadedEvent + | loadedEvent + | fullSnapshotEvent + | incrementalSnapshotEvent; + +export type eventWithTime = event & { + timestamp: number; +}; + +export type recordOptions = { + emit: (e: eventWithTime) => void; +}; + +export type observerParam = { + mutationCb: mutationCallBack; +}; + +export type textMutation = { + id: number; + value: string | null; +}; + +export type attributeMutation = { + id: number; + attributes: { + [key: string]: string | null; + }; +}; + +export type removedNodeMutation = { + parentId: number; + id: number; +}; + +export type addedNodeMutation = { + parentId: number; + previousId: number | null; + nextId: number | null; + id: number; +}; + +type mutationCallbackParam = { + texts: textMutation[]; + attributes: attributeMutation[]; + removes: removedNodeMutation[]; + adds: addedNodeMutation[]; +}; + +export type mutationCallBack = (m: mutationCallbackParam) => void; + +export type Mirror = { + map: idNodeMap; + getId: (n: INode) => number; + getNode: (id: number) => INode; }; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..64a73b68 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,11 @@ +import { Mirror } from './types'; + +export const mirror: Mirror = { + map: {}, + getId(n) { + return n.__sn && n.__sn.id; + }, + getNode(id) { + return mirror.map[id]; + }, +}; diff --git a/tsconfig.json b/tsconfig.json index 6378ca69..70ab5ce2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "module": "commonjs", "noImplicitAny": true, + "strictNullChecks": true, "removeComments": true, "preserveConstEnums": true, "sourceMap": true,