Path: blob/main/puppet-chrome/lib/BrowserContext.ts
1028 views
import { assert } from '@secret-agent/commons/utils';1import IPuppetContext, {2IPuppetContextEvents,3IPuppetPageOptions,4} from '@secret-agent/interfaces/IPuppetContext';5import { ICookie } from '@secret-agent/interfaces/ICookie';6import { URL } from 'url';7import Protocol from 'devtools-protocol';8import EventSubscriber from '@secret-agent/commons/EventSubscriber';9import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';10import { IBoundLog } from '@secret-agent/interfaces/ILog';11import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';12import { IPuppetWorker } from '@secret-agent/interfaces/IPuppetWorker';13import ProtocolMapping from 'devtools-protocol/types/protocol-mapping';14import ICorePlugins from '@secret-agent/interfaces/ICorePlugins';15import { IPuppetPage } from '@secret-agent/interfaces/IPuppetPage';16import IProxyConnectionOptions from '@secret-agent/interfaces/IProxyConnectionOptions';17import Resolvable from '@secret-agent/commons/Resolvable';18import {19IDevtoolsEventMessage,20IDevtoolsResponseMessage,21} from '@secret-agent/interfaces/IDevtoolsSession';22import { Page } from './Page';23import { Browser } from './Browser';24import { DevtoolsSession } from './DevtoolsSession';25import Frame from './Frame';26import CookieParam = Protocol.Network.CookieParam;27import TargetInfo = Protocol.Target.TargetInfo;2829export class BrowserContext30extends TypedEventEmitter<IPuppetContextEvents>31implements IPuppetContext32{33public logger: IBoundLog;3435public workersById = new Map<string, IPuppetWorker>();36public pagesById = new Map<string, Page>();37public plugins: ICorePlugins;38public proxy: IProxyConnectionOptions;39public readonly id: string;4041private attachedTargetIds = new Set<string>();42private pageOptionsByTargetId = new Map<string, IPuppetPageOptions>();43private readonly createdTargetIds = new Set<string>();44private creatingTargetPromises: Promise<void>[] = [];45private waitForPageAttachedById = new Map<string, Resolvable<Page>>();46private readonly browser: Browser;4748private isClosing = false;4950private devtoolsSessions = new WeakSet<DevtoolsSession>();51private eventSubscriber = new EventSubscriber();52private browserContextInitiatedMessageIds = new Set<number>();5354constructor(55browser: Browser,56plugins: ICorePlugins,57contextId: string,58logger: IBoundLog,59proxy?: IProxyConnectionOptions,60) {61super();62this.plugins = plugins;63this.browser = browser;64this.id = contextId;65this.logger = logger.createChild(module, {66browserContextId: contextId,67});68this.proxy = proxy;69this.browser.browserContextsById.set(this.id, this);7071this.subscribeToDevtoolsMessages(this.browser.devtoolsSession, {72sessionType: 'browser',73});74}7576public defaultPageInitializationFn: (page: IPuppetPage) => Promise<any> = () => Promise.resolve();7778async newPage(options?: IPuppetPageOptions): Promise<Page> {79const createTargetPromise = new Resolvable<void>();80this.creatingTargetPromises.push(createTargetPromise.promise);8182const { targetId } = await this.sendWithBrowserDevtoolsSession('Target.createTarget', {83url: 'about:blank',84browserContextId: this.id,85background: options ? true : undefined,86});87this.createdTargetIds.add(targetId);88this.pageOptionsByTargetId.set(targetId, options);8990await this.attachToTarget(targetId);9192createTargetPromise.resolve();93const idx = this.creatingTargetPromises.indexOf(createTargetPromise.promise);94if (idx >= 0) this.creatingTargetPromises.splice(idx, 1);9596let page = this.pagesById.get(targetId);97if (!page) {98const pageAttachedPromise = new Resolvable<Page>(9960e3,100'Error creating page. Timed out waiting to attach',101);102this.waitForPageAttachedById.set(targetId, pageAttachedPromise);103page = await pageAttachedPromise.promise;104this.waitForPageAttachedById.delete(targetId);105}106107await page.isReady;108if (page.isClosed) throw new Error('Page has been closed.');109return page;110}111112initializePage(page: Page): Promise<any> {113if (this.pageOptionsByTargetId.get(page.targetId)?.runPageScripts === false) return;114115const promises = [this.defaultPageInitializationFn(page).catch(err => err)];116promises.push(this.plugins.onNewPuppetPage(page).catch(err => err));117return Promise.all(promises);118}119120async onPageAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo): Promise<Page> {121this.attachedTargetIds.add(targetInfo.targetId);122await Promise.all(this.creatingTargetPromises);123if (this.pagesById.has(targetInfo.targetId)) return;124125this.subscribeToDevtoolsMessages(devtoolsSession, {126sessionType: 'page',127pageTargetId: targetInfo.targetId,128});129130const pageOptions = this.pageOptionsByTargetId.get(targetInfo.targetId);131132let opener = targetInfo.openerId ? this.pagesById.get(targetInfo.openerId) || null : null;133if (pageOptions?.triggerPopupOnPageId) {134opener = this.pagesById.get(pageOptions.triggerPopupOnPageId);135}136// make the first page the active page137if (!opener && !this.createdTargetIds.has(targetInfo.targetId)) {138opener = this.pagesById.values().next().value;139}140141const page = new Page(devtoolsSession, targetInfo.targetId, this, this.logger, opener);142this.pagesById.set(page.targetId, page);143this.waitForPageAttachedById.get(page.targetId)?.resolve(page);144await page.isReady;145this.emit('page', { page });146return page;147}148149onPageDetached(targetId: string) {150this.attachedTargetIds.delete(targetId);151const page = this.pagesById.get(targetId);152if (page) {153this.pagesById.delete(targetId);154page.didClose();155}156}157158async onSharedWorkerAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo) {159const page: Page =160[...this.pagesById.values()].find(x => !x.isClosed) ?? this.pagesById.values().next().value;161await page.onWorkerAttached(devtoolsSession, targetInfo);162}163164beforeWorkerAttached(165devtoolsSession: DevtoolsSession,166workerTargetId: string,167pageTargetId: string,168) {169this.subscribeToDevtoolsMessages(devtoolsSession, {170sessionType: 'worker' as const,171pageTargetId,172workerTargetId,173});174}175176onWorkerAttached(worker: IPuppetWorker) {177this.workersById.set(worker.id, worker);178worker.on('close', () => this.workersById.delete(worker.id));179this.emit('worker', { worker });180}181182targetDestroyed(targetId: string) {183this.attachedTargetIds.delete(targetId);184const page = this.pagesById.get(targetId);185if (page) page.didClose();186}187188targetKilled(targetId: string, errorCode: number) {189const page = this.pagesById.get(targetId);190if (page) page.onTargetKilled(errorCode);191}192193async attachToTarget(targetId: string) {194// chrome 80 still needs you to manually attach195if (!this.attachedTargetIds.has(targetId)) {196await this.sendWithBrowserDevtoolsSession('Target.attachToTarget', {197targetId,198flatten: true,199});200}201}202203async attachToWorker(targetInfo: TargetInfo) {204await this.sendWithBrowserDevtoolsSession('Target.attachToTarget', {205targetId: targetInfo.targetId,206flatten: true,207});208}209210async close(): Promise<void> {211if (this.isClosing) return;212this.isClosing = true;213214for (const waitingPage of this.waitForPageAttachedById.values()) {215waitingPage.reject(new CanceledPromiseError('BrowserContext shutting down'));216}217if (this.browser.devtoolsSession.isConnected()) {218await Promise.all([...this.pagesById.values()].map(x => x.close()));219await this.sendWithBrowserDevtoolsSession('Target.disposeBrowserContext', {220browserContextId: this.id,221}).catch(err => {222if (err instanceof CanceledPromiseError) return;223throw err;224});225}226this.eventSubscriber.close();227this.browser.browserContextsById.delete(this.id);228}229230async getCookies(url?: URL): Promise<ICookie[]> {231const { cookies } = await this.sendWithBrowserDevtoolsSession('Storage.getCookies', {232browserContextId: this.id,233});234return cookies235.map(c => {236const copy: any = { sameSite: 'None', ...c };237delete copy.size;238delete copy.priority;239delete copy.session;240241copy.expires = String(copy.expires);242return copy as ICookie;243})244.filter(c => {245if (!url) return true;246247let domain = c.domain;248if (!domain.startsWith('.')) domain = `.${domain}`;249if (!`.${url.hostname}`.endsWith(domain)) return false;250if (!url.pathname.startsWith(c.path)) return false;251if (c.secure === true && url.protocol !== 'https:') return false;252return true;253});254}255256async addCookies(257cookies: (Omit<ICookie, 'expires'> & { expires?: string | Date | number })[],258origins?: string[],259) {260const originUrls = (origins ?? []).map(x => new URL(x));261const parsedCookies: CookieParam[] = [];262for (const cookie of cookies) {263assert(cookie.name, 'Cookie should have a name');264assert(cookie.value !== undefined && cookie.value !== null, 'Cookie should have a value');265assert(cookie.domain || cookie.url, 'Cookie should have a domain or url');266267let expires = cookie.expires ?? -1;268if (expires && typeof expires === 'string') {269if (expires.match(/\d+/)) {270expires = parseInt(expires, 10);271} else {272expires = new Date(expires).getTime();273}274} else if (expires && expires instanceof Date) {275expires = expires.getTime();276}277278const cookieToSend: CookieParam = {279...cookie,280expires: expires as number,281};282283if (!cookieToSend.url) {284cookieToSend.url = `http${cookie.secure ? 's' : ''}://${cookie.domain}${cookie.path}`;285const match = originUrls.find(x => {286return x.hostname.endsWith(cookie.domain);287});288if (match) cookieToSend.url = match.href;289}290291// chrome won't allow same site not for non-secure cookies292if (!cookie.secure && cookie.sameSite === 'None') {293delete cookieToSend.sameSite;294}295296parsedCookies.push(cookieToSend);297}298await this.sendWithBrowserDevtoolsSession('Storage.setCookies', {299cookies: parsedCookies,300browserContextId: this.id,301});302}303304sendWithBrowserDevtoolsSession<T extends keyof ProtocolMapping.Commands>(305method: T,306params: ProtocolMapping.Commands[T]['paramsType'][0] = {},307): Promise<ProtocolMapping.Commands[T]['returnType']> {308return this.browser.devtoolsSession.send(method, params, this);309}310311private subscribeToDevtoolsMessages(312devtoolsSession: DevtoolsSession,313details: Pick<314IPuppetContextEvents['devtools-message'],315'pageTargetId' | 'sessionType' | 'workerTargetId'316>,317) {318if (this.devtoolsSessions.has(devtoolsSession)) return;319320this.devtoolsSessions.add(devtoolsSession);321const shouldFilter = details.sessionType === 'browser';322323this.eventSubscriber.on(devtoolsSession.messageEvents, 'receive', event => {324if (shouldFilter) {325// see if this was initiated by this browser context326const { id } = event as IDevtoolsResponseMessage;327if (id && !this.browserContextInitiatedMessageIds.has(id)) return;328329// see if this has a browser context target330const target = (event as IDevtoolsEventMessage).params?.targetInfo as TargetInfo;331if (target && target.browserContextId && target.browserContextId !== this.id) return;332}333this.emit('devtools-message', {334direction: 'receive',335...details,336...event,337});338});339this.eventSubscriber.on(devtoolsSession.messageEvents, 'send', (event, initiator?: any) => {340if (shouldFilter) {341if (initiator && initiator !== this) return;342if ('id' in event) this.browserContextInitiatedMessageIds.add(event.id);343}344if (initiator && initiator instanceof Frame) {345(event as any).frameId = initiator.id;346}347this.emit('devtools-message', {348direction: 'send',349...details,350...event,351});352});353}354}355356357