Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/SessionState.ts
1029 views
1
import * as fs from 'fs';
2
import {
3
IRequestSessionRequestEvent,
4
IRequestSessionResponseEvent,
5
ISocketEvent,
6
} from '@secret-agent/mitm/handlers/RequestSession';
7
import IWebsocketMessage from '@secret-agent/interfaces/IWebsocketMessage';
8
import IResourceMeta from '@secret-agent/interfaces/IResourceMeta';
9
import ICommandMeta from '@secret-agent/interfaces/ICommandMeta';
10
import { IBoundLog } from '@secret-agent/interfaces/ILog';
11
import Log, { ILogEntry, LogEvents, loggerSessionIdNames } from '@secret-agent/commons/Logger';
12
import IViewport from '@secret-agent/interfaces/IViewport';
13
import INavigation, { LoadStatus } from '@secret-agent/interfaces/INavigation';
14
import IScriptInstanceMeta from '@secret-agent/interfaces/IScriptInstanceMeta';
15
import IWebsocketResourceMessage from '@secret-agent/interfaces/IWebsocketResourceMessage';
16
import type { IPuppetContextEvents } from '@secret-agent/interfaces/IPuppetContext';
17
import ResourceState from '@secret-agent/mitm/interfaces/ResourceState';
18
import { IScrollEvent } from '@secret-agent/interfaces/IScrollEvent';
19
import { IFocusEvent } from '@secret-agent/interfaces/IFocusEvent';
20
import { IMouseEvent } from '@secret-agent/interfaces/IMouseEvent';
21
import { IDomChangeEvent } from '@secret-agent/interfaces/IDomChangeEvent';
22
import injectedSourceUrl from '@secret-agent/interfaces/injectedSourceUrl';
23
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
24
import ResourcesTable from '../models/ResourcesTable';
25
import SessionsDb from '../dbs/SessionsDb';
26
import SessionDb from '../dbs/SessionDb';
27
import { IJsPathHistory } from './JsPath';
28
import { IOutputChangeRecord } from '../models/OutputTable';
29
import FrameEnvironment from './FrameEnvironment';
30
31
const { log } = Log(module);
32
33
export default class SessionState {
34
public static registry = new Map<string, SessionState>();
35
public readonly commands: ICommandMeta[] = [];
36
public get lastCommand(): ICommandMeta | undefined {
37
if (this.commands.length === 0) return;
38
return this.commands[this.commands.length - 1];
39
}
40
41
public readonly sessionId: string;
42
43
public viewport: IViewport;
44
public readonly db: SessionDb;
45
46
public nextCommandMeta: { commandId: number; startDate: Date; sendDate: Date };
47
48
private readonly sessionName: string;
49
private readonly scriptInstanceMeta: IScriptInstanceMeta;
50
private readonly createDate = new Date();
51
private readonly resourcesById = new Map<number, IResourceMeta>();
52
private readonly websocketMessages: IWebsocketResourceMessage[] = [];
53
private websocketListeners: {
54
[resourceId: string]: ((msg: IWebsocketResourceMessage) => any)[];
55
} = {};
56
57
private readonly logger: IBoundLog;
58
59
private browserRequestIdToResources: {
60
[browserRequestId: string]: { resourceId: number; url: string }[];
61
} = {};
62
63
private readonly sessionsDirectory: string;
64
private lastErrorTime?: Date;
65
private closeDate?: Date;
66
private lastNavigationTime?: Date;
67
private hasLoadedAnyPage = false;
68
69
private isClosing = false;
70
71
private websocketMessageIdCounter = 0;
72
73
private readonly logSubscriptionId: number;
74
75
constructor(
76
sessionsDirectory: string,
77
sessionId: string,
78
sessionName: string | null,
79
scriptInstanceMeta: IScriptInstanceMeta,
80
viewport: IViewport,
81
) {
82
this.sessionId = sessionId;
83
this.sessionName = sessionName;
84
this.scriptInstanceMeta = scriptInstanceMeta;
85
this.viewport = viewport;
86
this.logger = log.createChild(module, {
87
sessionId,
88
});
89
SessionState.registry.set(sessionId, this);
90
91
fs.mkdirSync(sessionsDirectory, { recursive: true });
92
93
this.db = new SessionDb(sessionsDirectory, sessionId);
94
this.sessionsDirectory = sessionsDirectory;
95
if (scriptInstanceMeta) {
96
const sessionsDb = SessionsDb.find(sessionsDirectory);
97
const sessionsTable = sessionsDb.sessions;
98
sessionsTable.insert(
99
sessionId,
100
sessionName,
101
this.createDate.getTime(),
102
scriptInstanceMeta.id,
103
scriptInstanceMeta.entrypoint,
104
scriptInstanceMeta.startDate,
105
);
106
}
107
108
loggerSessionIdNames.set(sessionId, sessionName);
109
110
this.logSubscriptionId = LogEvents.subscribe(this.onLogEvent.bind(this));
111
}
112
113
public recordSession(options: {
114
browserEmulatorId: string;
115
browserVersion: string;
116
humanEmulatorId: string;
117
timezoneId?: string;
118
locale?: string;
119
sessionOptions: ISessionCreateOptions;
120
}) {
121
const { sessionName, scriptInstanceMeta, ...optionsToStore } = options.sessionOptions;
122
this.db.session.insert(
123
this.sessionId,
124
this.sessionName,
125
options.browserEmulatorId,
126
options.browserVersion,
127
options.humanEmulatorId,
128
this.createDate,
129
this.scriptInstanceMeta?.id,
130
this.scriptInstanceMeta?.entrypoint,
131
this.scriptInstanceMeta?.startDate,
132
options.timezoneId,
133
this.viewport,
134
options.locale,
135
optionsToStore,
136
);
137
}
138
139
public recordCommandStart(commandMeta: ICommandMeta) {
140
this.commands.push(commandMeta);
141
this.db.commands.insert(commandMeta);
142
}
143
144
public recordCommandFinished(commandMeta: ICommandMeta) {
145
this.db.commands.insert(commandMeta);
146
}
147
148
public onWebsocketMessages(
149
resourceId: number,
150
listenerFn: (message: IWebsocketMessage) => any,
151
): void {
152
if (!this.websocketListeners[resourceId]) {
153
this.websocketListeners[resourceId] = [];
154
}
155
this.websocketListeners[resourceId].push(listenerFn);
156
// push all existing
157
for (const message of this.websocketMessages) {
158
if (message.resourceId === resourceId) {
159
listenerFn(message);
160
}
161
}
162
}
163
164
public stopWebsocketMessages(
165
resourceId: string,
166
listenerFn: (message: IWebsocketMessage) => any,
167
): void {
168
const listeners = this.websocketListeners[resourceId];
169
if (!listeners) return;
170
const idx = listeners.indexOf(listenerFn);
171
if (idx >= 0) listeners.splice(idx, 1);
172
}
173
174
public captureWebsocketMessage(event: {
175
browserRequestId: string;
176
isFromServer: boolean;
177
message: string | Buffer;
178
}): IWebsocketResourceMessage | undefined {
179
const { browserRequestId, isFromServer, message } = event;
180
const resources = this.browserRequestIdToResources[browserRequestId];
181
if (!resources?.length) {
182
this.logger.error(`CaptureWebsocketMessageError.UnregisteredResource`, {
183
browserRequestId,
184
message,
185
});
186
return;
187
}
188
189
const finalRedirect = resources[resources.length - 1];
190
191
const resourceMessage = {
192
resourceId: finalRedirect.resourceId,
193
message,
194
messageId: (this.websocketMessageIdCounter += 1),
195
source: isFromServer ? 'server' : 'client',
196
} as IWebsocketResourceMessage;
197
198
this.websocketMessages.push(resourceMessage);
199
this.db.websocketMessages.insert(this.lastCommand?.id, resourceMessage);
200
201
const listeners = this.websocketListeners[resourceMessage.resourceId];
202
if (listeners) {
203
for (const listener of listeners) {
204
listener(resourceMessage);
205
}
206
}
207
return resourceMessage;
208
}
209
210
public captureResourceState(id: number, state: Map<ResourceState, Date>): void {
211
this.db.resourceStates.insert(id, state);
212
}
213
214
public captureResourceFailed(
215
tabId: number,
216
resourceFailedEvent: IRequestSessionResponseEvent,
217
error: Error,
218
): IResourceMeta {
219
const resourceId = resourceFailedEvent.id;
220
if (!resourceId) {
221
this.logger.warn('Session.FailedResourceWithoutId', {
222
resourceFailedEvent,
223
error,
224
});
225
return;
226
}
227
228
try {
229
const convertedMeta = this.resourceEventToMeta(tabId, resourceFailedEvent);
230
const resource = this.resourcesById.get(resourceId);
231
232
if (!resource) {
233
this.resourcesById.set(convertedMeta.id, convertedMeta);
234
this.db.resources.insert(tabId, convertedMeta, null, resourceFailedEvent, error);
235
return convertedMeta;
236
}
237
238
// if we already have this resource, we need to merge
239
const existingDbRecord = this.db.resources.get(resourceId);
240
241
existingDbRecord.type ??= convertedMeta.type;
242
resource.type ??= convertedMeta.type;
243
existingDbRecord.devtoolsRequestId ??= resourceFailedEvent.browserRequestId;
244
existingDbRecord.browserBlockedReason = resourceFailedEvent.browserBlockedReason;
245
existingDbRecord.browserCanceled = resourceFailedEvent.browserCanceled;
246
existingDbRecord.redirectedToUrl ??= resourceFailedEvent.redirectedToUrl;
247
existingDbRecord.statusCode ??= convertedMeta.response.statusCode;
248
existingDbRecord.statusMessage ??= convertedMeta.response.statusMessage;
249
existingDbRecord.browserLoadFailure = convertedMeta.response?.browserLoadFailure;
250
251
if (!resource.response) {
252
resource.response = convertedMeta.response ?? ({} as any);
253
}
254
255
if (convertedMeta.response.headers) {
256
const responseHeaders = JSON.stringify(convertedMeta.response.headers);
257
if (responseHeaders.length > existingDbRecord.responseHeaders?.length) {
258
existingDbRecord.responseHeaders = responseHeaders;
259
resource.response.headers = convertedMeta.response.headers;
260
}
261
}
262
if (resourceFailedEvent.responseOriginalHeaders) {
263
const responseHeaders = JSON.stringify(resourceFailedEvent.responseOriginalHeaders);
264
if (responseHeaders.length > existingDbRecord.responseOriginalHeaders?.length) {
265
existingDbRecord.responseOriginalHeaders = responseHeaders;
266
}
267
}
268
if (error) {
269
existingDbRecord.httpError = ResourcesTable.getErrorString(error);
270
}
271
272
resource.response.browserLoadFailure = convertedMeta.response?.browserLoadFailure;
273
274
this.db.resources.save(existingDbRecord);
275
return resource;
276
} catch (saveError) {
277
this.logger.warn('SessionState.captureResourceFailed::ErrorSaving', {
278
error: saveError,
279
resourceFailedEvent,
280
});
281
}
282
}
283
284
public captureResourceError(
285
tabId: number,
286
resourceEvent: IRequestSessionResponseEvent,
287
error: Error,
288
): IResourceMeta {
289
const resource = this.resourceEventToMeta(tabId, resourceEvent);
290
this.db.resources.insert(tabId, resource, null, resourceEvent, error);
291
292
if (!this.resourcesById.has(resource.id)) {
293
this.resourcesById.set(resource.id, resource);
294
}
295
return resource;
296
}
297
298
public captureResourceRequestId(
299
resourceId: number,
300
browserRequestId: string,
301
tabId: number,
302
): IResourceMeta {
303
const resource = this.resourcesById.get(resourceId);
304
if (resource) {
305
resource.tabId = tabId;
306
307
// NOTE: browserRequestId can be shared amongst redirects
308
this.browserRequestIdToResources[browserRequestId] ??= [];
309
this.browserRequestIdToResources[browserRequestId].push({
310
resourceId,
311
url: resource.url,
312
});
313
this.db.resources.updateResource(resourceId, { browserRequestId, tabId });
314
}
315
return resource;
316
}
317
318
public captureResource(
319
tabId: number,
320
resourceEvent: IRequestSessionResponseEvent | IRequestSessionRequestEvent,
321
isResponse: boolean,
322
): IResourceMeta {
323
const resource = this.resourceEventToMeta(tabId, resourceEvent);
324
const resourceResponseEvent = resourceEvent as IRequestSessionResponseEvent;
325
326
this.db.resources.insert(tabId, resource, resourceResponseEvent.body, resourceEvent);
327
328
if (isResponse) {
329
this.resourcesById.set(resource.id, resource);
330
}
331
return resource;
332
}
333
334
public getBrowserRequestResources(
335
browserRequestId: string,
336
): { resourceId: number; url: string }[] {
337
return this.browserRequestIdToResources[browserRequestId];
338
}
339
340
public resourceEventToMeta(
341
tabId: number,
342
resourceEvent: IRequestSessionResponseEvent | IRequestSessionRequestEvent,
343
): IResourceMeta {
344
const {
345
request,
346
response,
347
resourceType,
348
browserRequestId,
349
redirectedToUrl,
350
} = resourceEvent as IRequestSessionResponseEvent;
351
352
if (browserRequestId) {
353
// NOTE: browserRequestId can be shared amongst redirects
354
this.browserRequestIdToResources[browserRequestId] ??= [];
355
this.browserRequestIdToResources[browserRequestId].push({
356
resourceId: resourceEvent.id,
357
url: request.url,
358
});
359
}
360
361
const resource = {
362
id: resourceEvent.id,
363
tabId,
364
url: request.url,
365
receivedAtCommandId: this.lastCommand?.id,
366
type: resourceType,
367
isRedirect: !!redirectedToUrl,
368
documentUrl: resourceEvent.documentUrl,
369
request: {
370
...request,
371
postData: request.postData?.toString(),
372
},
373
} as IResourceMeta;
374
375
if (response?.statusCode || response?.browserServedFromCache || response?.browserLoadFailure) {
376
resource.response = response;
377
if (response.url) resource.url = response.url;
378
else resource.response.url = request.url;
379
}
380
381
return resource;
382
}
383
384
public getResourceLookupMap(tabId: number): { [method_url: string]: IResourceMeta[] } {
385
const result: { [method_url: string]: IResourceMeta[] } = {};
386
for (const resource of this.resourcesById.values()) {
387
if (resource.tabId === tabId) {
388
const key = `${resource.request.method}_${resource.request.url}`;
389
result[key] ??= [];
390
result[key].push(resource);
391
}
392
}
393
return result;
394
}
395
396
public getResources(tabId: number): IResourceMeta[] {
397
const resources: IResourceMeta[] = [];
398
for (const resource of this.resourcesById.values()) {
399
if (resource.tabId === tabId) resources.push(resource);
400
}
401
return resources;
402
}
403
404
public getResourceData(id: number, decompress: boolean): Promise<Buffer> {
405
return this.db.resources.getResourceBodyById(id, decompress);
406
}
407
408
public getResourceMeta(id: number): IResourceMeta {
409
return this.resourcesById.get(id);
410
}
411
412
/////// FRAMES ///////
413
414
public captureFrameDetails(frame: FrameEnvironment): void {
415
this.db.frames.insert({
416
id: frame.id,
417
tabId: frame.tab.id,
418
domNodeId: frame.domNodeId,
419
parentId: frame.parentId,
420
devtoolsFrameId: frame.devtoolsFrameId,
421
name: frame.puppetFrame.name,
422
securityOrigin: frame.securityOrigin,
423
startCommandId: frame.createdAtCommandId,
424
createdTimestamp: frame.createdTime.getTime(),
425
});
426
}
427
428
public captureError(tabId: number, frameId: number, source: string, error: Error): void {
429
this.db.pageLogs.insert(tabId, frameId, source, error.stack || String(error), new Date());
430
}
431
432
public captureLog(
433
tabId: number,
434
frameId: number,
435
consoleType: string,
436
message: string,
437
location?: string,
438
): void {
439
let level = 'info';
440
if (message.startsWith('ERROR:') && message.includes(injectedSourceUrl)) {
441
level = 'error';
442
}
443
this.logger[level]('Window.console', { message });
444
this.db.pageLogs.insert(tabId, frameId, consoleType, message, new Date(), location);
445
}
446
447
public onLogEvent(entry: ILogEntry): void {
448
if (entry.sessionId === this.sessionId || !entry.sessionId) {
449
if (entry.action === 'Window.runCommand') entry.data = { id: entry.data.id };
450
if (entry.action === 'Window.ranCommand') entry.data = null;
451
if (entry.level === 'error') {
452
this.lastErrorTime = entry.timestamp;
453
}
454
this.db.sessionLogs.insert(entry);
455
}
456
}
457
458
public close(): void {
459
if (this.isClosing) return;
460
this.isClosing = true;
461
this.logger.stats('SessionState.Closing');
462
this.closeDate = new Date();
463
this.db.session.close(this.sessionId, this.closeDate);
464
LogEvents.unsubscribe(this.logSubscriptionId);
465
loggerSessionIdNames.delete(this.sessionId);
466
this.db.flush();
467
this.db.close();
468
this.resourcesById.clear();
469
this.browserRequestIdToResources = {};
470
this.websocketListeners = {};
471
this.websocketMessages.length = 0;
472
SessionState.registry.delete(this.sessionId);
473
}
474
475
public recordNavigation(navigation: INavigation) {
476
this.db.frameNavigations.insert(navigation);
477
if (
478
navigation.stateChanges.has(LoadStatus.Load) ||
479
navigation.stateChanges.has(LoadStatus.ContentPaint)
480
) {
481
this.hasLoadedAnyPage = true;
482
}
483
for (const date of navigation.stateChanges.values()) {
484
if (date > this.lastNavigationTime) this.lastNavigationTime = date;
485
}
486
if (navigation.initiatedTime) this.lastNavigationTime = navigation.initiatedTime;
487
}
488
489
public checkForResponsive(): {
490
hasRecentErrors: boolean;
491
lastActivityDate: Date;
492
lastCommandName: string;
493
closeDate: Date | null;
494
} {
495
let lastSuccessDate = this.createDate;
496
497
if (!this.lastNavigationTime) {
498
const lastNavigation = this.db.frameNavigations.last();
499
if (lastNavigation && lastNavigation.initiatedTime) {
500
this.lastNavigationTime = new Date(
501
lastNavigation.loadTime ??
502
lastNavigation.contentPaintedTime ??
503
lastNavigation.initiatedTime,
504
);
505
this.hasLoadedAnyPage = !!lastNavigation.initiatedTime || !!lastNavigation.loadTime;
506
}
507
}
508
509
if (this.lastNavigationTime && this.lastNavigationTime > lastSuccessDate) {
510
lastSuccessDate = this.lastNavigationTime;
511
}
512
513
for (let i = this.commands.length - 1; i >= 0; i -= 1) {
514
const command = this.commands[i];
515
if (!command.endDate) continue;
516
const endDate = new Date(command.endDate);
517
if (
518
this.hasLoadedAnyPage &&
519
endDate > lastSuccessDate &&
520
!command.resultType?.includes('Error')
521
) {
522
lastSuccessDate = endDate;
523
break;
524
}
525
}
526
527
const hasRecentErrors = this.lastErrorTime >= lastSuccessDate;
528
529
const lastCommand = this.lastCommand;
530
let lastActivityDate = lastSuccessDate ? new Date(lastSuccessDate) : null;
531
let lastCommandName: string;
532
if (lastCommand) {
533
lastCommandName = lastCommand.name;
534
const commandDate = new Date(lastCommand.endDate ?? lastCommand.runStartDate);
535
if (commandDate > lastActivityDate) {
536
lastActivityDate = commandDate;
537
}
538
}
539
return {
540
hasRecentErrors,
541
lastActivityDate,
542
lastCommandName,
543
closeDate: this.closeDate,
544
};
545
}
546
547
public captureDomEvents(
548
tabId: number,
549
frameId: number,
550
domChanges: IDomChangeEvent[],
551
mouseEvents: IMouseEvent[],
552
focusEvents: IFocusEvent[],
553
scrollEvents: IScrollEvent[],
554
) {
555
let lastCommand = this.lastCommand;
556
if (!lastCommand) return; // nothing to store yet
557
for (const domChange of domChanges) {
558
lastCommand = this.getCommandForTimestamp(lastCommand, domChange[2]);
559
this.db.domChanges.insert(tabId, frameId, lastCommand.id, domChange);
560
}
561
562
for (const mouseEvent of mouseEvents) {
563
lastCommand = this.getCommandForTimestamp(lastCommand, mouseEvent[8]);
564
this.db.mouseEvents.insert(tabId, frameId, lastCommand.id, mouseEvent);
565
}
566
567
for (const focusEvent of focusEvents) {
568
lastCommand = this.getCommandForTimestamp(lastCommand, focusEvent[3]);
569
this.db.focusEvents.insert(tabId, frameId, lastCommand.id, focusEvent);
570
}
571
572
for (const scrollEvent of scrollEvents) {
573
lastCommand = this.getCommandForTimestamp(lastCommand, scrollEvent[2]);
574
this.db.scrollEvents.insert(tabId, frameId, lastCommand.id, scrollEvent);
575
}
576
}
577
578
public getCommandForTimestamp(lastCommand: ICommandMeta, timestamp: number): ICommandMeta {
579
let command = lastCommand;
580
if (command.runStartDate <= timestamp && command.endDate > timestamp) {
581
return command;
582
}
583
584
for (let i = this.commands.length - 1; i >= 0; i -= 1) {
585
command = this.commands[i];
586
if (command.runStartDate <= timestamp) break;
587
}
588
return command;
589
}
590
591
public captureDevtoolsMessage(event: IPuppetContextEvents['devtools-message']): void {
592
this.db.devtoolsMessages.insert(event);
593
}
594
595
public captureTab(
596
tabId: number,
597
pageId: string,
598
devtoolsSessionId: string,
599
parentTabId?: number,
600
detachedAtCommandId?: number,
601
): void {
602
this.db.tabs.insert(
603
tabId,
604
pageId,
605
devtoolsSessionId,
606
this.viewport,
607
parentTabId,
608
detachedAtCommandId,
609
);
610
}
611
612
public captureSocketEvent(socketEvent: ISocketEvent): void {
613
this.db.sockets.insert(socketEvent.socket);
614
}
615
616
/////// JsPath Calls
617
public findDetachedJsPathCalls(callsite: string, key?: string): IJsPathHistory[] {
618
const sessionsDb = SessionsDb.find(this.sessionsDirectory);
619
const detachedCalls = sessionsDb.detachedJsPathCalls.find(
620
this.scriptInstanceMeta,
621
callsite,
622
key,
623
);
624
if (detachedCalls?.execJsPathHistory) {
625
return JSON.parse(detachedCalls.execJsPathHistory);
626
}
627
return null;
628
}
629
630
public recordDetachedJsPathCalls(calls: IJsPathHistory[], callsite: string, key?: string): void {
631
if (!calls?.length) return;
632
const sessionsDb = SessionsDb.find(this.sessionsDirectory);
633
sessionsDb.detachedJsPathCalls.insert(
634
this.scriptInstanceMeta,
635
callsite,
636
calls,
637
new Date(),
638
key,
639
);
640
}
641
642
public recordOutputChanges(changes: IOutputChangeRecord[]) {
643
this.nextCommandMeta = null;
644
for (const change of changes) {
645
this.db.output.insert(change);
646
}
647
}
648
}
649
650