Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/GlobalPool.ts
1029 views
1
import * as Fs from 'fs';
2
import * as Path from 'path';
3
import IResolvablePromise from '@secret-agent/interfaces/IResolvablePromise';
4
import { createPromise } from '@secret-agent/commons/utils';
5
import Log from '@secret-agent/commons/Logger';
6
import { MitmProxy } from '@secret-agent/mitm';
7
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
8
import Puppet from '@secret-agent/puppet';
9
import * as Os from 'os';
10
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
11
import IBrowserEngine from '@secret-agent/interfaces/IBrowserEngine';
12
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
13
import IPuppetLaunchArgs from '@secret-agent/interfaces/IPuppetLaunchArgs';
14
import SessionsDb from '../dbs/SessionsDb';
15
import Session from './Session';
16
import DevtoolsPreferences from './DevtoolsPreferences';
17
18
const { log } = Log(module);
19
let sessionsDir = process.env.SA_SESSIONS_DIR || Path.join(Os.tmpdir(), '.secret-agent'); // transferred to GlobalPool below class definition
20
const disableMitm = Boolean(JSON.parse(process.env.SA_DISABLE_MITM ?? 'false'));
21
22
export default class GlobalPool {
23
public static maxConcurrentAgentsCount = 10;
24
public static localProxyPortStart = 0;
25
public static get activeSessionCount() {
26
return this._activeSessionCount;
27
}
28
29
public static get hasAvailability() {
30
return this.activeSessionCount < GlobalPool.maxConcurrentAgentsCount;
31
}
32
33
public static events = new TypedEventEmitter<{
34
'browser-has-no-open-windows': { puppet: Puppet };
35
'all-browsers-closed': void;
36
}>();
37
38
private static isClosing = false;
39
private static defaultLaunchArgs: IPuppetLaunchArgs;
40
private static _activeSessionCount = 0;
41
private static puppets: Puppet[] = [];
42
private static mitmServer: MitmProxy;
43
private static mitmStartPromise: Promise<MitmProxy>;
44
private static waitingForAvailability: {
45
options: ISessionCreateOptions;
46
promise: IResolvablePromise<Session>;
47
}[] = [];
48
49
public static async start(): Promise<void> {
50
this.isClosing = false;
51
log.info('StartingGlobalPool', {
52
sessionId: null,
53
});
54
await this.startMitm();
55
}
56
57
public static createSession(options: ISessionCreateOptions): Promise<Session> {
58
log.info('AcquiringChrome', {
59
sessionId: null,
60
activeSessionCount: this.activeSessionCount,
61
waitingForAvailability: this.waitingForAvailability.length,
62
maxConcurrentAgentsCount: this.maxConcurrentAgentsCount,
63
});
64
65
if (!this.hasAvailability) {
66
const resolvablePromise = createPromise<Session>();
67
this.waitingForAvailability.push({ options, promise: resolvablePromise });
68
return resolvablePromise.promise;
69
}
70
return this.createSessionNow(options);
71
}
72
73
public static close(): Promise<void> {
74
if (this.isClosing) return Promise.resolve();
75
this.isClosing = true;
76
const logId = log.stats('GlobalPool.Closing');
77
78
for (const { promise } of this.waitingForAvailability) {
79
promise.reject(new CanceledPromiseError('Puppet pool shutting down'));
80
}
81
this.waitingForAvailability.length = 0;
82
const closePromises: Promise<any>[] = [];
83
while (this.puppets.length) {
84
const puppetBrowser = this.puppets.shift();
85
closePromises.push(puppetBrowser.close().catch(err => err));
86
}
87
MitmProxy.close();
88
if (this.mitmServer) {
89
this.mitmServer.close();
90
this.mitmServer = null;
91
}
92
SessionsDb.shutdown();
93
return Promise.all(closePromises)
94
.then(() => {
95
log.stats('GlobalPool.Closed', { parentLogId: logId, sessionId: null });
96
return null;
97
})
98
.catch(error => {
99
log.error('Error in GlobalPoolShutdown', { parentLogId: logId, sessionId: null, error });
100
});
101
}
102
103
private static getPuppet(browserEngine: IBrowserEngine): Promise<Puppet> {
104
const args = this.getPuppetLaunchArgs();
105
const puppet = new Puppet(browserEngine, args);
106
107
const existing = this.puppets.find(x =>
108
this.isSameEngine(puppet.browserEngine, x.browserEngine),
109
);
110
if (existing) return Promise.resolve(existing);
111
112
this.puppets.push(puppet);
113
puppet.once('close', this.onEngineClosed.bind(this, browserEngine));
114
const browserDir = browserEngine.executablePath.split(browserEngine.fullVersion).shift();
115
116
const preferencesInterceptor = new DevtoolsPreferences(
117
`${browserDir}/devtoolsPreferences.json`,
118
);
119
120
return puppet.start(preferencesInterceptor.installOnConnect);
121
}
122
123
private static async startMitm(): Promise<void> {
124
if (this.mitmServer || disableMitm === true) return;
125
if (this.mitmStartPromise) await this.mitmStartPromise;
126
else {
127
this.mitmStartPromise = MitmProxy.start(this.localProxyPortStart, this.sessionsDir);
128
this.mitmServer = await this.mitmStartPromise;
129
}
130
}
131
132
private static async createSessionNow(options: ISessionCreateOptions): Promise<Session> {
133
await this.startMitm();
134
135
this._activeSessionCount += 1;
136
try {
137
const session = new Session(options);
138
139
const puppet = await this.getPuppet(session.browserEngine);
140
141
if (disableMitm !== true) {
142
await session.registerWithMitm(this.mitmServer, puppet.supportsBrowserContextProxy);
143
}
144
145
const browserContext = await puppet.newContext(
146
session.plugins,
147
log.createChild(module, {
148
sessionId: session.id,
149
}),
150
session.getMitmProxy(),
151
);
152
await session.initialize(browserContext);
153
154
session.on('all-tabs-closed', this.checkForInactiveBrowserEngine.bind(this, session));
155
156
session.once('closing', this.releaseConnection.bind(this));
157
return session;
158
} catch (err) {
159
this._activeSessionCount -= 1;
160
161
throw err;
162
}
163
}
164
165
private static async onEngineClosed(engine: IBrowserEngine): Promise<void> {
166
if (this.isClosing) return;
167
for (const session of Session.sessionsWithBrowserEngine(this.isSameEngine.bind(this, engine))) {
168
await session.close();
169
}
170
log.info('PuppetEngine.closed', {
171
engine,
172
sessionId: null,
173
});
174
const idx = this.puppets.findIndex(x => this.isSameEngine(engine, x.browserEngine));
175
if (idx >= 0) this.puppets.splice(idx, 1);
176
if (this.puppets.length === 0) {
177
this.events.emit('all-browsers-closed');
178
}
179
}
180
181
private static checkForInactiveBrowserEngine(session: Session): void {
182
const sessionsUsingEngine = Session.sessionsWithBrowserEngine(
183
this.isSameEngine.bind(this, session.browserEngine),
184
);
185
const hasWindows = sessionsUsingEngine.some(x => x.tabsById.size > 0);
186
187
log.info('Session.allTabsClosed', {
188
sessionId: session.id,
189
engineHasOtherOpenTabs: hasWindows,
190
});
191
if (hasWindows) return;
192
193
const puppet = this.puppets.find(x =>
194
this.isSameEngine(session.browserEngine, x.browserEngine),
195
);
196
197
if (puppet) {
198
this.events.emit('browser-has-no-open-windows', { puppet });
199
}
200
}
201
202
private static releaseConnection(): void {
203
this._activeSessionCount -= 1;
204
205
const wasTransferred = this.resolveWaitingConnection();
206
if (wasTransferred) {
207
log.info('ReleasingChrome', {
208
sessionId: null,
209
activeSessionCount: this.activeSessionCount,
210
waitingForAvailability: this.waitingForAvailability.length,
211
});
212
}
213
}
214
215
private static resolveWaitingConnection(): boolean {
216
if (!this.waitingForAvailability.length) {
217
return false;
218
}
219
const { options, promise } = this.waitingForAvailability.shift();
220
221
// NOTE: we want this to blow up if an exception occurs inside the promise
222
// eslint-disable-next-line promise/catch-or-return
223
this.createSessionNow(options).then(session => promise.resolve(session));
224
225
log.info('TransferredChromeToWaitingAcquirer');
226
return true;
227
}
228
229
private static getPuppetLaunchArgs(): IPuppetLaunchArgs {
230
this.defaultLaunchArgs ??= {
231
showBrowser: Boolean(
232
JSON.parse(process.env.SA_SHOW_BROWSER ?? process.env.SHOW_BROWSER ?? 'false'),
233
),
234
disableDevtools: Boolean(JSON.parse(process.env.SA_DISABLE_DEVTOOLS ?? 'true')),
235
noChromeSandbox: Boolean(JSON.parse(process.env.SA_NO_CHROME_SANDBOX ?? 'false')),
236
disableGpu: Boolean(JSON.parse(process.env.SA_DISABLE_GPU ?? 'false')),
237
enableMitm: !disableMitm,
238
};
239
return {
240
...this.defaultLaunchArgs,
241
proxyPort: this.mitmServer?.port,
242
};
243
}
244
245
private static isSameEngine(engineA: IBrowserEngine, engineB: IBrowserEngine): boolean {
246
return (
247
engineA.executablePath === engineB.executablePath &&
248
engineA.launchArguments.toString() === engineB.launchArguments.toString()
249
);
250
}
251
252
public static get sessionsDir(): string {
253
return sessionsDir;
254
}
255
256
public static set sessionsDir(dir: string) {
257
const absoluteDir = Path.isAbsolute(dir) ? dir : Path.join(process.cwd(), dir);
258
if (!Fs.existsSync(`${absoluteDir}`)) {
259
Fs.mkdirSync(`${absoluteDir}`, { recursive: true });
260
}
261
sessionsDir = absoluteDir;
262
}
263
}
264
265
GlobalPool.sessionsDir = sessionsDir;
266
267