Source code
Revision control
Copy as Markdown
Other Tools
/**
* @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 type {CdpCDPSession} from './CDPSession.js';
import type {Connection} from './Connection.js';
import {CdpTarget, InitializationStatus} from './Target.js';
import {
type TargetFactory,
type TargetManager,
TargetManagerEvent,
type TargetManagerEvents,
} from './TargetManager.js';
function isPageTargetBecomingPrimary(
target: CdpTarget,
newTargetInfo: Protocol.Target.TargetInfo
): boolean {
return Boolean(target._subtype()) && !newTargetInfo.subtype;
}
/**
* ChromeTargetManager 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 ChromeTargetManager
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 ChromeTargetManager 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>();
#targetsIdsForInit = new Set<string>();
#waitForInitiallyDiscoveredTargets = true;
#discoveryFilter: Protocol.Target.FilterEntry[] = [{}];
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);
}
#storeExistingTargetsForInit = () => {
if (!this.#waitForInitiallyDiscoveredTargets) {
return;
}
for (const [
targetId,
targetInfo,
] of this.#discoveredTargetsByTargetId.entries()) {
const targetForFilter = new CdpTarget(
targetInfo,
undefined,
undefined,
this,
undefined
);
// Only wait for pages and frames (except those from extensions)
// to auto-attach.
const isPageOrFrame =
targetInfo.type === 'page' || targetInfo.type === 'iframe';
const isExtension = targetInfo.url.startsWith('chrome-extension://');
if (
(!this.#targetFilterCallback ||
this.#targetFilterCallback(targetForFilter)) &&
isPageOrFrame &&
!isExtension
) {
this.#targetsIdsForInit.add(targetId);
}
}
};
async initialize(): Promise<void> {
await this.#connection.send('Target.setDiscoverTargets', {
discover: true,
filter: this.#discoveryFilter,
});
this.#storeExistingTargetsForInit();
await this.#connection.send('Target.setAutoAttach', {
waitForDebuggerOnStart: true,
flatten: true,
autoAttach: true,
filter: [
{
type: 'page',
exclude: true,
},
...this.#discoveryFilter,
],
});
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') {
this.#finishInitializationIfReady(targetInfo.targetId);
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 CDPSession ? parentSession : undefined
);
if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
this.#ignoredTargets.add(targetInfo.targetId);
this.#finishInitializationIfReady(targetInfo.targetId);
await silentDetach();
return;
}
this.#setupAttachmentListeners(session);
if (isExistingTarget) {
(session as CdpCDPSession)._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);
}
const parentTarget =
parentSession instanceof CDPSession
? (parentSession as CdpCDPSession)._target()
: null;
parentTarget?._addChildTarget(target);
parentSession.emit(CDPSessionEvent.Ready, session);
this.#targetsIdsForInit.delete(target._targetId);
if (!isExistingTarget) {
this.emit(TargetManagerEvent.TargetAvailable, target);
}
this.#finishInitializationIfReady();
// 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 {
targetId !== undefined && this.#targetsIdsForInit.delete(targetId);
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);
};
}