Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/Session.ts
1029 views
1
import { v1 as uuidv1 } from 'uuid';
2
import Log from '@secret-agent/commons/Logger';
3
import RequestSession, {
4
IRequestSessionHttpErrorEvent,
5
IRequestSessionRequestEvent,
6
IRequestSessionResponseEvent,
7
IResourceStateChangeEvent,
8
ISocketEvent,
9
} from '@secret-agent/mitm/handlers/RequestSession';
10
import IPuppetContext, { IPuppetContextEvents } from '@secret-agent/interfaces/IPuppetContext';
11
import IUserProfile from '@secret-agent/interfaces/IUserProfile';
12
import { IPuppetPage } from '@secret-agent/interfaces/IPuppetPage';
13
import IBrowserEngine from '@secret-agent/interfaces/IBrowserEngine';
14
import IConfigureSessionOptions from '@secret-agent/interfaces/IConfigureSessionOptions';
15
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
16
import ICoreEventPayload from '@secret-agent/interfaces/ICoreEventPayload';
17
import ISessionMeta from '@secret-agent/interfaces/ISessionMeta';
18
import { IBoundLog } from '@secret-agent/interfaces/ILog';
19
import { MitmProxy } from '@secret-agent/mitm/index';
20
import IViewport from '@secret-agent/interfaces/IViewport';
21
import IJsPathResult from '@secret-agent/interfaces/IJsPathResult';
22
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
23
import IGeolocation from '@secret-agent/interfaces/IGeolocation';
24
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
25
import SessionState from './SessionState';
26
import AwaitedEventListener from './AwaitedEventListener';
27
import GlobalPool from './GlobalPool';
28
import Tab from './Tab';
29
import UserProfile from './UserProfile';
30
import InjectedScripts from './InjectedScripts';
31
import CommandRecorder from './CommandRecorder';
32
import DetachedTabState from './DetachedTabState';
33
import CorePlugins from './CorePlugins';
34
import { IOutputChangeRecord } from '../models/OutputTable';
35
36
const { log } = Log(module);
37
38
export default class Session extends TypedEventEmitter<{
39
closing: void;
40
closed: void;
41
'all-tabs-closed': void;
42
'awaited-event': ICoreEventPayload;
43
}> {
44
private static readonly byId: { [id: string]: Session } = {};
45
46
public awaitedEventListener: AwaitedEventListener;
47
public readonly id: string;
48
public readonly baseDir: string;
49
public browserEngine: IBrowserEngine;
50
public plugins: CorePlugins;
51
52
public viewport: IViewport;
53
public timezoneId: string;
54
public locale: string;
55
public geolocation: IGeolocation;
56
57
public upstreamProxyUrl: string | null;
58
public readonly mitmRequestSession: RequestSession;
59
public sessionState: SessionState;
60
public browserContext?: IPuppetContext;
61
public userProfile?: IUserProfile;
62
63
public tabsById = new Map<number, Tab>();
64
65
public get isClosing() {
66
return this._isClosing;
67
}
68
69
public mitmErrorsByUrl = new Map<
70
string,
71
{
72
resourceId: number;
73
event: IRequestSessionHttpErrorEvent;
74
}[]
75
>();
76
77
protected readonly logger: IBoundLog;
78
79
private hasLoadedUserProfile = false;
80
private commandRecorder: CommandRecorder;
81
private isolatedMitmProxy?: MitmProxy;
82
private _isClosing = false;
83
private detachedTabsById = new Map<number, Tab>();
84
private eventSubscriber = new EventSubscriber();
85
86
private tabIdCounter = 0;
87
private frameIdCounter = 0;
88
89
constructor(readonly options: ISessionCreateOptions) {
90
super();
91
this.id = uuidv1();
92
Session.byId[this.id] = this;
93
const providedOptions = { ...options };
94
this.logger = log.createChild(module, { sessionId: this.id });
95
this.awaitedEventListener = new AwaitedEventListener(this);
96
97
const {
98
browserEmulatorId,
99
humanEmulatorId,
100
dependencyMap,
101
corePluginPaths,
102
userProfile,
103
userAgent,
104
} = options;
105
106
const userAgentSelector = userAgent ?? userProfile?.userAgentString;
107
this.plugins = new CorePlugins(
108
{
109
userAgentSelector,
110
browserEmulatorId,
111
humanEmulatorId,
112
dependencyMap,
113
corePluginPaths,
114
deviceProfile: userProfile?.deviceProfile,
115
},
116
this.logger,
117
);
118
119
this.browserEngine = this.plugins.browserEngine;
120
121
this.userProfile = options.userProfile;
122
this.upstreamProxyUrl = options.upstreamProxyUrl;
123
this.geolocation = options.geolocation;
124
125
this.plugins.configure(options);
126
this.timezoneId = options.timezoneId || '';
127
this.viewport =
128
options.viewport ||
129
({
130
positionX: 0,
131
positionY: 0,
132
screenWidth: 1440,
133
screenHeight: 900,
134
width: 1440,
135
height: 900,
136
deviceScaleFactor: 1,
137
} as IViewport);
138
139
this.baseDir = GlobalPool.sessionsDir;
140
this.sessionState = new SessionState(
141
this.baseDir,
142
this.id,
143
options.sessionName,
144
options.scriptInstanceMeta,
145
this.viewport,
146
);
147
this.sessionState.recordSession({
148
browserEmulatorId: this.plugins.browserEmulator.id,
149
browserVersion: this.browserEngine.fullVersion,
150
humanEmulatorId: this.plugins.humanEmulator.id,
151
locale: options.locale,
152
timezoneId: options.timezoneId,
153
sessionOptions: providedOptions,
154
});
155
this.mitmRequestSession = new RequestSession(this.id, this.plugins, this.upstreamProxyUrl);
156
this.commandRecorder = new CommandRecorder(this, this, null, null, [
157
this.configure,
158
this.detachTab,
159
this.exportUserProfile,
160
]);
161
}
162
163
public getTab(id: number): Tab {
164
return this.tabsById.get(id) ?? this.detachedTabsById.get(id);
165
}
166
167
public async configure(options: IConfigureSessionOptions) {
168
if (options.upstreamProxyUrl !== undefined) {
169
this.upstreamProxyUrl = options.upstreamProxyUrl;
170
this.mitmRequestSession.upstreamProxyUrl = options.upstreamProxyUrl;
171
}
172
if (options.blockedResourceTypes !== undefined) {
173
for (const tab of this.tabsById.values()) {
174
await tab.setBlockedResourceTypes(options.blockedResourceTypes);
175
}
176
}
177
178
if (options.userProfile !== undefined) {
179
this.userProfile = options.userProfile;
180
}
181
this.plugins.configure(options);
182
}
183
184
public async detachTab(
185
sourceTab: Tab,
186
callsite: string,
187
key?: string,
188
): Promise<{
189
detachedTab: Tab;
190
detachedState: DetachedTabState;
191
prefetchedJsPaths: IJsPathResult[];
192
}> {
193
const [detachedState, page] = await Promise.all([
194
sourceTab.createDetachedState(),
195
this.browserContext.newPage({
196
runPageScripts: false,
197
}),
198
]);
199
const jsPathCalls = this.sessionState.findDetachedJsPathCalls(callsite, key);
200
await Promise.all([
201
page.setNetworkRequestInterceptor(detachedState.mockNetworkRequests.bind(detachedState)),
202
page.setJavaScriptEnabled(false),
203
]);
204
const newTab = Tab.create(this, page, true, sourceTab);
205
206
await detachedState.restoreDomIntoTab(newTab);
207
await newTab.isReady;
208
209
this.sessionState.captureTab(
210
newTab.id,
211
page.id,
212
page.devtoolsSession.id,
213
sourceTab.id,
214
detachedState.detachedAtCommandId,
215
);
216
this.detachedTabsById.set(newTab.id, newTab);
217
newTab.once('close', () => {
218
if (newTab.mainFrameEnvironment.jsPath.hasNewExecJsPathHistory) {
219
this.sessionState.recordDetachedJsPathCalls(
220
newTab.mainFrameEnvironment.jsPath.execHistory,
221
callsite,
222
key,
223
);
224
}
225
226
this.detachedTabsById.delete(newTab.id);
227
});
228
229
const prefetches = await newTab.mainFrameEnvironment.prefetchExecJsPaths(jsPathCalls);
230
return { detachedTab: newTab, detachedState, prefetchedJsPaths: prefetches };
231
}
232
233
public getMitmProxy(): { address: string; password?: string } {
234
return {
235
address: this.isolatedMitmProxy ? `localhost:${this.isolatedMitmProxy.port}` : null,
236
password: this.isolatedMitmProxy ? null : this.id,
237
};
238
}
239
240
public async registerWithMitm(
241
sharedMitmProxy: MitmProxy,
242
doesPuppetSupportBrowserContextProxy: boolean,
243
): Promise<void> {
244
let mitmProxy = sharedMitmProxy;
245
if (doesPuppetSupportBrowserContextProxy) {
246
this.isolatedMitmProxy = await MitmProxy.start(
247
GlobalPool.localProxyPortStart,
248
GlobalPool.sessionsDir,
249
);
250
mitmProxy = this.isolatedMitmProxy;
251
}
252
253
mitmProxy.registerSession(this.mitmRequestSession, !!this.isolatedMitmProxy);
254
}
255
256
public async initialize(context: IPuppetContext) {
257
this.browserContext = context;
258
259
this.eventSubscriber.on(context, 'devtools-message', this.onDevtoolsMessage.bind(this));
260
261
if (this.userProfile) {
262
await UserProfile.installCookies(this);
263
}
264
265
context.defaultPageInitializationFn = InjectedScripts.install;
266
267
const requestSession = this.mitmRequestSession;
268
this.eventSubscriber.on(requestSession, 'request', this.onMitmRequest.bind(this));
269
this.eventSubscriber.on(requestSession, 'response', this.onMitmResponse.bind(this));
270
this.eventSubscriber.on(requestSession, 'http-error', this.onMitmError.bind(this));
271
this.eventSubscriber.on(requestSession, 'resource-state', this.onResourceStates.bind(this));
272
this.eventSubscriber.on(requestSession, 'socket-close', this.onSocketClose.bind(this));
273
this.eventSubscriber.on(requestSession, 'socket-connect', this.onSocketConnect.bind(this));
274
await this.plugins.onHttpAgentInitialized(requestSession.requestAgent);
275
}
276
277
public nextTabId(): number {
278
return (this.tabIdCounter += 1);
279
}
280
281
public nextFrameId(): number {
282
return (this.frameIdCounter += 1);
283
}
284
285
public exportUserProfile(): Promise<IUserProfile> {
286
return UserProfile.export(this);
287
}
288
289
public async createTab() {
290
const page = await this.newPage();
291
292
// if first tab, install session storage
293
if (!this.hasLoadedUserProfile && this.userProfile?.storage) {
294
await UserProfile.installStorage(this, page);
295
this.hasLoadedUserProfile = true;
296
}
297
298
const tab = Tab.create(this, page);
299
this.sessionState.captureTab(tab.id, page.id, page.devtoolsSession.id);
300
this.registerTab(tab, page);
301
await tab.isReady;
302
return tab;
303
}
304
305
public async close() {
306
delete Session.byId[this.id];
307
if (this._isClosing) return;
308
this.emit('closing');
309
this._isClosing = true;
310
const start = log.info('Session.Closing', {
311
sessionId: this.id,
312
});
313
314
try {
315
this.awaitedEventListener.close();
316
const promises: Promise<any>[] = [];
317
for (const tab of this.tabsById.values()) {
318
promises.push(tab.close());
319
}
320
for (const tab of this.detachedTabsById.values()) {
321
promises.push(tab.close());
322
}
323
await Promise.all(promises);
324
this.mitmRequestSession.close();
325
if (this.isolatedMitmProxy) this.isolatedMitmProxy.close();
326
} catch (error) {
327
log.error('Session.CloseMitmError', { error, sessionId: this.id });
328
}
329
330
try {
331
await this.browserContext?.close();
332
} catch (error) {
333
log.error('Session.CloseBrowserContextError', { error, sessionId: this.id });
334
}
335
log.stats('Session.Closed', {
336
sessionId: this.id,
337
parentLogId: start,
338
});
339
this.commandRecorder.clear();
340
this.eventSubscriber.close();
341
342
this.emit('closed');
343
// should go last so we can capture logs
344
this.sessionState.close();
345
}
346
347
public onAwaitedEvent(payload: ICoreEventPayload) {
348
this.emit('awaited-event', payload);
349
}
350
351
public recordOutput(changes: IOutputChangeRecord[]) {
352
this.sessionState.recordOutputChanges(changes);
353
}
354
355
private onDevtoolsMessage(event: IPuppetContextEvents['devtools-message']) {
356
this.sessionState.captureDevtoolsMessage(event);
357
}
358
359
private onMitmRequest(event: IRequestSessionRequestEvent) {
360
// don't know the tab id at this point
361
this.sessionState.captureResource(null, event, false);
362
}
363
364
private onMitmResponse(event: IRequestSessionResponseEvent) {
365
const tabId = this.mitmRequestSession.browserRequestMatcher.requestIdToTabId.get(
366
event.browserRequestId,
367
);
368
let tab = this.tabsById.get(tabId);
369
if (!tab && !tabId) {
370
// if we can't place it, just use the first active tab
371
for (const next of this.tabsById.values()) {
372
tab = next;
373
if (!next.isClosing) break;
374
}
375
}
376
377
const resource = this.sessionState.captureResource(tab?.id ?? tabId, event, true);
378
if (!event.didBlockResource) {
379
tab?.emit('resource', resource);
380
}
381
tab?.checkForResolvedNavigation(event.browserRequestId, resource);
382
}
383
384
private onMitmError(event: IRequestSessionHttpErrorEvent) {
385
const { request } = event;
386
let tabId = this.mitmRequestSession.browserRequestMatcher.requestIdToTabId.get(
387
request.browserRequestId,
388
);
389
const url = request.request?.url;
390
const isDocument = request?.resourceType === 'Document';
391
if (isDocument && !tabId) {
392
for (const tab of this.tabsById.values()) {
393
const isMatch = tab.frameWithPendingNavigation(
394
request.browserRequestId,
395
url,
396
request.response?.url,
397
);
398
if (isMatch) {
399
tabId = tab.id;
400
break;
401
}
402
}
403
}
404
405
// record errors
406
const resource = this.sessionState.captureResourceError(tabId, request, event.error);
407
if (!request.browserRequestId && url) {
408
const existing = this.mitmErrorsByUrl.get(url) ?? [];
409
existing.push({
410
resourceId: resource.id,
411
event,
412
});
413
this.mitmErrorsByUrl.set(url, existing);
414
}
415
416
if (tabId && isDocument) {
417
const tab = this.tabsById.get(tabId);
418
tab?.checkForResolvedNavigation(request.browserRequestId, resource, event.error);
419
}
420
}
421
422
private onResourceStates(event: IResourceStateChangeEvent) {
423
this.sessionState.captureResourceState(event.context.id, event.context.stateChanges);
424
}
425
426
private onSocketClose(event: ISocketEvent) {
427
this.sessionState.captureSocketEvent(event);
428
}
429
430
private onSocketConnect(event: ISocketEvent) {
431
this.sessionState.captureSocketEvent(event);
432
}
433
434
private async onNewTab(
435
parentTab: Tab,
436
page: IPuppetPage,
437
openParams: { url: string; windowName: string } | null,
438
) {
439
const tab = Tab.create(this, page, false, parentTab, {
440
...openParams,
441
loaderId: page.mainFrame.isDefaultUrl ? null : page.mainFrame.activeLoader.id,
442
});
443
this.sessionState.captureTab(tab.id, page.id, page.devtoolsSession.id, parentTab.id);
444
this.registerTab(tab, page);
445
446
await tab.isReady;
447
448
parentTab.emit('child-tab-created', tab);
449
return tab;
450
}
451
452
private registerTab(tab: Tab, page: IPuppetPage) {
453
const id = tab.id;
454
this.tabsById.set(id, tab);
455
tab.on('close', () => {
456
this.tabsById.delete(id);
457
if (this.tabsById.size === 0 && this.detachedTabsById.size === 0) {
458
this.emit('all-tabs-closed');
459
}
460
});
461
page.popupInitializeFn = this.onNewTab.bind(this, tab);
462
return tab;
463
}
464
465
private async newPage() {
466
if (this._isClosing) throw new Error('Cannot create tab, shutting down');
467
return await this.browserContext.newPage();
468
}
469
470
public static get(sessionId: string): Session {
471
if (!sessionId) return null;
472
return this.byId[sessionId];
473
}
474
475
public static getTab(meta: ISessionMeta): Tab | undefined {
476
if (!meta) return undefined;
477
const session = this.get(meta.sessionId);
478
if (!session) return undefined;
479
return session.tabsById.get(meta.tabId) ?? session.detachedTabsById.get(meta.tabId);
480
}
481
482
public static sessionsWithBrowserEngine(
483
isEngineMatch: (engine: IBrowserEngine) => boolean,
484
): Session[] {
485
return Object.values(this.byId).filter(x => isEngineMatch(x.browserEngine));
486
}
487
}
488
489