Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/replay/backend/api/ReplayTabState.ts
1030 views
1
import { EventEmitter } from 'events';
2
import ICommandWithResult from '~shared/interfaces/ICommandResult';
3
import {
4
IFocusRecord,
5
IFrontendMouseEvent,
6
IMouseEvent,
7
IScrollRecord,
8
ISessionTab,
9
} from '~shared/interfaces/ISaSession';
10
import ReplayTick, { IEventType } from '~backend/api/ReplayTick';
11
import IPaintEvent from '~shared/interfaces/IPaintEvent';
12
import {
13
DomActionType,
14
IDomChangeEvent,
15
IFrontendDomChangeEvent,
16
} from '~shared/interfaces/IDomChangeEvent';
17
import ITickState from '~shared/interfaces/ITickState';
18
import ReplayTime from '~backend/api/ReplayTime';
19
import getResolvable from '~shared/utils/promise';
20
21
export default class ReplayTabState extends EventEmitter {
22
public ticks: ReplayTick[] = [];
23
public readonly commandsById = new Map<number, ICommandWithResult>();
24
25
public tabId: number;
26
public detachedFromTabId: number;
27
public startOrigin: string;
28
public urlOrigin: string;
29
public viewportWidth: number;
30
public viewportHeight: number;
31
public currentPlaybarOffsetPct = 0;
32
public replayTime: ReplayTime;
33
public tabCreatedTime: number;
34
public hasAllData = false;
35
36
public get isActive() {
37
return this.listenerCount('tick:changes') > 0;
38
}
39
40
public get currentTick() {
41
return this.ticks[this.currentTickIdx];
42
}
43
44
public get nextTick() {
45
return this.ticks[this.currentTickIdx + 1];
46
}
47
48
public isReady = getResolvable<void>();
49
50
private readonly mouseEventsByTick: Record<number, IMouseEvent> = {};
51
private readonly scrollEventsByTick: Record<number, IScrollRecord> = {};
52
private readonly focusEventsByTick: Record<number, IFocusRecord> = {};
53
private readonly paintEvents: IPaintEvent[] = [];
54
55
private currentTickIdx = -1;
56
// put in placeholder
57
private paintEventsLoadedIdx = -1;
58
private broadcastTimer: NodeJS.Timer;
59
private lastBroadcast?: Date;
60
private eventRouter = {
61
'dom-changes': this.loadDomChange.bind(this),
62
'mouse-events': this.loadPageEvent.bind(this, 'mouse'),
63
'focus-events': this.loadPageEvent.bind(this, 'focus'),
64
'scroll-events': this.loadPageEvent.bind(this, 'scroll'),
65
commands: this.loadCommand.bind(this),
66
};
67
68
constructor(tabMeta: ISessionTab, replayTime: ReplayTime) {
69
super();
70
this.replayTime = replayTime;
71
this.tabCreatedTime = tabMeta.createdTime;
72
this.startOrigin = tabMeta.startOrigin;
73
this.viewportHeight = tabMeta.height;
74
this.viewportWidth = tabMeta.width;
75
this.detachedFromTabId = tabMeta.detachedFromTabId;
76
if (this.startOrigin) this.isReady.resolve();
77
this.tabId = tabMeta.tabId;
78
this.ticks.push(new ReplayTick(this, 'init', 0, -1, replayTime.start.getTime(), 'Load'));
79
}
80
81
public onApiFeed(eventName: string, event: any) {
82
const method = this.eventRouter[eventName];
83
if (method) method(event);
84
}
85
86
public getTickState() {
87
return {
88
currentTickOffset: this.currentPlaybarOffsetPct,
89
durationMillis: this.replayTime.millis,
90
ticks: this.ticks.filter(x => x.isMajor()).map(x => x.playbarOffsetPercent),
91
} as ITickState;
92
}
93
94
public transitionToPreviousTick() {
95
const prevTickIdx = this.currentTickIdx > 0 ? this.currentTickIdx - 1 : this.currentTickIdx;
96
return this.loadTick(prevTickIdx);
97
}
98
99
public transitionToNextTick() {
100
const result = this.loadTick(this.currentTickIdx + 1);
101
if (this.replayTime.close && this.hasAllData && this.currentTickIdx === this.ticks.length - 1) {
102
this.currentPlaybarOffsetPct = 100;
103
}
104
return result;
105
}
106
107
public setTickValue(playbarOffset: number, isReset = false) {
108
const ticks = this.ticks;
109
if (isReset) {
110
this.currentPlaybarOffsetPct = 0;
111
this.currentTickIdx = -1;
112
this.paintEventsLoadedIdx = -1;
113
}
114
if (!ticks.length || this.currentPlaybarOffsetPct === playbarOffset) return;
115
116
let newTickIdx = this.currentTickIdx;
117
// if going forward, load next ticks
118
if (playbarOffset > this.currentPlaybarOffsetPct) {
119
for (let i = this.currentTickIdx; i < ticks.length; i += 1) {
120
if (i < 0) continue;
121
if (ticks[i].playbarOffsetPercent > playbarOffset) break;
122
newTickIdx = i;
123
}
124
} else {
125
for (let i = this.currentTickIdx - 1; i >= 0; i -= 1) {
126
if (ticks[i].playbarOffsetPercent < playbarOffset) break;
127
newTickIdx = i;
128
}
129
}
130
131
return this.loadTick(newTickIdx, playbarOffset);
132
}
133
134
public setPaintIndex(newTick: ReplayTick) {
135
if (newTick.paintEventIdx === this.paintEventsLoadedIdx || newTick.paintEventIdx === null) {
136
return;
137
}
138
139
const isBackwards = newTick.paintEventIdx < this.paintEventsLoadedIdx;
140
141
let startIndex = this.paintEventsLoadedIdx + 1;
142
if (isBackwards) {
143
startIndex = newTick.documentLoadPaintIndex;
144
}
145
146
const changeEvents: IFrontendDomChangeEvent[] = [];
147
if (newTick.eventType === 'init' || (newTick.paintEventIdx === -1 && isBackwards)) {
148
startIndex = -1;
149
changeEvents.push({
150
action: DomActionType.newDocument,
151
textContent: this.startOrigin,
152
commandId: newTick.commandId,
153
} as any);
154
} else {
155
for (let i = startIndex; i <= newTick.paintEventIdx; i += 1) {
156
const paints = this.paintEvents[i];
157
if (!paints) {
158
console.log('Paint event not loaded!', i);
159
return;
160
}
161
if (
162
paints.changeEvents[0].frameIdPath === 'main' &&
163
paints.changeEvents[0].action === DomActionType.newDocument
164
) {
165
changeEvents.length = 0;
166
}
167
changeEvents.push(...paints.changeEvents);
168
}
169
}
170
171
console.log(
172
'Paint load. Current Idx=%s, Loading [%s->%s] (paints: %s, back? %s)',
173
this.paintEventsLoadedIdx,
174
startIndex,
175
newTick.paintEventIdx,
176
changeEvents.length,
177
isBackwards,
178
);
179
180
this.urlOrigin = newTick.documentOrigin;
181
this.paintEventsLoadedIdx = newTick.paintEventIdx;
182
return changeEvents;
183
}
184
185
public copyPaintEvents(
186
timestampRange: [number, number],
187
eventIndexRange: [number, number],
188
): IPaintEvent[] {
189
const [startTimestamp, endTimestamp] = timestampRange;
190
const [startIndex, endIndex] = eventIndexRange;
191
const paintEvents: IPaintEvent[] = [];
192
for (const paintEvent of this.paintEvents) {
193
if (paintEvent.timestamp >= startTimestamp && paintEvent.timestamp <= endTimestamp) {
194
paintEvents.push({
195
timestamp: paintEvent.timestamp,
196
commandId: paintEvent.commandId,
197
changeEvents: paintEvent.changeEvents.filter(x => {
198
if (x.timestamp === startTimestamp) {
199
return x.eventIndex >= startIndex;
200
}
201
if (x.timestamp === endTimestamp) {
202
return x.eventIndex <= endIndex;
203
}
204
return true;
205
}),
206
});
207
}
208
}
209
return paintEvents;
210
}
211
212
public loadTick(
213
newTickIdx: number,
214
specificPlaybarOffset?: number,
215
): [
216
IFrontendDomChangeEvent[],
217
{ frameIdPath: string; nodeIds: number[] },
218
IFrontendMouseEvent,
219
IScrollRecord,
220
] {
221
if (newTickIdx === this.currentTickIdx) return;
222
const newTick = this.ticks[newTickIdx];
223
224
// need to wait for load
225
if (!newTick) return;
226
if (!this.replayTime.close) {
227
// give ticks time to load. TODO: need a better strategy for this
228
if (new Date().getTime() - new Date(newTick.timestamp).getTime() < 2e3) return;
229
}
230
231
// console.log('Loading tick %s', newTickIdx);
232
233
const playbarOffset = specificPlaybarOffset ?? newTick.playbarOffsetPercent;
234
this.currentTickIdx = newTickIdx;
235
this.currentPlaybarOffsetPct = playbarOffset;
236
237
const paintEvents = this.setPaintIndex(newTick);
238
const mouseEvent = this.mouseEventsByTick[newTick.mouseEventTick];
239
const scrollEvent = this.scrollEventsByTick[newTick.scrollEventTick];
240
const nodesToHighlight = newTick.highlightNodeIds;
241
242
let frontendMouseEvent: IFrontendMouseEvent;
243
if (mouseEvent) {
244
frontendMouseEvent = {
245
frameIdPath: mouseEvent.frameIdPath,
246
pageX: mouseEvent.pageX,
247
pageY: mouseEvent.pageY,
248
offsetX: mouseEvent.offsetX,
249
offsetY: mouseEvent.offsetY,
250
targetNodeId: mouseEvent.targetNodeId,
251
buttons: mouseEvent.buttons,
252
viewportHeight: this.viewportHeight,
253
viewportWidth: this.viewportWidth,
254
};
255
}
256
257
return [paintEvents, nodesToHighlight, frontendMouseEvent, scrollEvent];
258
}
259
260
public loadCommand(command: ICommandWithResult) {
261
if (command.result && typeof command.result === 'string' && command.result.startsWith('"{')) {
262
try {
263
command.result = JSON.parse(command.result);
264
} catch (e) {
265
// didn't parse, just ignore
266
}
267
}
268
const existing = this.commandsById.get(command.id);
269
if (existing) {
270
Object.assign(existing, command);
271
} else {
272
const idx = this.commandsById.size;
273
this.commandsById.set(command.id, command);
274
const tick = new ReplayTick(
275
this,
276
'command',
277
idx,
278
command.id,
279
command.startDate,
280
command.label,
281
);
282
this.ticks.push(tick);
283
}
284
}
285
286
public loadPageEvent(eventType: IEventType, event: IDomEvent) {
287
let events: Record<number, IDomEvent>;
288
if (eventType === 'mouse') events = this.mouseEventsByTick;
289
if (eventType === 'focus') events = this.focusEventsByTick;
290
if (eventType === 'scroll') events = this.scrollEventsByTick;
291
292
events[event.timestamp] = event;
293
const tick = new ReplayTick(
294
this,
295
eventType,
296
event.timestamp,
297
event.commandId,
298
Number(event.timestamp),
299
);
300
this.ticks.push(tick);
301
}
302
303
public loadDetachedState(
304
detachedFromTabId: number,
305
paintEvents: IPaintEvent[],
306
timestamp: number,
307
commandId: number,
308
origin: string,
309
): void {
310
this.detachedFromTabId = detachedFromTabId;
311
const flatEvent = <IPaintEvent>{ changeEvents: [], commandId, timestamp };
312
for (const paintEvent of paintEvents) {
313
flatEvent.changeEvents.push(...paintEvent.changeEvents);
314
}
315
this.paintEvents.push(flatEvent);
316
this.startOrigin = origin;
317
const tick = new ReplayTick(this, 'paint', 0, commandId, timestamp);
318
tick.isNewDocumentTick = true;
319
tick.documentOrigin = origin;
320
this.ticks.push(tick);
321
this.isReady.resolve();
322
}
323
324
public loadDomChange(event: IDomChangeEvent) {
325
const { commandId, action, textContent, timestamp } = event;
326
327
// if this is a subframe without a frame, ignore it
328
if (!event.frameIdPath) return;
329
330
const isMainFrame = event.frameIdPath === 'main';
331
if (isMainFrame && event.action === DomActionType.newDocument && !this.startOrigin) {
332
this.startOrigin = event.textContent;
333
console.log('Got start origin for new tab', this.startOrigin);
334
this.isReady.resolve();
335
}
336
337
const lastPaintEvent = this.paintEvents.length
338
? this.paintEvents[this.paintEvents.length - 1]
339
: null;
340
341
let paintEvent: IPaintEvent;
342
if (lastPaintEvent?.timestamp === timestamp) {
343
paintEvent = lastPaintEvent;
344
} else {
345
for (let i = this.paintEvents.length - 1; i >= 0; i -= 1) {
346
const paint = this.paintEvents[i];
347
if (!paint) continue;
348
if (paint.timestamp === timestamp) {
349
paintEvent = paint;
350
break;
351
}
352
}
353
}
354
355
if (paintEvent) {
356
const events = paintEvent.changeEvents;
357
events.push(event);
358
359
// if events are out of order, set the index of paints back to this index
360
if (events.length > 1 && events[events.length - 2].eventIndex > event.eventIndex) {
361
events.sort((a, b) => {
362
if (a.frameIdPath === b.frameIdPath) {
363
return a.eventIndex - b.eventIndex;
364
}
365
return a.frameIdPath.localeCompare(b.frameIdPath);
366
});
367
368
const paintIndex = this.paintEvents.indexOf(paintEvent);
369
if (paintIndex !== -1 && paintIndex < this.paintEventsLoadedIdx)
370
this.paintEventsLoadedIdx = paintIndex - 1;
371
}
372
} else {
373
paintEvent = {
374
changeEvents: [event],
375
timestamp,
376
commandId,
377
};
378
379
const index = this.paintEvents.length;
380
this.paintEvents.push(paintEvent);
381
382
const tick = new ReplayTick(this, 'paint', index, commandId, timestamp);
383
this.ticks.push(tick);
384
if (isMainFrame && action === DomActionType.newDocument) {
385
tick.isNewDocumentTick = true;
386
tick.documentOrigin = textContent;
387
}
388
389
if (lastPaintEvent && lastPaintEvent.timestamp >= timestamp) {
390
console.log('Need to resort paint events - received out of order');
391
392
this.paintEvents.sort((a, b) => a.timestamp - b.timestamp);
393
394
for (const t of this.ticks) {
395
if (t.eventType !== 'paint') continue;
396
const newIndex = this.paintEvents.findIndex(x => x.timestamp === t.timestamp);
397
if (newIndex >= 0 && t.eventTypeTick !== newIndex) {
398
if (this.paintEventsLoadedIdx >= newIndex) this.paintEventsLoadedIdx = newIndex - 1;
399
t.eventTypeTick = newIndex;
400
}
401
}
402
}
403
}
404
}
405
406
public sortTicks() {
407
for (const tick of this.ticks) {
408
tick.updateDuration(this.replayTime);
409
}
410
411
// The ticks can get out of order when they sync from browser -> db -> replay, so need to be resorted
412
413
this.ticks.sort((a, b) => {
414
return a.playbarOffsetPercent - b.playbarOffsetPercent;
415
});
416
417
let prev: ReplayTick;
418
for (const tick of this.ticks) {
419
if (prev && prev.playbarOffsetPercent >= tick.playbarOffsetPercent) {
420
tick.playbarOffsetPercent = prev.playbarOffsetPercent + 0.01;
421
}
422
prev = tick;
423
}
424
425
let lastPaintEventIdx: number = null;
426
let lastScrollEventTick: number = null;
427
let lastFocusEventTick: number = null;
428
let lastMouseEventTick: number = null;
429
let lastSelectedNodeIds: { frameIdPath: string; nodeIds: number[] } = null;
430
let documentLoadPaintIndex: number = null;
431
let documentOrigin = this.startOrigin;
432
for (const tick of this.ticks) {
433
// if new doc, reset the markers
434
if (tick.isNewDocumentTick) {
435
lastFocusEventTick = null;
436
lastScrollEventTick = null;
437
lastPaintEventIdx = tick.eventTypeTick;
438
documentLoadPaintIndex = tick.eventTypeTick;
439
documentOrigin = tick.documentOrigin;
440
lastMouseEventTick = null;
441
lastSelectedNodeIds = null;
442
}
443
switch (tick.eventType) {
444
case 'command':
445
const command = this.commandsById.get(tick.commandId);
446
if (command.resultNodeIds) {
447
lastSelectedNodeIds = {
448
nodeIds: command.resultNodeIds,
449
frameIdPath: command.frameIdPath,
450
};
451
}
452
break;
453
case 'focus':
454
lastFocusEventTick = tick.eventTypeTick;
455
const focusEvent = this.focusEventsByTick[tick.eventTypeTick];
456
if (focusEvent.event === 0 && focusEvent.targetNodeId) {
457
lastSelectedNodeIds = {
458
nodeIds: [focusEvent.targetNodeId],
459
frameIdPath: focusEvent.frameIdPath,
460
};
461
} else if (focusEvent.event === 1) {
462
lastSelectedNodeIds = null;
463
}
464
465
break;
466
case 'paint':
467
lastPaintEventIdx = tick.eventTypeTick;
468
break;
469
case 'scroll':
470
lastScrollEventTick = tick.eventTypeTick;
471
break;
472
case 'mouse':
473
lastMouseEventTick = tick.eventTypeTick;
474
const mouseEvent = this.mouseEventsByTick[tick.eventTypeTick];
475
if (mouseEvent.event === 1 && mouseEvent.targetNodeId) {
476
lastSelectedNodeIds = {
477
nodeIds: [mouseEvent.targetNodeId],
478
frameIdPath: mouseEvent.frameIdPath,
479
};
480
} else if (mouseEvent.event === 2) {
481
lastSelectedNodeIds = null;
482
}
483
break;
484
}
485
486
tick.focusEventTick = lastFocusEventTick;
487
tick.scrollEventTick = lastScrollEventTick;
488
tick.mouseEventTick = lastMouseEventTick;
489
tick.paintEventIdx = lastPaintEventIdx;
490
tick.documentLoadPaintIndex = documentLoadPaintIndex;
491
tick.documentOrigin = documentOrigin;
492
tick.highlightNodeIds = lastSelectedNodeIds;
493
494
if (tick.eventType === 'init' || lastPaintEventIdx === null) {
495
tick.documentLoadPaintIndex = -1;
496
tick.documentOrigin = this.startOrigin;
497
tick.paintEventIdx = -1;
498
}
499
}
500
this.checkBroadcast();
501
}
502
503
public checkBroadcast() {
504
clearTimeout(this.broadcastTimer);
505
506
const shouldBroadcast =
507
!this.lastBroadcast || new Date().getTime() - this.lastBroadcast.getTime() > 500;
508
509
// if we haven't updated in 500ms, do so now
510
if (shouldBroadcast) {
511
setImmediate(this.broadcast.bind(this));
512
return;
513
}
514
515
this.broadcastTimer = setTimeout(this.broadcast.bind(this), 50);
516
}
517
518
private broadcast() {
519
this.lastBroadcast = new Date();
520
this.emit('tick:changes');
521
}
522
}
523
524
interface IDomEvent {
525
commandId: number;
526
timestamp: number;
527
}
528
529