Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/puppet-chrome/lib/BrowserContext.ts
1028 views
1
import { assert } from '@secret-agent/commons/utils';
2
import IPuppetContext, {
3
IPuppetContextEvents,
4
IPuppetPageOptions,
5
} from '@secret-agent/interfaces/IPuppetContext';
6
import { ICookie } from '@secret-agent/interfaces/ICookie';
7
import { URL } from 'url';
8
import Protocol from 'devtools-protocol';
9
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
10
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
11
import { IBoundLog } from '@secret-agent/interfaces/ILog';
12
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
13
import { IPuppetWorker } from '@secret-agent/interfaces/IPuppetWorker';
14
import ProtocolMapping from 'devtools-protocol/types/protocol-mapping';
15
import ICorePlugins from '@secret-agent/interfaces/ICorePlugins';
16
import { IPuppetPage } from '@secret-agent/interfaces/IPuppetPage';
17
import IProxyConnectionOptions from '@secret-agent/interfaces/IProxyConnectionOptions';
18
import Resolvable from '@secret-agent/commons/Resolvable';
19
import {
20
IDevtoolsEventMessage,
21
IDevtoolsResponseMessage,
22
} from '@secret-agent/interfaces/IDevtoolsSession';
23
import { Page } from './Page';
24
import { Browser } from './Browser';
25
import { DevtoolsSession } from './DevtoolsSession';
26
import Frame from './Frame';
27
import CookieParam = Protocol.Network.CookieParam;
28
import TargetInfo = Protocol.Target.TargetInfo;
29
30
export class BrowserContext
31
extends TypedEventEmitter<IPuppetContextEvents>
32
implements IPuppetContext
33
{
34
public logger: IBoundLog;
35
36
public workersById = new Map<string, IPuppetWorker>();
37
public pagesById = new Map<string, Page>();
38
public plugins: ICorePlugins;
39
public proxy: IProxyConnectionOptions;
40
public readonly id: string;
41
42
private attachedTargetIds = new Set<string>();
43
private pageOptionsByTargetId = new Map<string, IPuppetPageOptions>();
44
private readonly createdTargetIds = new Set<string>();
45
private creatingTargetPromises: Promise<void>[] = [];
46
private waitForPageAttachedById = new Map<string, Resolvable<Page>>();
47
private readonly browser: Browser;
48
49
private isClosing = false;
50
51
private devtoolsSessions = new WeakSet<DevtoolsSession>();
52
private eventSubscriber = new EventSubscriber();
53
private browserContextInitiatedMessageIds = new Set<number>();
54
55
constructor(
56
browser: Browser,
57
plugins: ICorePlugins,
58
contextId: string,
59
logger: IBoundLog,
60
proxy?: IProxyConnectionOptions,
61
) {
62
super();
63
this.plugins = plugins;
64
this.browser = browser;
65
this.id = contextId;
66
this.logger = logger.createChild(module, {
67
browserContextId: contextId,
68
});
69
this.proxy = proxy;
70
this.browser.browserContextsById.set(this.id, this);
71
72
this.subscribeToDevtoolsMessages(this.browser.devtoolsSession, {
73
sessionType: 'browser',
74
});
75
}
76
77
public defaultPageInitializationFn: (page: IPuppetPage) => Promise<any> = () => Promise.resolve();
78
79
async newPage(options?: IPuppetPageOptions): Promise<Page> {
80
const createTargetPromise = new Resolvable<void>();
81
this.creatingTargetPromises.push(createTargetPromise.promise);
82
83
const { targetId } = await this.sendWithBrowserDevtoolsSession('Target.createTarget', {
84
url: 'about:blank',
85
browserContextId: this.id,
86
background: options ? true : undefined,
87
});
88
this.createdTargetIds.add(targetId);
89
this.pageOptionsByTargetId.set(targetId, options);
90
91
await this.attachToTarget(targetId);
92
93
createTargetPromise.resolve();
94
const idx = this.creatingTargetPromises.indexOf(createTargetPromise.promise);
95
if (idx >= 0) this.creatingTargetPromises.splice(idx, 1);
96
97
let page = this.pagesById.get(targetId);
98
if (!page) {
99
const pageAttachedPromise = new Resolvable<Page>(
100
60e3,
101
'Error creating page. Timed out waiting to attach',
102
);
103
this.waitForPageAttachedById.set(targetId, pageAttachedPromise);
104
page = await pageAttachedPromise.promise;
105
this.waitForPageAttachedById.delete(targetId);
106
}
107
108
await page.isReady;
109
if (page.isClosed) throw new Error('Page has been closed.');
110
return page;
111
}
112
113
initializePage(page: Page): Promise<any> {
114
if (this.pageOptionsByTargetId.get(page.targetId)?.runPageScripts === false) return;
115
116
const promises = [this.defaultPageInitializationFn(page).catch(err => err)];
117
promises.push(this.plugins.onNewPuppetPage(page).catch(err => err));
118
return Promise.all(promises);
119
}
120
121
async onPageAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo): Promise<Page> {
122
this.attachedTargetIds.add(targetInfo.targetId);
123
await Promise.all(this.creatingTargetPromises);
124
if (this.pagesById.has(targetInfo.targetId)) return;
125
126
this.subscribeToDevtoolsMessages(devtoolsSession, {
127
sessionType: 'page',
128
pageTargetId: targetInfo.targetId,
129
});
130
131
const pageOptions = this.pageOptionsByTargetId.get(targetInfo.targetId);
132
133
let opener = targetInfo.openerId ? this.pagesById.get(targetInfo.openerId) || null : null;
134
if (pageOptions?.triggerPopupOnPageId) {
135
opener = this.pagesById.get(pageOptions.triggerPopupOnPageId);
136
}
137
// make the first page the active page
138
if (!opener && !this.createdTargetIds.has(targetInfo.targetId)) {
139
opener = this.pagesById.values().next().value;
140
}
141
142
const page = new Page(devtoolsSession, targetInfo.targetId, this, this.logger, opener);
143
this.pagesById.set(page.targetId, page);
144
this.waitForPageAttachedById.get(page.targetId)?.resolve(page);
145
await page.isReady;
146
this.emit('page', { page });
147
return page;
148
}
149
150
onPageDetached(targetId: string) {
151
this.attachedTargetIds.delete(targetId);
152
const page = this.pagesById.get(targetId);
153
if (page) {
154
this.pagesById.delete(targetId);
155
page.didClose();
156
}
157
}
158
159
async onSharedWorkerAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo) {
160
const page: Page =
161
[...this.pagesById.values()].find(x => !x.isClosed) ?? this.pagesById.values().next().value;
162
await page.onWorkerAttached(devtoolsSession, targetInfo);
163
}
164
165
beforeWorkerAttached(
166
devtoolsSession: DevtoolsSession,
167
workerTargetId: string,
168
pageTargetId: string,
169
) {
170
this.subscribeToDevtoolsMessages(devtoolsSession, {
171
sessionType: 'worker' as const,
172
pageTargetId,
173
workerTargetId,
174
});
175
}
176
177
onWorkerAttached(worker: IPuppetWorker) {
178
this.workersById.set(worker.id, worker);
179
worker.on('close', () => this.workersById.delete(worker.id));
180
this.emit('worker', { worker });
181
}
182
183
targetDestroyed(targetId: string) {
184
this.attachedTargetIds.delete(targetId);
185
const page = this.pagesById.get(targetId);
186
if (page) page.didClose();
187
}
188
189
targetKilled(targetId: string, errorCode: number) {
190
const page = this.pagesById.get(targetId);
191
if (page) page.onTargetKilled(errorCode);
192
}
193
194
async attachToTarget(targetId: string) {
195
// chrome 80 still needs you to manually attach
196
if (!this.attachedTargetIds.has(targetId)) {
197
await this.sendWithBrowserDevtoolsSession('Target.attachToTarget', {
198
targetId,
199
flatten: true,
200
});
201
}
202
}
203
204
async attachToWorker(targetInfo: TargetInfo) {
205
await this.sendWithBrowserDevtoolsSession('Target.attachToTarget', {
206
targetId: targetInfo.targetId,
207
flatten: true,
208
});
209
}
210
211
async close(): Promise<void> {
212
if (this.isClosing) return;
213
this.isClosing = true;
214
215
for (const waitingPage of this.waitForPageAttachedById.values()) {
216
waitingPage.reject(new CanceledPromiseError('BrowserContext shutting down'));
217
}
218
if (this.browser.devtoolsSession.isConnected()) {
219
await Promise.all([...this.pagesById.values()].map(x => x.close()));
220
await this.sendWithBrowserDevtoolsSession('Target.disposeBrowserContext', {
221
browserContextId: this.id,
222
}).catch(err => {
223
if (err instanceof CanceledPromiseError) return;
224
throw err;
225
});
226
}
227
this.eventSubscriber.close();
228
this.browser.browserContextsById.delete(this.id);
229
}
230
231
async getCookies(url?: URL): Promise<ICookie[]> {
232
const { cookies } = await this.sendWithBrowserDevtoolsSession('Storage.getCookies', {
233
browserContextId: this.id,
234
});
235
return cookies
236
.map(c => {
237
const copy: any = { sameSite: 'None', ...c };
238
delete copy.size;
239
delete copy.priority;
240
delete copy.session;
241
242
copy.expires = String(copy.expires);
243
return copy as ICookie;
244
})
245
.filter(c => {
246
if (!url) return true;
247
248
let domain = c.domain;
249
if (!domain.startsWith('.')) domain = `.${domain}`;
250
if (!`.${url.hostname}`.endsWith(domain)) return false;
251
if (!url.pathname.startsWith(c.path)) return false;
252
if (c.secure === true && url.protocol !== 'https:') return false;
253
return true;
254
});
255
}
256
257
async addCookies(
258
cookies: (Omit<ICookie, 'expires'> & { expires?: string | Date | number })[],
259
origins?: string[],
260
) {
261
const originUrls = (origins ?? []).map(x => new URL(x));
262
const parsedCookies: CookieParam[] = [];
263
for (const cookie of cookies) {
264
assert(cookie.name, 'Cookie should have a name');
265
assert(cookie.value !== undefined && cookie.value !== null, 'Cookie should have a value');
266
assert(cookie.domain || cookie.url, 'Cookie should have a domain or url');
267
268
let expires = cookie.expires ?? -1;
269
if (expires && typeof expires === 'string') {
270
if (expires.match(/\d+/)) {
271
expires = parseInt(expires, 10);
272
} else {
273
expires = new Date(expires).getTime();
274
}
275
} else if (expires && expires instanceof Date) {
276
expires = expires.getTime();
277
}
278
279
const cookieToSend: CookieParam = {
280
...cookie,
281
expires: expires as number,
282
};
283
284
if (!cookieToSend.url) {
285
cookieToSend.url = `http${cookie.secure ? 's' : ''}://${cookie.domain}${cookie.path}`;
286
const match = originUrls.find(x => {
287
return x.hostname.endsWith(cookie.domain);
288
});
289
if (match) cookieToSend.url = match.href;
290
}
291
292
// chrome won't allow same site not for non-secure cookies
293
if (!cookie.secure && cookie.sameSite === 'None') {
294
delete cookieToSend.sameSite;
295
}
296
297
parsedCookies.push(cookieToSend);
298
}
299
await this.sendWithBrowserDevtoolsSession('Storage.setCookies', {
300
cookies: parsedCookies,
301
browserContextId: this.id,
302
});
303
}
304
305
sendWithBrowserDevtoolsSession<T extends keyof ProtocolMapping.Commands>(
306
method: T,
307
params: ProtocolMapping.Commands[T]['paramsType'][0] = {},
308
): Promise<ProtocolMapping.Commands[T]['returnType']> {
309
return this.browser.devtoolsSession.send(method, params, this);
310
}
311
312
private subscribeToDevtoolsMessages(
313
devtoolsSession: DevtoolsSession,
314
details: Pick<
315
IPuppetContextEvents['devtools-message'],
316
'pageTargetId' | 'sessionType' | 'workerTargetId'
317
>,
318
) {
319
if (this.devtoolsSessions.has(devtoolsSession)) return;
320
321
this.devtoolsSessions.add(devtoolsSession);
322
const shouldFilter = details.sessionType === 'browser';
323
324
this.eventSubscriber.on(devtoolsSession.messageEvents, 'receive', event => {
325
if (shouldFilter) {
326
// see if this was initiated by this browser context
327
const { id } = event as IDevtoolsResponseMessage;
328
if (id && !this.browserContextInitiatedMessageIds.has(id)) return;
329
330
// see if this has a browser context target
331
const target = (event as IDevtoolsEventMessage).params?.targetInfo as TargetInfo;
332
if (target && target.browserContextId && target.browserContextId !== this.id) return;
333
}
334
this.emit('devtools-message', {
335
direction: 'receive',
336
...details,
337
...event,
338
});
339
});
340
this.eventSubscriber.on(devtoolsSession.messageEvents, 'send', (event, initiator?: any) => {
341
if (shouldFilter) {
342
if (initiator && initiator !== this) return;
343
if ('id' in event) this.browserContextInitiatedMessageIds.add(event.id);
344
}
345
if (initiator && initiator instanceof Frame) {
346
(event as any).frameId = initiator.id;
347
}
348
this.emit('devtools-message', {
349
direction: 'send',
350
...details,
351
...event,
352
});
353
});
354
}
355
}
356
357