FrameManager.ts (17982B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; 10 import {FrameEvent} from '../api/Frame.js'; 11 import type {NewDocumentScriptEvaluation} from '../api/Page.js'; 12 import {EventEmitter} from '../common/EventEmitter.js'; 13 import type {TimeoutSettings} from '../common/TimeoutSettings.js'; 14 import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js'; 15 import {assert} from '../util/assert.js'; 16 import {Deferred} from '../util/Deferred.js'; 17 import {disposeSymbol} from '../util/disposable.js'; 18 import {isErrorLike} from '../util/ErrorLike.js'; 19 20 import type {Binding} from './Binding.js'; 21 import {CdpPreloadScript} from './CdpPreloadScript.js'; 22 import type {CdpCDPSession} from './CdpSession.js'; 23 import {isTargetClosedError} from './Connection.js'; 24 import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js'; 25 import {ExecutionContext} from './ExecutionContext.js'; 26 import {CdpFrame} from './Frame.js'; 27 import type {FrameManagerEvents} from './FrameManagerEvents.js'; 28 import {FrameManagerEvent} from './FrameManagerEvents.js'; 29 import {FrameTree} from './FrameTree.js'; 30 import type {IsolatedWorld} from './IsolatedWorld.js'; 31 import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; 32 import {NetworkManager} from './NetworkManager.js'; 33 import type {CdpPage} from './Page.js'; 34 import type {CdpTarget} from './Target.js'; 35 36 const TIME_FOR_WAITING_FOR_SWAP = 100; // ms. 37 38 /** 39 * A frame manager manages the frames for a given {@link Page | page}. 40 * 41 * @internal 42 */ 43 export class FrameManager extends EventEmitter<FrameManagerEvents> { 44 #page: CdpPage; 45 #networkManager: NetworkManager; 46 #timeoutSettings: TimeoutSettings; 47 #isolatedWorlds = new Set<string>(); 48 #client: CdpCDPSession; 49 #scriptsToEvaluateOnNewDocument = new Map<string, CdpPreloadScript>(); 50 #bindings = new Set<Binding>(); 51 52 _frameTree = new FrameTree<CdpFrame>(); 53 54 /** 55 * Set of frame IDs stored to indicate if a frame has received a 56 * frameNavigated event so that frame tree responses could be ignored as the 57 * frameNavigated event usually contains the latest information. 58 */ 59 #frameNavigatedReceived = new Set<string>(); 60 61 #deviceRequestPromptManagerMap = new WeakMap< 62 CDPSession, 63 DeviceRequestPromptManager 64 >(); 65 66 #frameTreeHandled?: Deferred<void>; 67 68 get timeoutSettings(): TimeoutSettings { 69 return this.#timeoutSettings; 70 } 71 72 get networkManager(): NetworkManager { 73 return this.#networkManager; 74 } 75 76 get client(): CdpCDPSession { 77 return this.#client; 78 } 79 80 constructor( 81 client: CdpCDPSession, 82 page: CdpPage, 83 timeoutSettings: TimeoutSettings, 84 ) { 85 super(); 86 this.#client = client; 87 this.#page = page; 88 this.#networkManager = new NetworkManager(this); 89 this.#timeoutSettings = timeoutSettings; 90 this.setupEventListeners(this.#client); 91 client.once(CDPSessionEvent.Disconnected, () => { 92 this.#onClientDisconnect().catch(debugError); 93 }); 94 } 95 96 /** 97 * Called when the frame's client is disconnected. We don't know if the 98 * disconnect means that the frame is removed or if it will be replaced by a 99 * new frame. Therefore, we wait for a swap event. 100 */ 101 async #onClientDisconnect() { 102 const mainFrame = this._frameTree.getMainFrame(); 103 if (!mainFrame) { 104 return; 105 } 106 107 if (!this.#page.browser().connected) { 108 // If the browser is not connected we know 109 // that activation will not happen 110 this.#removeFramesRecursively(mainFrame); 111 return; 112 } 113 114 for (const child of mainFrame.childFrames()) { 115 this.#removeFramesRecursively(child); 116 } 117 const swapped = Deferred.create<void>({ 118 timeout: TIME_FOR_WAITING_FOR_SWAP, 119 message: 'Frame was not swapped', 120 }); 121 mainFrame.once(FrameEvent.FrameSwappedByActivation, () => { 122 swapped.resolve(); 123 }); 124 try { 125 await swapped.valueOrThrow(); 126 } catch { 127 this.#removeFramesRecursively(mainFrame); 128 } 129 } 130 131 /** 132 * When the main frame is replaced by another main frame, 133 * we maintain the main frame object identity while updating 134 * its frame tree and ID. 135 */ 136 async swapFrameTree(client: CdpCDPSession): Promise<void> { 137 this.#client = client; 138 const frame = this._frameTree.getMainFrame(); 139 if (frame) { 140 this.#frameNavigatedReceived.add(this.#client.target()._targetId); 141 this._frameTree.removeFrame(frame); 142 frame.updateId(this.#client.target()._targetId); 143 this._frameTree.addFrame(frame); 144 frame.updateClient(client); 145 } 146 this.setupEventListeners(client); 147 client.once(CDPSessionEvent.Disconnected, () => { 148 this.#onClientDisconnect().catch(debugError); 149 }); 150 await this.initialize(client, frame); 151 await this.#networkManager.addClient(client); 152 if (frame) { 153 frame.emit(FrameEvent.FrameSwappedByActivation, undefined); 154 } 155 } 156 157 async registerSpeculativeSession(client: CdpCDPSession): Promise<void> { 158 await this.#networkManager.addClient(client); 159 } 160 161 private setupEventListeners(session: CDPSession) { 162 session.on('Page.frameAttached', async event => { 163 await this.#frameTreeHandled?.valueOrThrow(); 164 this.#onFrameAttached(session, event.frameId, event.parentFrameId); 165 }); 166 session.on('Page.frameNavigated', async event => { 167 this.#frameNavigatedReceived.add(event.frame.id); 168 await this.#frameTreeHandled?.valueOrThrow(); 169 void this.#onFrameNavigated(event.frame, event.type); 170 }); 171 session.on('Page.navigatedWithinDocument', async event => { 172 await this.#frameTreeHandled?.valueOrThrow(); 173 this.#onFrameNavigatedWithinDocument(event.frameId, event.url); 174 }); 175 session.on( 176 'Page.frameDetached', 177 async (event: Protocol.Page.FrameDetachedEvent) => { 178 await this.#frameTreeHandled?.valueOrThrow(); 179 this.#onFrameDetached( 180 event.frameId, 181 event.reason as Protocol.Page.FrameDetachedEventReason, 182 ); 183 }, 184 ); 185 session.on('Page.frameStartedLoading', async event => { 186 await this.#frameTreeHandled?.valueOrThrow(); 187 this.#onFrameStartedLoading(event.frameId); 188 }); 189 session.on('Page.frameStoppedLoading', async event => { 190 await this.#frameTreeHandled?.valueOrThrow(); 191 this.#onFrameStoppedLoading(event.frameId); 192 }); 193 session.on('Runtime.executionContextCreated', async event => { 194 await this.#frameTreeHandled?.valueOrThrow(); 195 this.#onExecutionContextCreated(event.context, session); 196 }); 197 session.on('Page.lifecycleEvent', async event => { 198 await this.#frameTreeHandled?.valueOrThrow(); 199 this.#onLifecycleEvent(event); 200 }); 201 } 202 203 async initialize(client: CDPSession, frame?: CdpFrame | null): Promise<void> { 204 try { 205 this.#frameTreeHandled?.resolve(); 206 this.#frameTreeHandled = Deferred.create(); 207 // We need to schedule all these commands while the target is paused, 208 // therefore, it needs to happen synchronously. At the same time we 209 // should not start processing execution context and frame events before 210 // we received the initial information about the frame tree. 211 await Promise.all([ 212 this.#networkManager.addClient(client), 213 client.send('Page.enable'), 214 client.send('Page.getFrameTree').then(({frameTree}) => { 215 this.#handleFrameTree(client, frameTree); 216 this.#frameTreeHandled?.resolve(); 217 }), 218 client.send('Page.setLifecycleEventsEnabled', {enabled: true}), 219 client.send('Runtime.enable').then(() => { 220 return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME); 221 }), 222 ...(frame 223 ? Array.from(this.#scriptsToEvaluateOnNewDocument.values()) 224 : [] 225 ).map(script => { 226 return frame?.addPreloadScript(script); 227 }), 228 ...(frame ? Array.from(this.#bindings.values()) : []).map(binding => { 229 return frame?.addExposedFunctionBinding(binding); 230 }), 231 ]); 232 } catch (error) { 233 this.#frameTreeHandled?.resolve(); 234 // The target might have been closed before the initialization finished. 235 if (isErrorLike(error) && isTargetClosedError(error)) { 236 return; 237 } 238 239 throw error; 240 } 241 } 242 243 page(): CdpPage { 244 return this.#page; 245 } 246 247 mainFrame(): CdpFrame { 248 const mainFrame = this._frameTree.getMainFrame(); 249 assert(mainFrame, 'Requesting main frame too early!'); 250 return mainFrame; 251 } 252 253 frames(): CdpFrame[] { 254 return Array.from(this._frameTree.frames()); 255 } 256 257 frame(frameId: string): CdpFrame | null { 258 return this._frameTree.getById(frameId) || null; 259 } 260 261 async addExposedFunctionBinding(binding: Binding): Promise<void> { 262 this.#bindings.add(binding); 263 await Promise.all( 264 this.frames().map(async frame => { 265 return await frame.addExposedFunctionBinding(binding); 266 }), 267 ); 268 } 269 270 async removeExposedFunctionBinding(binding: Binding): Promise<void> { 271 this.#bindings.delete(binding); 272 await Promise.all( 273 this.frames().map(async frame => { 274 return await frame.removeExposedFunctionBinding(binding); 275 }), 276 ); 277 } 278 279 async evaluateOnNewDocument( 280 source: string, 281 ): Promise<NewDocumentScriptEvaluation> { 282 const {identifier} = await this.mainFrame() 283 ._client() 284 .send('Page.addScriptToEvaluateOnNewDocument', { 285 source, 286 }); 287 288 const preloadScript = new CdpPreloadScript( 289 this.mainFrame(), 290 identifier, 291 source, 292 ); 293 294 this.#scriptsToEvaluateOnNewDocument.set(identifier, preloadScript); 295 296 await Promise.all( 297 this.frames().map(async frame => { 298 return await frame.addPreloadScript(preloadScript); 299 }), 300 ); 301 302 return {identifier}; 303 } 304 305 async removeScriptToEvaluateOnNewDocument(identifier: string): Promise<void> { 306 const preloadScript = this.#scriptsToEvaluateOnNewDocument.get(identifier); 307 if (!preloadScript) { 308 throw new Error( 309 `Script to evaluate on new document with id ${identifier} not found`, 310 ); 311 } 312 313 this.#scriptsToEvaluateOnNewDocument.delete(identifier); 314 315 await Promise.all( 316 this.frames().map(frame => { 317 const identifier = preloadScript.getIdForFrame(frame); 318 if (!identifier) { 319 return; 320 } 321 return frame 322 ._client() 323 .send('Page.removeScriptToEvaluateOnNewDocument', { 324 identifier, 325 }) 326 .catch(debugError); 327 }), 328 ); 329 } 330 331 onAttachedToTarget(target: CdpTarget): void { 332 if (target._getTargetInfo().type !== 'iframe') { 333 return; 334 } 335 336 const frame = this.frame(target._getTargetInfo().targetId); 337 if (frame) { 338 frame.updateClient(target._session()!); 339 } 340 this.setupEventListeners(target._session()!); 341 void this.initialize(target._session()!, frame); 342 } 343 344 _deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager { 345 let manager = this.#deviceRequestPromptManagerMap.get(client); 346 if (manager === undefined) { 347 manager = new DeviceRequestPromptManager(client, this.#timeoutSettings); 348 this.#deviceRequestPromptManagerMap.set(client, manager); 349 } 350 return manager; 351 } 352 353 #onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { 354 const frame = this.frame(event.frameId); 355 if (!frame) { 356 return; 357 } 358 frame._onLifecycleEvent(event.loaderId, event.name); 359 this.emit(FrameManagerEvent.LifecycleEvent, frame); 360 frame.emit(FrameEvent.LifecycleEvent, undefined); 361 } 362 363 #onFrameStartedLoading(frameId: string): void { 364 const frame = this.frame(frameId); 365 if (!frame) { 366 return; 367 } 368 frame._onLoadingStarted(); 369 } 370 371 #onFrameStoppedLoading(frameId: string): void { 372 const frame = this.frame(frameId); 373 if (!frame) { 374 return; 375 } 376 frame._onLoadingStopped(); 377 this.emit(FrameManagerEvent.LifecycleEvent, frame); 378 frame.emit(FrameEvent.LifecycleEvent, undefined); 379 } 380 381 #handleFrameTree( 382 session: CDPSession, 383 frameTree: Protocol.Page.FrameTree, 384 ): void { 385 if (frameTree.frame.parentId) { 386 this.#onFrameAttached( 387 session, 388 frameTree.frame.id, 389 frameTree.frame.parentId, 390 ); 391 } 392 if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) { 393 void this.#onFrameNavigated(frameTree.frame, 'Navigation'); 394 } else { 395 this.#frameNavigatedReceived.delete(frameTree.frame.id); 396 } 397 398 if (!frameTree.childFrames) { 399 return; 400 } 401 402 for (const child of frameTree.childFrames) { 403 this.#handleFrameTree(session, child); 404 } 405 } 406 407 #onFrameAttached( 408 session: CDPSession, 409 frameId: string, 410 parentFrameId: string, 411 ): void { 412 let frame = this.frame(frameId); 413 if (frame) { 414 const parentFrame = this.frame(parentFrameId); 415 if (session && parentFrame && frame.client !== parentFrame?.client) { 416 // If an OOP iframes becomes a normal iframe 417 // again it is first attached to the parent frame before the 418 // target is removed. 419 frame.updateClient(session); 420 } 421 return; 422 } 423 424 frame = new CdpFrame(this, frameId, parentFrameId, session); 425 this._frameTree.addFrame(frame); 426 this.emit(FrameManagerEvent.FrameAttached, frame); 427 } 428 429 async #onFrameNavigated( 430 framePayload: Protocol.Page.Frame, 431 navigationType: Protocol.Page.NavigationType, 432 ): Promise<void> { 433 const frameId = framePayload.id; 434 const isMainFrame = !framePayload.parentId; 435 436 let frame = this._frameTree.getById(frameId); 437 438 // Detach all child frames first. 439 if (frame) { 440 for (const child of frame.childFrames()) { 441 this.#removeFramesRecursively(child); 442 } 443 } 444 445 // Update or create main frame. 446 if (isMainFrame) { 447 if (frame) { 448 // Update frame id to retain frame identity on cross-process navigation. 449 this._frameTree.removeFrame(frame); 450 frame._id = frameId; 451 } else { 452 // Initial main frame navigation. 453 frame = new CdpFrame(this, frameId, undefined, this.#client); 454 } 455 this._frameTree.addFrame(frame); 456 } 457 458 frame = await this._frameTree.waitForFrame(frameId); 459 frame._navigated(framePayload); 460 this.emit(FrameManagerEvent.FrameNavigated, frame); 461 frame.emit(FrameEvent.FrameNavigated, navigationType); 462 } 463 464 async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> { 465 const key = `${session.id()}:${name}`; 466 467 if (this.#isolatedWorlds.has(key)) { 468 return; 469 } 470 471 await session.send('Page.addScriptToEvaluateOnNewDocument', { 472 source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`, 473 worldName: name, 474 }); 475 476 await Promise.all( 477 this.frames() 478 .filter(frame => { 479 return frame.client === session; 480 }) 481 .map(frame => { 482 // Frames might be removed before we send this, so we don't want to 483 // throw an error. 484 return session 485 .send('Page.createIsolatedWorld', { 486 frameId: frame._id, 487 worldName: name, 488 grantUniveralAccess: true, 489 }) 490 .catch(debugError); 491 }), 492 ); 493 494 this.#isolatedWorlds.add(key); 495 } 496 497 #onFrameNavigatedWithinDocument(frameId: string, url: string): void { 498 const frame = this.frame(frameId); 499 if (!frame) { 500 return; 501 } 502 frame._navigatedWithinDocument(url); 503 this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame); 504 frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined); 505 this.emit(FrameManagerEvent.FrameNavigated, frame); 506 frame.emit(FrameEvent.FrameNavigated, 'Navigation'); 507 } 508 509 #onFrameDetached( 510 frameId: string, 511 reason: Protocol.Page.FrameDetachedEventReason, 512 ): void { 513 const frame = this.frame(frameId); 514 if (!frame) { 515 return; 516 } 517 switch (reason) { 518 case 'remove': 519 // Only remove the frame if the reason for the detached event is 520 // an actual removement of the frame. 521 // For frames that become OOP iframes, the reason would be 'swap'. 522 this.#removeFramesRecursively(frame); 523 break; 524 case 'swap': 525 this.emit(FrameManagerEvent.FrameSwapped, frame); 526 frame.emit(FrameEvent.FrameSwapped, undefined); 527 break; 528 } 529 } 530 531 #onExecutionContextCreated( 532 contextPayload: Protocol.Runtime.ExecutionContextDescription, 533 session: CDPSession, 534 ): void { 535 const auxData = contextPayload.auxData as {frameId?: string} | undefined; 536 const frameId = auxData && auxData.frameId; 537 const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined; 538 let world: IsolatedWorld | undefined; 539 if (frame) { 540 // Only care about execution contexts created for the current session. 541 if (frame.client !== session) { 542 return; 543 } 544 if (contextPayload.auxData && contextPayload.auxData['isDefault']) { 545 world = frame.worlds[MAIN_WORLD]; 546 } else if (contextPayload.name === UTILITY_WORLD_NAME) { 547 // In case of multiple sessions to the same target, there's a race between 548 // connections so we might end up creating multiple isolated worlds. 549 // We can use either. 550 world = frame.worlds[PUPPETEER_WORLD]; 551 } 552 } 553 // If there is no world, the context is not meant to be handled by us. 554 if (!world) { 555 return; 556 } 557 const context = new ExecutionContext( 558 frame?.client || this.#client, 559 contextPayload, 560 world, 561 ); 562 world.setContext(context); 563 } 564 565 #removeFramesRecursively(frame: CdpFrame): void { 566 for (const child of frame.childFrames()) { 567 this.#removeFramesRecursively(child); 568 } 569 frame[disposeSymbol](); 570 this._frameTree.removeFrame(frame); 571 this.emit(FrameManagerEvent.FrameDetached, frame); 572 frame.emit(FrameEvent.FrameDetached, frame); 573 } 574 }