Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/puppet-chrome/lib/Browser.ts
1028 views
1
import { Protocol } from 'devtools-protocol';
2
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
3
import { assert } from '@secret-agent/commons/utils';
4
import IPuppetBrowser from '@secret-agent/interfaces/IPuppetBrowser';
5
import Log from '@secret-agent/commons/Logger';
6
import { IBoundLog } from '@secret-agent/interfaces/ILog';
7
import IBrowserEngine from '@secret-agent/interfaces/IBrowserEngine';
8
import ICorePlugins from '@secret-agent/interfaces/ICorePlugins';
9
import IProxyConnectionOptions from '@secret-agent/interfaces/IProxyConnectionOptions';
10
import { Connection } from './Connection';
11
import { BrowserContext } from './BrowserContext';
12
import { DevtoolsSession } from './DevtoolsSession';
13
import GetVersionResponse = Protocol.Browser.GetVersionResponse;
14
15
interface IBrowserEvents {
16
disconnected: void;
17
}
18
const { log } = Log(module);
19
20
let browserIdCounter = 0;
21
22
export class Browser extends TypedEventEmitter<IBrowserEvents> implements IPuppetBrowser {
23
public readonly browserContextsById = new Map<string, BrowserContext>();
24
public readonly devtoolsSession: DevtoolsSession;
25
public onDevtoolsPanelAttached?: (session: DevtoolsSession) => Promise<any>;
26
public id: string;
27
28
public get name(): string {
29
return this.version.product.split('/').shift();
30
}
31
32
public get fullVersion(): string {
33
return this.version.product.split('/').pop();
34
}
35
36
public get majorVersion(): number {
37
return this.fullVersion?.split('.').map(Number).shift();
38
}
39
40
private readonly connection: Connection;
41
42
private readonly closeCallback: () => void;
43
44
private version: GetVersionResponse;
45
46
constructor(connection: Connection, closeCallback: () => void) {
47
super();
48
this.connection = connection;
49
this.devtoolsSession = connection.rootSession;
50
this.closeCallback = closeCallback;
51
this.id = String((browserIdCounter += 1));
52
53
this.connection.on('disconnected', this.emit.bind(this, 'disconnected'));
54
this.devtoolsSession.on('Target.attachedToTarget', this.onAttachedToTarget.bind(this));
55
this.devtoolsSession.on('Target.detachedFromTarget', this.onDetachedFromTarget.bind(this));
56
this.devtoolsSession.on('Target.targetCreated', this.onTargetCreated.bind(this));
57
this.devtoolsSession.on('Target.targetDestroyed', this.onTargetDestroyed.bind(this));
58
this.devtoolsSession.on('Target.targetCrashed', this.onTargetCrashed.bind(this));
59
}
60
61
public async newContext(
62
plugins: ICorePlugins,
63
logger: IBoundLog,
64
proxy?: IProxyConnectionOptions,
65
): Promise<BrowserContext> {
66
const proxySettings = proxy?.address
67
? {
68
proxyBypassList: '<-loopback>',
69
proxyServer: proxy.address,
70
}
71
: {};
72
// Creates a new incognito browser context. This won't share cookies/cache with other browser contexts.
73
const { browserContextId } = await this.devtoolsSession.send('Target.createBrowserContext', {
74
disposeOnDetach: true,
75
...proxySettings,
76
});
77
78
return new BrowserContext(this, plugins, browserContextId, logger, proxy);
79
}
80
81
public async close(): Promise<void> {
82
const closePromises: Promise<any>[] = [];
83
for (const [, context] of this.browserContextsById) closePromises.push(context.close());
84
await Promise.all(closePromises);
85
await this.closeCallback();
86
this.connection.dispose();
87
}
88
89
public isConnected(): boolean {
90
return !this.connection.isClosed;
91
}
92
93
protected async listen() {
94
await this.devtoolsSession.send('Target.setAutoAttach', {
95
autoAttach: true,
96
waitForDebuggerOnStart: true,
97
flatten: true,
98
});
99
100
await this.devtoolsSession.send('Target.setDiscoverTargets', {
101
discover: true,
102
});
103
104
return this;
105
}
106
107
private onAttachedToTarget(event: Protocol.Target.AttachedToTargetEvent) {
108
const { targetInfo, sessionId } = event;
109
110
if (!targetInfo.browserContextId) {
111
assert(targetInfo.browserContextId, `targetInfo: ${JSON.stringify(targetInfo, null, 2)}`);
112
}
113
114
if (targetInfo.type === 'page') {
115
const devtoolsSession = this.connection.getSession(sessionId);
116
const context = this.browserContextsById.get(targetInfo.browserContextId);
117
context?.onPageAttached(devtoolsSession, targetInfo).catch(() => null);
118
return;
119
}
120
121
if (targetInfo.type === 'shared_worker') {
122
const devtoolsSession = this.connection.getSession(sessionId);
123
const context = this.browserContextsById.get(targetInfo.browserContextId);
124
context?.onSharedWorkerAttached(devtoolsSession, targetInfo).catch(() => null);
125
}
126
127
if (event.waitingForDebugger && targetInfo.type === 'service_worker') {
128
const devtoolsSession = this.connection.getSession(sessionId);
129
if (!devtoolsSession) return;
130
devtoolsSession.send('Runtime.runIfWaitingForDebugger').catch(() => null);
131
}
132
133
if (
134
targetInfo.type === 'other' &&
135
targetInfo.url.startsWith('devtools://devtools') &&
136
this.onDevtoolsPanelAttached
137
) {
138
const devtoolsSession = this.connection.getSession(sessionId);
139
this.onDevtoolsPanelAttached(devtoolsSession).catch(() => null);
140
return;
141
}
142
143
if (event.waitingForDebugger && targetInfo.type === 'other') {
144
const devtoolsSession = this.connection.getSession(sessionId);
145
if (!devtoolsSession) return;
146
// Ideally, detaching should resume any target, but there is a bug in the backend.
147
devtoolsSession
148
.send('Runtime.runIfWaitingForDebugger')
149
.catch(() => null)
150
.then(() => this.devtoolsSession.send('Target.detachFromTarget', { sessionId }))
151
.catch(() => null);
152
}
153
}
154
155
private async onTargetCreated(event: Protocol.Target.TargetCreatedEvent) {
156
const { targetInfo } = event;
157
try {
158
if (targetInfo.type === 'page' && !targetInfo.attached) {
159
const context = this.browserContextsById.get(targetInfo.browserContextId);
160
await context?.attachToTarget(targetInfo.targetId);
161
}
162
if (targetInfo.type === 'shared_worker') {
163
const context = this.browserContextsById.get(targetInfo.browserContextId);
164
await context?.attachToWorker(targetInfo);
165
}
166
} catch (error) {
167
// target went away too quickly
168
if (String(error).includes('No target with given id found')) return;
169
throw error;
170
}
171
}
172
173
private onTargetDestroyed(event: Protocol.Target.TargetDestroyedEvent) {
174
const { targetId } = event;
175
for (const context of this.browserContextsById.values()) {
176
context.targetDestroyed(targetId);
177
}
178
}
179
180
private onTargetCrashed(event: Protocol.Target.TargetCrashedEvent) {
181
const { targetId, errorCode, status } = event;
182
if (status === 'killed') {
183
for (const context of this.browserContextsById.values()) {
184
context.targetKilled(targetId, errorCode);
185
}
186
}
187
}
188
189
private onDetachedFromTarget(payload: Protocol.Target.DetachedFromTargetEvent) {
190
const targetId = payload.targetId;
191
for (const [, context] of this.browserContextsById) {
192
context.onPageDetached(targetId);
193
}
194
}
195
196
public static async create(
197
connection: Connection,
198
browserEngine: IBrowserEngine,
199
closeCallback: () => void,
200
): Promise<Browser> {
201
const browser = new Browser(connection, closeCallback);
202
203
const version = await browser.devtoolsSession.send('Browser.getVersion');
204
browser.version = version;
205
206
log.info('Browser.create', {
207
...version,
208
executablePath: browserEngine.executablePath,
209
desiredFullVersion: browserEngine.fullVersion,
210
sessionId: null,
211
});
212
213
return await browser.listen();
214
}
215
}
216
217