TargetManager.ts (14681B)
1 /** 2 * @license 3 * Copyright 2022 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import type {TargetFilterCallback} from '../api/Browser.js'; 10 import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; 11 import {EventEmitter} from '../common/EventEmitter.js'; 12 import {debugError} from '../common/util.js'; 13 import {assert} from '../util/assert.js'; 14 import {Deferred} from '../util/Deferred.js'; 15 16 import {CdpCDPSession} from './CdpSession.js'; 17 import type {Connection} from './Connection.js'; 18 import {CdpTarget, InitializationStatus} from './Target.js'; 19 import type {TargetManagerEvents} from './TargetManageEvents.js'; 20 import {TargetManagerEvent} from './TargetManageEvents.js'; 21 22 /** 23 * @internal 24 */ 25 export type TargetFactory = ( 26 targetInfo: Protocol.Target.TargetInfo, 27 session?: CdpCDPSession, 28 parentSession?: CdpCDPSession, 29 ) => CdpTarget; 30 31 function isPageTargetBecomingPrimary( 32 target: CdpTarget, 33 newTargetInfo: Protocol.Target.TargetInfo, 34 ): boolean { 35 return Boolean(target._subtype()) && !newTargetInfo.subtype; 36 } 37 38 /** 39 * TargetManager encapsulates all interactions with CDP targets and is 40 * responsible for coordinating the configuration of targets with the rest of 41 * Puppeteer. Code outside of this class should not subscribe `Target.*` events 42 * and only use the TargetManager events. 43 * 44 * TargetManager uses the CDP's auto-attach mechanism to intercept 45 * new targets and allow the rest of Puppeteer to configure listeners while 46 * the target is paused. 47 * 48 * @internal 49 */ 50 export class TargetManager 51 extends EventEmitter<TargetManagerEvents> 52 implements TargetManager 53 { 54 #connection: Connection; 55 /** 56 * Keeps track of the following events: 'Target.targetCreated', 57 * 'Target.targetDestroyed', 'Target.targetInfoChanged'. 58 * 59 * A target becomes discovered when 'Target.targetCreated' is received. 60 * A target is removed from this map once 'Target.targetDestroyed' is 61 * received. 62 * 63 * `targetFilterCallback` has no effect on this map. 64 */ 65 #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>(); 66 /** 67 * A target is added to this map once TargetManager has created 68 * a Target and attached at least once to it. 69 */ 70 #attachedTargetsByTargetId = new Map<string, CdpTarget>(); 71 /** 72 * Tracks which sessions attach to which target. 73 */ 74 #attachedTargetsBySessionId = new Map<string, CdpTarget>(); 75 /** 76 * If a target was filtered out by `targetFilterCallback`, we still receive 77 * events about it from CDP, but we don't forward them to the rest of Puppeteer. 78 */ 79 #ignoredTargets = new Set<string>(); 80 #targetFilterCallback: TargetFilterCallback | undefined; 81 #targetFactory: TargetFactory; 82 83 #attachedToTargetListenersBySession = new WeakMap< 84 CDPSession | Connection, 85 (event: Protocol.Target.AttachedToTargetEvent) => void 86 >(); 87 #detachedFromTargetListenersBySession = new WeakMap< 88 CDPSession | Connection, 89 (event: Protocol.Target.DetachedFromTargetEvent) => void 90 >(); 91 92 #initializeDeferred = Deferred.create<void>(); 93 #targetsIdsForInit = new Set<string>(); 94 #waitForInitiallyDiscoveredTargets = true; 95 96 #discoveryFilter: Protocol.Target.FilterEntry[] = [{}]; 97 98 constructor( 99 connection: Connection, 100 targetFactory: TargetFactory, 101 targetFilterCallback?: TargetFilterCallback, 102 waitForInitiallyDiscoveredTargets = true, 103 ) { 104 super(); 105 this.#connection = connection; 106 this.#targetFilterCallback = targetFilterCallback; 107 this.#targetFactory = targetFactory; 108 this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets; 109 110 this.#connection.on('Target.targetCreated', this.#onTargetCreated); 111 this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); 112 this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged); 113 this.#connection.on( 114 CDPSessionEvent.SessionDetached, 115 this.#onSessionDetached, 116 ); 117 this.#setupAttachmentListeners(this.#connection); 118 } 119 120 #storeExistingTargetsForInit = () => { 121 if (!this.#waitForInitiallyDiscoveredTargets) { 122 return; 123 } 124 for (const [ 125 targetId, 126 targetInfo, 127 ] of this.#discoveredTargetsByTargetId.entries()) { 128 const targetForFilter = new CdpTarget( 129 targetInfo, 130 undefined, 131 undefined, 132 this, 133 undefined, 134 ); 135 // Only wait for pages and frames (except those from extensions) 136 // to auto-attach. 137 const isPageOrFrame = 138 targetInfo.type === 'page' || targetInfo.type === 'iframe'; 139 const isExtension = targetInfo.url.startsWith('chrome-extension://'); 140 if ( 141 (!this.#targetFilterCallback || 142 this.#targetFilterCallback(targetForFilter)) && 143 isPageOrFrame && 144 !isExtension 145 ) { 146 this.#targetsIdsForInit.add(targetId); 147 } 148 } 149 }; 150 151 async initialize(): Promise<void> { 152 await this.#connection.send('Target.setDiscoverTargets', { 153 discover: true, 154 filter: this.#discoveryFilter, 155 }); 156 157 this.#storeExistingTargetsForInit(); 158 159 await this.#connection.send('Target.setAutoAttach', { 160 waitForDebuggerOnStart: true, 161 flatten: true, 162 autoAttach: true, 163 filter: [ 164 { 165 type: 'page', 166 exclude: true, 167 }, 168 ...this.#discoveryFilter, 169 ], 170 }); 171 this.#finishInitializationIfReady(); 172 await this.#initializeDeferred.valueOrThrow(); 173 } 174 175 getChildTargets(target: CdpTarget): ReadonlySet<CdpTarget> { 176 return target._childTargets(); 177 } 178 179 dispose(): void { 180 this.#connection.off('Target.targetCreated', this.#onTargetCreated); 181 this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); 182 this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged); 183 this.#connection.off( 184 CDPSessionEvent.SessionDetached, 185 this.#onSessionDetached, 186 ); 187 188 this.#removeAttachmentListeners(this.#connection); 189 } 190 191 getAvailableTargets(): ReadonlyMap<string, CdpTarget> { 192 return this.#attachedTargetsByTargetId; 193 } 194 195 #setupAttachmentListeners(session: CDPSession | Connection): void { 196 const listener = (event: Protocol.Target.AttachedToTargetEvent) => { 197 void this.#onAttachedToTarget(session, event); 198 }; 199 assert(!this.#attachedToTargetListenersBySession.has(session)); 200 this.#attachedToTargetListenersBySession.set(session, listener); 201 session.on('Target.attachedToTarget', listener); 202 203 const detachedListener = ( 204 event: Protocol.Target.DetachedFromTargetEvent, 205 ) => { 206 return this.#onDetachedFromTarget(session, event); 207 }; 208 assert(!this.#detachedFromTargetListenersBySession.has(session)); 209 this.#detachedFromTargetListenersBySession.set(session, detachedListener); 210 session.on('Target.detachedFromTarget', detachedListener); 211 } 212 213 #removeAttachmentListeners(session: CDPSession | Connection): void { 214 const listener = this.#attachedToTargetListenersBySession.get(session); 215 if (listener) { 216 session.off('Target.attachedToTarget', listener); 217 this.#attachedToTargetListenersBySession.delete(session); 218 } 219 220 if (this.#detachedFromTargetListenersBySession.has(session)) { 221 session.off( 222 'Target.detachedFromTarget', 223 this.#detachedFromTargetListenersBySession.get(session)!, 224 ); 225 this.#detachedFromTargetListenersBySession.delete(session); 226 } 227 } 228 229 #onSessionDetached = (session: CDPSession) => { 230 this.#removeAttachmentListeners(session); 231 }; 232 233 #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { 234 this.#discoveredTargetsByTargetId.set( 235 event.targetInfo.targetId, 236 event.targetInfo, 237 ); 238 239 this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo); 240 241 // The connection is already attached to the browser target implicitly, 242 // therefore, no new CDPSession is created and we have special handling 243 // here. 244 if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { 245 if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) { 246 return; 247 } 248 const target = this.#targetFactory(event.targetInfo, undefined); 249 target._initialize(); 250 this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); 251 } 252 }; 253 254 #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => { 255 const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId); 256 this.#discoveredTargetsByTargetId.delete(event.targetId); 257 this.#finishInitializationIfReady(event.targetId); 258 if ( 259 targetInfo?.type === 'service_worker' && 260 this.#attachedTargetsByTargetId.has(event.targetId) 261 ) { 262 // Special case for service workers: report TargetGone event when 263 // the worker is destroyed. 264 const target = this.#attachedTargetsByTargetId.get(event.targetId); 265 if (target) { 266 this.emit(TargetManagerEvent.TargetGone, target); 267 this.#attachedTargetsByTargetId.delete(event.targetId); 268 } 269 } 270 }; 271 272 #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => { 273 this.#discoveredTargetsByTargetId.set( 274 event.targetInfo.targetId, 275 event.targetInfo, 276 ); 277 278 if ( 279 this.#ignoredTargets.has(event.targetInfo.targetId) || 280 !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) || 281 !event.targetInfo.attached 282 ) { 283 return; 284 } 285 286 const target = this.#attachedTargetsByTargetId.get( 287 event.targetInfo.targetId, 288 ); 289 if (!target) { 290 return; 291 } 292 const previousURL = target.url(); 293 const wasInitialized = 294 target._initializedDeferred.value() === InitializationStatus.SUCCESS; 295 296 if (isPageTargetBecomingPrimary(target, event.targetInfo)) { 297 const session = target?._session(); 298 assert( 299 session, 300 'Target that is being activated is missing a CDPSession.', 301 ); 302 session.parentSession()?.emit(CDPSessionEvent.Swapped, session); 303 } 304 305 target._targetInfoChanged(event.targetInfo); 306 307 if (wasInitialized && previousURL !== target.url()) { 308 this.emit(TargetManagerEvent.TargetChanged, { 309 target, 310 wasInitialized, 311 previousURL, 312 }); 313 } 314 }; 315 316 #onAttachedToTarget = async ( 317 parentSession: Connection | CDPSession, 318 event: Protocol.Target.AttachedToTargetEvent, 319 ) => { 320 const targetInfo = event.targetInfo; 321 const session = this.#connection._session(event.sessionId); 322 if (!session) { 323 throw new Error(`Session ${event.sessionId} was not created.`); 324 } 325 326 const silentDetach = async () => { 327 await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); 328 // We don't use `session.detach()` because that dispatches all commands on 329 // the connection instead of the parent session. 330 await parentSession 331 .send('Target.detachFromTarget', { 332 sessionId: session.id(), 333 }) 334 .catch(debugError); 335 }; 336 337 if (!this.#connection.isAutoAttached(targetInfo.targetId)) { 338 return; 339 } 340 341 // Special case for service workers: being attached to service workers will 342 // prevent them from ever being destroyed. Therefore, we silently detach 343 // from service workers unless the connection was manually created via 344 // `page.worker()`. To determine this, we use 345 // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we 346 // should determine if a target is auto-attached or not with the help of 347 // CDP. 348 if (targetInfo.type === 'service_worker') { 349 this.#finishInitializationIfReady(targetInfo.targetId); 350 await silentDetach(); 351 if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) { 352 return; 353 } 354 const target = this.#targetFactory(targetInfo); 355 target._initialize(); 356 this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); 357 this.emit(TargetManagerEvent.TargetAvailable, target); 358 return; 359 } 360 361 const isExistingTarget = this.#attachedTargetsByTargetId.has( 362 targetInfo.targetId, 363 ); 364 365 const target = isExistingTarget 366 ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! 367 : this.#targetFactory( 368 targetInfo, 369 session, 370 parentSession instanceof CdpCDPSession ? parentSession : undefined, 371 ); 372 373 if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) { 374 this.#ignoredTargets.add(targetInfo.targetId); 375 this.#finishInitializationIfReady(targetInfo.targetId); 376 await silentDetach(); 377 return; 378 } 379 380 this.#setupAttachmentListeners(session); 381 382 if (isExistingTarget) { 383 session.setTarget(target); 384 this.#attachedTargetsBySessionId.set( 385 session.id(), 386 this.#attachedTargetsByTargetId.get(targetInfo.targetId)!, 387 ); 388 } else { 389 target._initialize(); 390 this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); 391 this.#attachedTargetsBySessionId.set(session.id(), target); 392 } 393 394 const parentTarget = 395 parentSession instanceof CDPSession 396 ? (parentSession as CdpCDPSession).target() 397 : null; 398 parentTarget?._addChildTarget(target); 399 400 parentSession.emit(CDPSessionEvent.Ready, session); 401 402 this.#targetsIdsForInit.delete(target._targetId); 403 if (!isExistingTarget) { 404 this.emit(TargetManagerEvent.TargetAvailable, target); 405 } 406 this.#finishInitializationIfReady(); 407 408 // TODO: the browser might be shutting down here. What do we do with the 409 // error? 410 await Promise.all([ 411 session.send('Target.setAutoAttach', { 412 waitForDebuggerOnStart: true, 413 flatten: true, 414 autoAttach: true, 415 filter: this.#discoveryFilter, 416 }), 417 session.send('Runtime.runIfWaitingForDebugger'), 418 ]).catch(debugError); 419 }; 420 421 #finishInitializationIfReady(targetId?: string): void { 422 if (targetId !== undefined) { 423 this.#targetsIdsForInit.delete(targetId); 424 } 425 if (this.#targetsIdsForInit.size === 0) { 426 this.#initializeDeferred.resolve(); 427 } 428 } 429 430 #onDetachedFromTarget = ( 431 parentSession: Connection | CDPSession, 432 event: Protocol.Target.DetachedFromTargetEvent, 433 ) => { 434 const target = this.#attachedTargetsBySessionId.get(event.sessionId); 435 436 this.#attachedTargetsBySessionId.delete(event.sessionId); 437 438 if (!target) { 439 return; 440 } 441 442 if (parentSession instanceof CDPSession) { 443 (parentSession as CdpCDPSession).target()._removeChildTarget(target); 444 } 445 this.#attachedTargetsByTargetId.delete(target._targetId); 446 this.emit(TargetManagerEvent.TargetGone, target); 447 }; 448 }