| /** |
| * @license |
| * Copyright 2022 Google Inc. |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| |
| import type {Protocol} from 'devtools-protocol'; |
| |
| import type {TargetFilterCallback} from '../api/Browser.js'; |
| import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; |
| import {EventEmitter} from '../common/EventEmitter.js'; |
| import {debugError} from '../common/util.js'; |
| import {assert} from '../util/assert.js'; |
| import {Deferred} from '../util/Deferred.js'; |
| |
| import {CdpCDPSession} from './CdpSession.js'; |
| import type {Connection} from './Connection.js'; |
| import type {CdpTarget} from './Target.js'; |
| import {InitializationStatus} from './Target.js'; |
| import type {TargetManagerEvents} from './TargetManageEvents.js'; |
| import {TargetManagerEvent} from './TargetManageEvents.js'; |
| |
| /** |
| * @internal |
| */ |
| export type TargetFactory = ( |
| targetInfo: Protocol.Target.TargetInfo, |
| session?: CdpCDPSession, |
| parentSession?: CdpCDPSession, |
| ) => CdpTarget; |
| |
| function isPageTargetBecomingPrimary( |
| target: CdpTarget, |
| newTargetInfo: Protocol.Target.TargetInfo, |
| ): boolean { |
| return Boolean(target._subtype()) && !newTargetInfo.subtype; |
| } |
| |
| /** |
| * TargetManager encapsulates all interactions with CDP targets and is |
| * responsible for coordinating the configuration of targets with the rest of |
| * Puppeteer. Code outside of this class should not subscribe `Target.*` events |
| * and only use the TargetManager events. |
| * |
| * TargetManager uses the CDP's auto-attach mechanism to intercept |
| * new targets and allow the rest of Puppeteer to configure listeners while |
| * the target is paused. |
| * |
| * @internal |
| */ |
| export class TargetManager |
| extends EventEmitter<TargetManagerEvents> |
| implements TargetManager |
| { |
| #connection: Connection; |
| /** |
| * Keeps track of the following events: 'Target.targetCreated', |
| * 'Target.targetDestroyed', 'Target.targetInfoChanged'. |
| * |
| * A target becomes discovered when 'Target.targetCreated' is received. |
| * A target is removed from this map once 'Target.targetDestroyed' is |
| * received. |
| * |
| * `targetFilterCallback` has no effect on this map. |
| */ |
| #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>(); |
| /** |
| * A target is added to this map once TargetManager has created |
| * a Target and attached at least once to it. |
| */ |
| #attachedTargetsByTargetId = new Map<string, CdpTarget>(); |
| /** |
| * Tracks which sessions attach to which target. |
| */ |
| #attachedTargetsBySessionId = new Map<string, CdpTarget>(); |
| /** |
| * If a target was filtered out by `targetFilterCallback`, we still receive |
| * events about it from CDP, but we don't forward them to the rest of Puppeteer. |
| */ |
| #ignoredTargets = new Set<string>(); |
| #targetFilterCallback: TargetFilterCallback | undefined; |
| #targetFactory: TargetFactory; |
| |
| #attachedToTargetListenersBySession = new WeakMap< |
| CDPSession | Connection, |
| (event: Protocol.Target.AttachedToTargetEvent) => void |
| >(); |
| #detachedFromTargetListenersBySession = new WeakMap< |
| CDPSession | Connection, |
| (event: Protocol.Target.DetachedFromTargetEvent) => void |
| >(); |
| |
| #initializeDeferred = Deferred.create<void>(); |
| #waitForInitiallyDiscoveredTargets = true; |
| |
| #discoveryFilter: Protocol.Target.FilterEntry[] = [{}]; |
| // IDs of tab targets detected while running the initial Target.setAutoAttach |
| // request. These are the targets whose initialization we want to await for |
| // before resolving puppeteer.connect() or launch() to avoid flakiness. |
| // Whenever a sub-target whose parent is a tab target is attached, we remove |
| // the tab target from this list. Once the list is empty, we resolve the |
| // initializeDeferred. |
| #targetsIdsForInit = new Set<string>(); |
| // This is false until the connection-level Target.setAutoAttach request is |
| // done. It indicates whethere we are running the initial auto-attach step or |
| // if we are handling targets after that. |
| #initialAttachDone = false; |
| |
| constructor( |
| connection: Connection, |
| targetFactory: TargetFactory, |
| targetFilterCallback?: TargetFilterCallback, |
| waitForInitiallyDiscoveredTargets = true, |
| ) { |
| super(); |
| this.#connection = connection; |
| this.#targetFilterCallback = targetFilterCallback; |
| this.#targetFactory = targetFactory; |
| this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets; |
| |
| this.#connection.on('Target.targetCreated', this.#onTargetCreated); |
| this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); |
| this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged); |
| this.#connection.on( |
| CDPSessionEvent.SessionDetached, |
| this.#onSessionDetached, |
| ); |
| this.#setupAttachmentListeners(this.#connection); |
| } |
| |
| async initialize(): Promise<void> { |
| await this.#connection.send('Target.setDiscoverTargets', { |
| discover: true, |
| filter: this.#discoveryFilter, |
| }); |
| |
| await this.#connection.send('Target.setAutoAttach', { |
| waitForDebuggerOnStart: true, |
| flatten: true, |
| autoAttach: true, |
| filter: [ |
| { |
| type: 'page', |
| exclude: true, |
| }, |
| ...this.#discoveryFilter, |
| ], |
| }); |
| this.#initialAttachDone = true; |
| this.#finishInitializationIfReady(); |
| await this.#initializeDeferred.valueOrThrow(); |
| } |
| |
| getChildTargets(target: CdpTarget): ReadonlySet<CdpTarget> { |
| return target._childTargets(); |
| } |
| |
| dispose(): void { |
| this.#connection.off('Target.targetCreated', this.#onTargetCreated); |
| this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); |
| this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged); |
| this.#connection.off( |
| CDPSessionEvent.SessionDetached, |
| this.#onSessionDetached, |
| ); |
| |
| this.#removeAttachmentListeners(this.#connection); |
| } |
| |
| getAvailableTargets(): ReadonlyMap<string, CdpTarget> { |
| return this.#attachedTargetsByTargetId; |
| } |
| |
| #setupAttachmentListeners(session: CDPSession | Connection): void { |
| const listener = (event: Protocol.Target.AttachedToTargetEvent) => { |
| void this.#onAttachedToTarget(session, event); |
| }; |
| assert(!this.#attachedToTargetListenersBySession.has(session)); |
| this.#attachedToTargetListenersBySession.set(session, listener); |
| session.on('Target.attachedToTarget', listener); |
| |
| const detachedListener = ( |
| event: Protocol.Target.DetachedFromTargetEvent, |
| ) => { |
| return this.#onDetachedFromTarget(session, event); |
| }; |
| assert(!this.#detachedFromTargetListenersBySession.has(session)); |
| this.#detachedFromTargetListenersBySession.set(session, detachedListener); |
| session.on('Target.detachedFromTarget', detachedListener); |
| } |
| |
| #removeAttachmentListeners(session: CDPSession | Connection): void { |
| const listener = this.#attachedToTargetListenersBySession.get(session); |
| if (listener) { |
| session.off('Target.attachedToTarget', listener); |
| this.#attachedToTargetListenersBySession.delete(session); |
| } |
| |
| if (this.#detachedFromTargetListenersBySession.has(session)) { |
| session.off( |
| 'Target.detachedFromTarget', |
| this.#detachedFromTargetListenersBySession.get(session)!, |
| ); |
| this.#detachedFromTargetListenersBySession.delete(session); |
| } |
| } |
| |
| #onSessionDetached = (session: CDPSession) => { |
| this.#removeAttachmentListeners(session); |
| }; |
| |
| #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { |
| this.#discoveredTargetsByTargetId.set( |
| event.targetInfo.targetId, |
| event.targetInfo, |
| ); |
| |
| this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo); |
| |
| // The connection is already attached to the browser target implicitly, |
| // therefore, no new CDPSession is created and we have special handling |
| // here. |
| if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { |
| if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) { |
| return; |
| } |
| const target = this.#targetFactory(event.targetInfo, undefined); |
| target._initialize(); |
| this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); |
| } |
| }; |
| |
| #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => { |
| const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId); |
| this.#discoveredTargetsByTargetId.delete(event.targetId); |
| this.#finishInitializationIfReady(event.targetId); |
| if ( |
| targetInfo?.type === 'service_worker' && |
| this.#attachedTargetsByTargetId.has(event.targetId) |
| ) { |
| // Special case for service workers: report TargetGone event when |
| // the worker is destroyed. |
| const target = this.#attachedTargetsByTargetId.get(event.targetId); |
| if (target) { |
| this.emit(TargetManagerEvent.TargetGone, target); |
| this.#attachedTargetsByTargetId.delete(event.targetId); |
| } |
| } |
| }; |
| |
| #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => { |
| this.#discoveredTargetsByTargetId.set( |
| event.targetInfo.targetId, |
| event.targetInfo, |
| ); |
| |
| if ( |
| this.#ignoredTargets.has(event.targetInfo.targetId) || |
| !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) || |
| !event.targetInfo.attached |
| ) { |
| return; |
| } |
| |
| const target = this.#attachedTargetsByTargetId.get( |
| event.targetInfo.targetId, |
| ); |
| if (!target) { |
| return; |
| } |
| const previousURL = target.url(); |
| const wasInitialized = |
| target._initializedDeferred.value() === InitializationStatus.SUCCESS; |
| |
| if (isPageTargetBecomingPrimary(target, event.targetInfo)) { |
| const session = target?._session(); |
| assert( |
| session, |
| 'Target that is being activated is missing a CDPSession.', |
| ); |
| session.parentSession()?.emit(CDPSessionEvent.Swapped, session); |
| } |
| |
| target._targetInfoChanged(event.targetInfo); |
| |
| if (wasInitialized && previousURL !== target.url()) { |
| this.emit(TargetManagerEvent.TargetChanged, { |
| target, |
| wasInitialized, |
| previousURL, |
| }); |
| } |
| }; |
| |
| #onAttachedToTarget = async ( |
| parentSession: Connection | CDPSession, |
| event: Protocol.Target.AttachedToTargetEvent, |
| ) => { |
| const targetInfo = event.targetInfo; |
| const session = this.#connection._session(event.sessionId); |
| if (!session) { |
| throw new Error(`Session ${event.sessionId} was not created.`); |
| } |
| |
| const silentDetach = async () => { |
| await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); |
| // We don't use `session.detach()` because that dispatches all commands on |
| // the connection instead of the parent session. |
| await parentSession |
| .send('Target.detachFromTarget', { |
| sessionId: session.id(), |
| }) |
| .catch(debugError); |
| }; |
| |
| if (!this.#connection.isAutoAttached(targetInfo.targetId)) { |
| return; |
| } |
| |
| // Special case for service workers: being attached to service workers will |
| // prevent them from ever being destroyed. Therefore, we silently detach |
| // from service workers unless the connection was manually created via |
| // `page.worker()`. To determine this, we use |
| // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we |
| // should determine if a target is auto-attached or not with the help of |
| // CDP. |
| if (targetInfo.type === 'service_worker') { |
| await silentDetach(); |
| if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) { |
| return; |
| } |
| const target = this.#targetFactory(targetInfo); |
| target._initialize(); |
| this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); |
| this.emit(TargetManagerEvent.TargetAvailable, target); |
| return; |
| } |
| |
| const isExistingTarget = this.#attachedTargetsByTargetId.has( |
| targetInfo.targetId, |
| ); |
| |
| const target = isExistingTarget |
| ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! |
| : this.#targetFactory( |
| targetInfo, |
| session, |
| parentSession instanceof CdpCDPSession ? parentSession : undefined, |
| ); |
| |
| const parentTarget = |
| parentSession instanceof CdpCDPSession ? parentSession.target() : null; |
| |
| if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) { |
| this.#ignoredTargets.add(targetInfo.targetId); |
| if (parentTarget?.type() === 'tab') { |
| this.#finishInitializationIfReady(parentTarget._targetId); |
| } |
| await silentDetach(); |
| return; |
| } |
| |
| if ( |
| this.#waitForInitiallyDiscoveredTargets && |
| event.targetInfo.type === 'tab' && |
| !this.#initialAttachDone |
| ) { |
| this.#targetsIdsForInit.add(event.targetInfo.targetId); |
| } |
| |
| this.#setupAttachmentListeners(session); |
| |
| if (isExistingTarget) { |
| session.setTarget(target); |
| this.#attachedTargetsBySessionId.set( |
| session.id(), |
| this.#attachedTargetsByTargetId.get(targetInfo.targetId)!, |
| ); |
| } else { |
| target._initialize(); |
| this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); |
| this.#attachedTargetsBySessionId.set(session.id(), target); |
| } |
| |
| parentTarget?._addChildTarget(target); |
| |
| parentSession.emit(CDPSessionEvent.Ready, session); |
| |
| if (!isExistingTarget) { |
| this.emit(TargetManagerEvent.TargetAvailable, target); |
| } |
| if (parentTarget?.type() === 'tab') { |
| this.#finishInitializationIfReady(parentTarget._targetId); |
| } |
| |
| // TODO: the browser might be shutting down here. What do we do with the |
| // error? |
| await Promise.all([ |
| session.send('Target.setAutoAttach', { |
| waitForDebuggerOnStart: true, |
| flatten: true, |
| autoAttach: true, |
| filter: this.#discoveryFilter, |
| }), |
| session.send('Runtime.runIfWaitingForDebugger'), |
| ]).catch(debugError); |
| }; |
| |
| #finishInitializationIfReady(targetId?: string): void { |
| if (targetId !== undefined) { |
| this.#targetsIdsForInit.delete(targetId); |
| } |
| // If we are still initializing it might be that we have not learned about |
| // some targets yet. |
| if (!this.#initialAttachDone) { |
| return; |
| } |
| if (this.#targetsIdsForInit.size === 0) { |
| this.#initializeDeferred.resolve(); |
| } |
| } |
| |
| #onDetachedFromTarget = ( |
| parentSession: Connection | CDPSession, |
| event: Protocol.Target.DetachedFromTargetEvent, |
| ) => { |
| const target = this.#attachedTargetsBySessionId.get(event.sessionId); |
| |
| this.#attachedTargetsBySessionId.delete(event.sessionId); |
| |
| if (!target) { |
| return; |
| } |
| |
| if (parentSession instanceof CDPSession) { |
| (parentSession as CdpCDPSession).target()._removeChildTarget(target); |
| } |
| this.#attachedTargetsByTargetId.delete(target._targetId); |
| this.emit(TargetManagerEvent.TargetGone, target); |
| }; |
| } |