Path: blob/main/replay/backend/api/ReplayTabState.ts
1030 views
import { EventEmitter } from 'events';1import ICommandWithResult from '~shared/interfaces/ICommandResult';2import {3IFocusRecord,4IFrontendMouseEvent,5IMouseEvent,6IScrollRecord,7ISessionTab,8} from '~shared/interfaces/ISaSession';9import ReplayTick, { IEventType } from '~backend/api/ReplayTick';10import IPaintEvent from '~shared/interfaces/IPaintEvent';11import {12DomActionType,13IDomChangeEvent,14IFrontendDomChangeEvent,15} from '~shared/interfaces/IDomChangeEvent';16import ITickState from '~shared/interfaces/ITickState';17import ReplayTime from '~backend/api/ReplayTime';18import getResolvable from '~shared/utils/promise';1920export default class ReplayTabState extends EventEmitter {21public ticks: ReplayTick[] = [];22public readonly commandsById = new Map<number, ICommandWithResult>();2324public tabId: number;25public detachedFromTabId: number;26public startOrigin: string;27public urlOrigin: string;28public viewportWidth: number;29public viewportHeight: number;30public currentPlaybarOffsetPct = 0;31public replayTime: ReplayTime;32public tabCreatedTime: number;33public hasAllData = false;3435public get isActive() {36return this.listenerCount('tick:changes') > 0;37}3839public get currentTick() {40return this.ticks[this.currentTickIdx];41}4243public get nextTick() {44return this.ticks[this.currentTickIdx + 1];45}4647public isReady = getResolvable<void>();4849private readonly mouseEventsByTick: Record<number, IMouseEvent> = {};50private readonly scrollEventsByTick: Record<number, IScrollRecord> = {};51private readonly focusEventsByTick: Record<number, IFocusRecord> = {};52private readonly paintEvents: IPaintEvent[] = [];5354private currentTickIdx = -1;55// put in placeholder56private paintEventsLoadedIdx = -1;57private broadcastTimer: NodeJS.Timer;58private lastBroadcast?: Date;59private eventRouter = {60'dom-changes': this.loadDomChange.bind(this),61'mouse-events': this.loadPageEvent.bind(this, 'mouse'),62'focus-events': this.loadPageEvent.bind(this, 'focus'),63'scroll-events': this.loadPageEvent.bind(this, 'scroll'),64commands: this.loadCommand.bind(this),65};6667constructor(tabMeta: ISessionTab, replayTime: ReplayTime) {68super();69this.replayTime = replayTime;70this.tabCreatedTime = tabMeta.createdTime;71this.startOrigin = tabMeta.startOrigin;72this.viewportHeight = tabMeta.height;73this.viewportWidth = tabMeta.width;74this.detachedFromTabId = tabMeta.detachedFromTabId;75if (this.startOrigin) this.isReady.resolve();76this.tabId = tabMeta.tabId;77this.ticks.push(new ReplayTick(this, 'init', 0, -1, replayTime.start.getTime(), 'Load'));78}7980public onApiFeed(eventName: string, event: any) {81const method = this.eventRouter[eventName];82if (method) method(event);83}8485public getTickState() {86return {87currentTickOffset: this.currentPlaybarOffsetPct,88durationMillis: this.replayTime.millis,89ticks: this.ticks.filter(x => x.isMajor()).map(x => x.playbarOffsetPercent),90} as ITickState;91}9293public transitionToPreviousTick() {94const prevTickIdx = this.currentTickIdx > 0 ? this.currentTickIdx - 1 : this.currentTickIdx;95return this.loadTick(prevTickIdx);96}9798public transitionToNextTick() {99const result = this.loadTick(this.currentTickIdx + 1);100if (this.replayTime.close && this.hasAllData && this.currentTickIdx === this.ticks.length - 1) {101this.currentPlaybarOffsetPct = 100;102}103return result;104}105106public setTickValue(playbarOffset: number, isReset = false) {107const ticks = this.ticks;108if (isReset) {109this.currentPlaybarOffsetPct = 0;110this.currentTickIdx = -1;111this.paintEventsLoadedIdx = -1;112}113if (!ticks.length || this.currentPlaybarOffsetPct === playbarOffset) return;114115let newTickIdx = this.currentTickIdx;116// if going forward, load next ticks117if (playbarOffset > this.currentPlaybarOffsetPct) {118for (let i = this.currentTickIdx; i < ticks.length; i += 1) {119if (i < 0) continue;120if (ticks[i].playbarOffsetPercent > playbarOffset) break;121newTickIdx = i;122}123} else {124for (let i = this.currentTickIdx - 1; i >= 0; i -= 1) {125if (ticks[i].playbarOffsetPercent < playbarOffset) break;126newTickIdx = i;127}128}129130return this.loadTick(newTickIdx, playbarOffset);131}132133public setPaintIndex(newTick: ReplayTick) {134if (newTick.paintEventIdx === this.paintEventsLoadedIdx || newTick.paintEventIdx === null) {135return;136}137138const isBackwards = newTick.paintEventIdx < this.paintEventsLoadedIdx;139140let startIndex = this.paintEventsLoadedIdx + 1;141if (isBackwards) {142startIndex = newTick.documentLoadPaintIndex;143}144145const changeEvents: IFrontendDomChangeEvent[] = [];146if (newTick.eventType === 'init' || (newTick.paintEventIdx === -1 && isBackwards)) {147startIndex = -1;148changeEvents.push({149action: DomActionType.newDocument,150textContent: this.startOrigin,151commandId: newTick.commandId,152} as any);153} else {154for (let i = startIndex; i <= newTick.paintEventIdx; i += 1) {155const paints = this.paintEvents[i];156if (!paints) {157console.log('Paint event not loaded!', i);158return;159}160if (161paints.changeEvents[0].frameIdPath === 'main' &&162paints.changeEvents[0].action === DomActionType.newDocument163) {164changeEvents.length = 0;165}166changeEvents.push(...paints.changeEvents);167}168}169170console.log(171'Paint load. Current Idx=%s, Loading [%s->%s] (paints: %s, back? %s)',172this.paintEventsLoadedIdx,173startIndex,174newTick.paintEventIdx,175changeEvents.length,176isBackwards,177);178179this.urlOrigin = newTick.documentOrigin;180this.paintEventsLoadedIdx = newTick.paintEventIdx;181return changeEvents;182}183184public copyPaintEvents(185timestampRange: [number, number],186eventIndexRange: [number, number],187): IPaintEvent[] {188const [startTimestamp, endTimestamp] = timestampRange;189const [startIndex, endIndex] = eventIndexRange;190const paintEvents: IPaintEvent[] = [];191for (const paintEvent of this.paintEvents) {192if (paintEvent.timestamp >= startTimestamp && paintEvent.timestamp <= endTimestamp) {193paintEvents.push({194timestamp: paintEvent.timestamp,195commandId: paintEvent.commandId,196changeEvents: paintEvent.changeEvents.filter(x => {197if (x.timestamp === startTimestamp) {198return x.eventIndex >= startIndex;199}200if (x.timestamp === endTimestamp) {201return x.eventIndex <= endIndex;202}203return true;204}),205});206}207}208return paintEvents;209}210211public loadTick(212newTickIdx: number,213specificPlaybarOffset?: number,214): [215IFrontendDomChangeEvent[],216{ frameIdPath: string; nodeIds: number[] },217IFrontendMouseEvent,218IScrollRecord,219] {220if (newTickIdx === this.currentTickIdx) return;221const newTick = this.ticks[newTickIdx];222223// need to wait for load224if (!newTick) return;225if (!this.replayTime.close) {226// give ticks time to load. TODO: need a better strategy for this227if (new Date().getTime() - new Date(newTick.timestamp).getTime() < 2e3) return;228}229230// console.log('Loading tick %s', newTickIdx);231232const playbarOffset = specificPlaybarOffset ?? newTick.playbarOffsetPercent;233this.currentTickIdx = newTickIdx;234this.currentPlaybarOffsetPct = playbarOffset;235236const paintEvents = this.setPaintIndex(newTick);237const mouseEvent = this.mouseEventsByTick[newTick.mouseEventTick];238const scrollEvent = this.scrollEventsByTick[newTick.scrollEventTick];239const nodesToHighlight = newTick.highlightNodeIds;240241let frontendMouseEvent: IFrontendMouseEvent;242if (mouseEvent) {243frontendMouseEvent = {244frameIdPath: mouseEvent.frameIdPath,245pageX: mouseEvent.pageX,246pageY: mouseEvent.pageY,247offsetX: mouseEvent.offsetX,248offsetY: mouseEvent.offsetY,249targetNodeId: mouseEvent.targetNodeId,250buttons: mouseEvent.buttons,251viewportHeight: this.viewportHeight,252viewportWidth: this.viewportWidth,253};254}255256return [paintEvents, nodesToHighlight, frontendMouseEvent, scrollEvent];257}258259public loadCommand(command: ICommandWithResult) {260if (command.result && typeof command.result === 'string' && command.result.startsWith('"{')) {261try {262command.result = JSON.parse(command.result);263} catch (e) {264// didn't parse, just ignore265}266}267const existing = this.commandsById.get(command.id);268if (existing) {269Object.assign(existing, command);270} else {271const idx = this.commandsById.size;272this.commandsById.set(command.id, command);273const tick = new ReplayTick(274this,275'command',276idx,277command.id,278command.startDate,279command.label,280);281this.ticks.push(tick);282}283}284285public loadPageEvent(eventType: IEventType, event: IDomEvent) {286let events: Record<number, IDomEvent>;287if (eventType === 'mouse') events = this.mouseEventsByTick;288if (eventType === 'focus') events = this.focusEventsByTick;289if (eventType === 'scroll') events = this.scrollEventsByTick;290291events[event.timestamp] = event;292const tick = new ReplayTick(293this,294eventType,295event.timestamp,296event.commandId,297Number(event.timestamp),298);299this.ticks.push(tick);300}301302public loadDetachedState(303detachedFromTabId: number,304paintEvents: IPaintEvent[],305timestamp: number,306commandId: number,307origin: string,308): void {309this.detachedFromTabId = detachedFromTabId;310const flatEvent = <IPaintEvent>{ changeEvents: [], commandId, timestamp };311for (const paintEvent of paintEvents) {312flatEvent.changeEvents.push(...paintEvent.changeEvents);313}314this.paintEvents.push(flatEvent);315this.startOrigin = origin;316const tick = new ReplayTick(this, 'paint', 0, commandId, timestamp);317tick.isNewDocumentTick = true;318tick.documentOrigin = origin;319this.ticks.push(tick);320this.isReady.resolve();321}322323public loadDomChange(event: IDomChangeEvent) {324const { commandId, action, textContent, timestamp } = event;325326// if this is a subframe without a frame, ignore it327if (!event.frameIdPath) return;328329const isMainFrame = event.frameIdPath === 'main';330if (isMainFrame && event.action === DomActionType.newDocument && !this.startOrigin) {331this.startOrigin = event.textContent;332console.log('Got start origin for new tab', this.startOrigin);333this.isReady.resolve();334}335336const lastPaintEvent = this.paintEvents.length337? this.paintEvents[this.paintEvents.length - 1]338: null;339340let paintEvent: IPaintEvent;341if (lastPaintEvent?.timestamp === timestamp) {342paintEvent = lastPaintEvent;343} else {344for (let i = this.paintEvents.length - 1; i >= 0; i -= 1) {345const paint = this.paintEvents[i];346if (!paint) continue;347if (paint.timestamp === timestamp) {348paintEvent = paint;349break;350}351}352}353354if (paintEvent) {355const events = paintEvent.changeEvents;356events.push(event);357358// if events are out of order, set the index of paints back to this index359if (events.length > 1 && events[events.length - 2].eventIndex > event.eventIndex) {360events.sort((a, b) => {361if (a.frameIdPath === b.frameIdPath) {362return a.eventIndex - b.eventIndex;363}364return a.frameIdPath.localeCompare(b.frameIdPath);365});366367const paintIndex = this.paintEvents.indexOf(paintEvent);368if (paintIndex !== -1 && paintIndex < this.paintEventsLoadedIdx)369this.paintEventsLoadedIdx = paintIndex - 1;370}371} else {372paintEvent = {373changeEvents: [event],374timestamp,375commandId,376};377378const index = this.paintEvents.length;379this.paintEvents.push(paintEvent);380381const tick = new ReplayTick(this, 'paint', index, commandId, timestamp);382this.ticks.push(tick);383if (isMainFrame && action === DomActionType.newDocument) {384tick.isNewDocumentTick = true;385tick.documentOrigin = textContent;386}387388if (lastPaintEvent && lastPaintEvent.timestamp >= timestamp) {389console.log('Need to resort paint events - received out of order');390391this.paintEvents.sort((a, b) => a.timestamp - b.timestamp);392393for (const t of this.ticks) {394if (t.eventType !== 'paint') continue;395const newIndex = this.paintEvents.findIndex(x => x.timestamp === t.timestamp);396if (newIndex >= 0 && t.eventTypeTick !== newIndex) {397if (this.paintEventsLoadedIdx >= newIndex) this.paintEventsLoadedIdx = newIndex - 1;398t.eventTypeTick = newIndex;399}400}401}402}403}404405public sortTicks() {406for (const tick of this.ticks) {407tick.updateDuration(this.replayTime);408}409410// The ticks can get out of order when they sync from browser -> db -> replay, so need to be resorted411412this.ticks.sort((a, b) => {413return a.playbarOffsetPercent - b.playbarOffsetPercent;414});415416let prev: ReplayTick;417for (const tick of this.ticks) {418if (prev && prev.playbarOffsetPercent >= tick.playbarOffsetPercent) {419tick.playbarOffsetPercent = prev.playbarOffsetPercent + 0.01;420}421prev = tick;422}423424let lastPaintEventIdx: number = null;425let lastScrollEventTick: number = null;426let lastFocusEventTick: number = null;427let lastMouseEventTick: number = null;428let lastSelectedNodeIds: { frameIdPath: string; nodeIds: number[] } = null;429let documentLoadPaintIndex: number = null;430let documentOrigin = this.startOrigin;431for (const tick of this.ticks) {432// if new doc, reset the markers433if (tick.isNewDocumentTick) {434lastFocusEventTick = null;435lastScrollEventTick = null;436lastPaintEventIdx = tick.eventTypeTick;437documentLoadPaintIndex = tick.eventTypeTick;438documentOrigin = tick.documentOrigin;439lastMouseEventTick = null;440lastSelectedNodeIds = null;441}442switch (tick.eventType) {443case 'command':444const command = this.commandsById.get(tick.commandId);445if (command.resultNodeIds) {446lastSelectedNodeIds = {447nodeIds: command.resultNodeIds,448frameIdPath: command.frameIdPath,449};450}451break;452case 'focus':453lastFocusEventTick = tick.eventTypeTick;454const focusEvent = this.focusEventsByTick[tick.eventTypeTick];455if (focusEvent.event === 0 && focusEvent.targetNodeId) {456lastSelectedNodeIds = {457nodeIds: [focusEvent.targetNodeId],458frameIdPath: focusEvent.frameIdPath,459};460} else if (focusEvent.event === 1) {461lastSelectedNodeIds = null;462}463464break;465case 'paint':466lastPaintEventIdx = tick.eventTypeTick;467break;468case 'scroll':469lastScrollEventTick = tick.eventTypeTick;470break;471case 'mouse':472lastMouseEventTick = tick.eventTypeTick;473const mouseEvent = this.mouseEventsByTick[tick.eventTypeTick];474if (mouseEvent.event === 1 && mouseEvent.targetNodeId) {475lastSelectedNodeIds = {476nodeIds: [mouseEvent.targetNodeId],477frameIdPath: mouseEvent.frameIdPath,478};479} else if (mouseEvent.event === 2) {480lastSelectedNodeIds = null;481}482break;483}484485tick.focusEventTick = lastFocusEventTick;486tick.scrollEventTick = lastScrollEventTick;487tick.mouseEventTick = lastMouseEventTick;488tick.paintEventIdx = lastPaintEventIdx;489tick.documentLoadPaintIndex = documentLoadPaintIndex;490tick.documentOrigin = documentOrigin;491tick.highlightNodeIds = lastSelectedNodeIds;492493if (tick.eventType === 'init' || lastPaintEventIdx === null) {494tick.documentLoadPaintIndex = -1;495tick.documentOrigin = this.startOrigin;496tick.paintEventIdx = -1;497}498}499this.checkBroadcast();500}501502public checkBroadcast() {503clearTimeout(this.broadcastTimer);504505const shouldBroadcast =506!this.lastBroadcast || new Date().getTime() - this.lastBroadcast.getTime() > 500;507508// if we haven't updated in 500ms, do so now509if (shouldBroadcast) {510setImmediate(this.broadcast.bind(this));511return;512}513514this.broadcastTimer = setTimeout(this.broadcast.bind(this), 50);515}516517private broadcast() {518this.lastBroadcast = new Date();519this.emit('tick:changes');520}521}522523interface IDomEvent {524commandId: number;525timestamp: number;526}527528529