583 lines
18 KiB
TypeScript
583 lines
18 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2017 Google Inc.
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type {Protocol} from 'devtools-protocol';
|
|
|
|
import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
|
|
import {FrameEvent} from '../api/Frame.js';
|
|
import type {NewDocumentScriptEvaluation} from '../api/Page.js';
|
|
import {EventEmitter} from '../common/EventEmitter.js';
|
|
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
|
|
import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js';
|
|
import {assert} from '../util/assert.js';
|
|
import {Deferred} from '../util/Deferred.js';
|
|
import {disposeSymbol} from '../util/disposable.js';
|
|
import {isErrorLike} from '../util/ErrorLike.js';
|
|
|
|
import type {Binding} from './Binding.js';
|
|
import {CdpPreloadScript} from './CdpPreloadScript.js';
|
|
import type {CdpCDPSession} from './CdpSession.js';
|
|
import {isTargetClosedError} from './Connection.js';
|
|
import {CdpDeviceRequestPromptManager} from './DeviceRequestPrompt.js';
|
|
import {ExecutionContext} from './ExecutionContext.js';
|
|
import {CdpFrame} from './Frame.js';
|
|
import type {FrameManagerEvents} from './FrameManagerEvents.js';
|
|
import {FrameManagerEvent} from './FrameManagerEvents.js';
|
|
import {FrameTree} from './FrameTree.js';
|
|
import type {IsolatedWorld} from './IsolatedWorld.js';
|
|
import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
|
|
import {NetworkManager} from './NetworkManager.js';
|
|
import type {CdpPage} from './Page.js';
|
|
import type {CdpTarget} from './Target.js';
|
|
|
|
const TIME_FOR_WAITING_FOR_SWAP = 100; // ms.
|
|
|
|
/**
|
|
* A frame manager manages the frames for a given {@link Page | page}.
|
|
*
|
|
* @internal
|
|
*/
|
|
export class FrameManager extends EventEmitter<FrameManagerEvents> {
|
|
#page: CdpPage;
|
|
#networkManager: NetworkManager;
|
|
#timeoutSettings: TimeoutSettings;
|
|
#isolatedWorlds = new Set<string>();
|
|
#client: CdpCDPSession;
|
|
#scriptsToEvaluateOnNewDocument = new Map<string, CdpPreloadScript>();
|
|
#bindings = new Set<Binding>();
|
|
|
|
_frameTree = new FrameTree<CdpFrame>();
|
|
|
|
/**
|
|
* Set of frame IDs stored to indicate if a frame has received a
|
|
* frameNavigated event so that frame tree responses could be ignored as the
|
|
* frameNavigated event usually contains the latest information.
|
|
*/
|
|
#frameNavigatedReceived = new Set<string>();
|
|
|
|
#deviceRequestPromptManagerMap = new WeakMap<
|
|
CDPSession,
|
|
CdpDeviceRequestPromptManager
|
|
>();
|
|
|
|
#frameTreeHandled?: Deferred<void>;
|
|
|
|
get timeoutSettings(): TimeoutSettings {
|
|
return this.#timeoutSettings;
|
|
}
|
|
|
|
get networkManager(): NetworkManager {
|
|
return this.#networkManager;
|
|
}
|
|
|
|
get client(): CdpCDPSession {
|
|
return this.#client;
|
|
}
|
|
|
|
constructor(
|
|
client: CdpCDPSession,
|
|
page: CdpPage,
|
|
timeoutSettings: TimeoutSettings,
|
|
) {
|
|
super();
|
|
this.#client = client;
|
|
this.#page = page;
|
|
this.#networkManager = new NetworkManager(
|
|
this,
|
|
page.browser().isNetworkEnabled(),
|
|
);
|
|
this.#timeoutSettings = timeoutSettings;
|
|
this.setupEventListeners(this.#client);
|
|
client.once(CDPSessionEvent.Disconnected, () => {
|
|
this.#onClientDisconnect().catch(debugError);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called when the frame's client is disconnected. We don't know if the
|
|
* disconnect means that the frame is removed or if it will be replaced by a
|
|
* new frame. Therefore, we wait for a swap event.
|
|
*/
|
|
async #onClientDisconnect() {
|
|
const mainFrame = this._frameTree.getMainFrame();
|
|
if (!mainFrame) {
|
|
return;
|
|
}
|
|
|
|
if (!this.#page.browser().connected) {
|
|
// If the browser is not connected we know
|
|
// that activation will not happen
|
|
this.#removeFramesRecursively(mainFrame);
|
|
return;
|
|
}
|
|
|
|
for (const child of mainFrame.childFrames()) {
|
|
this.#removeFramesRecursively(child);
|
|
}
|
|
const swapped = Deferred.create<void>({
|
|
timeout: TIME_FOR_WAITING_FOR_SWAP,
|
|
message: 'Frame was not swapped',
|
|
});
|
|
mainFrame.once(FrameEvent.FrameSwappedByActivation, () => {
|
|
swapped.resolve();
|
|
});
|
|
try {
|
|
await swapped.valueOrThrow();
|
|
} catch {
|
|
this.#removeFramesRecursively(mainFrame);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When the main frame is replaced by another main frame,
|
|
* we maintain the main frame object identity while updating
|
|
* its frame tree and ID.
|
|
*/
|
|
async swapFrameTree(client: CdpCDPSession): Promise<void> {
|
|
this.#client = client;
|
|
const frame = this._frameTree.getMainFrame();
|
|
if (frame) {
|
|
this.#frameNavigatedReceived.add(this.#client.target()._targetId);
|
|
this._frameTree.removeFrame(frame);
|
|
frame.updateId(this.#client.target()._targetId);
|
|
this._frameTree.addFrame(frame);
|
|
frame.updateClient(client);
|
|
}
|
|
this.setupEventListeners(client);
|
|
client.once(CDPSessionEvent.Disconnected, () => {
|
|
this.#onClientDisconnect().catch(debugError);
|
|
});
|
|
await this.initialize(client, frame);
|
|
await this.#networkManager.addClient(client);
|
|
if (frame) {
|
|
frame.emit(FrameEvent.FrameSwappedByActivation, undefined);
|
|
}
|
|
}
|
|
|
|
async registerSpeculativeSession(client: CdpCDPSession): Promise<void> {
|
|
await this.#networkManager.addClient(client);
|
|
}
|
|
|
|
private setupEventListeners(session: CDPSession) {
|
|
session.on('Page.frameAttached', async event => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onFrameAttached(session, event.frameId, event.parentFrameId);
|
|
});
|
|
session.on('Page.frameNavigated', async event => {
|
|
this.#frameNavigatedReceived.add(event.frame.id);
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
void this.#onFrameNavigated(event.frame, event.type);
|
|
});
|
|
session.on('Page.navigatedWithinDocument', async event => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onFrameNavigatedWithinDocument(event.frameId, event.url);
|
|
});
|
|
session.on(
|
|
'Page.frameDetached',
|
|
async (event: Protocol.Page.FrameDetachedEvent) => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onFrameDetached(
|
|
event.frameId,
|
|
event.reason as Protocol.Page.FrameDetachedEventReason,
|
|
);
|
|
},
|
|
);
|
|
session.on('Page.frameStartedLoading', async event => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onFrameStartedLoading(event.frameId);
|
|
});
|
|
session.on('Page.frameStoppedLoading', async event => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onFrameStoppedLoading(event.frameId);
|
|
});
|
|
session.on('Runtime.executionContextCreated', async event => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onExecutionContextCreated(event.context, session);
|
|
});
|
|
session.on('Page.lifecycleEvent', async event => {
|
|
await this.#frameTreeHandled?.valueOrThrow();
|
|
this.#onLifecycleEvent(event);
|
|
});
|
|
}
|
|
|
|
async initialize(client: CDPSession, frame?: CdpFrame | null): Promise<void> {
|
|
try {
|
|
this.#frameTreeHandled?.resolve();
|
|
this.#frameTreeHandled = Deferred.create();
|
|
// We need to schedule all these commands while the target is paused,
|
|
// therefore, it needs to happen synchronously. At the same time we
|
|
// should not start processing execution context and frame events before
|
|
// we received the initial information about the frame tree.
|
|
await Promise.all([
|
|
this.#networkManager.addClient(client),
|
|
client.send('Page.enable'),
|
|
client.send('Page.getFrameTree').then(({frameTree}) => {
|
|
this.#handleFrameTree(client, frameTree);
|
|
this.#frameTreeHandled?.resolve();
|
|
}),
|
|
client.send('Page.setLifecycleEventsEnabled', {enabled: true}),
|
|
client.send('Runtime.enable').then(() => {
|
|
return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME);
|
|
}),
|
|
...(frame
|
|
? Array.from(this.#scriptsToEvaluateOnNewDocument.values())
|
|
: []
|
|
).map(script => {
|
|
return frame?.addPreloadScript(script);
|
|
}),
|
|
...(frame ? Array.from(this.#bindings.values()) : []).map(binding => {
|
|
return frame?.addExposedFunctionBinding(binding);
|
|
}),
|
|
]);
|
|
} catch (error) {
|
|
this.#frameTreeHandled?.resolve();
|
|
// The target might have been closed before the initialization finished.
|
|
if (isErrorLike(error) && isTargetClosedError(error)) {
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
page(): CdpPage {
|
|
return this.#page;
|
|
}
|
|
|
|
mainFrame(): CdpFrame {
|
|
const mainFrame = this._frameTree.getMainFrame();
|
|
assert(mainFrame, 'Requesting main frame too early!');
|
|
return mainFrame;
|
|
}
|
|
|
|
frames(): CdpFrame[] {
|
|
return Array.from(this._frameTree.frames());
|
|
}
|
|
|
|
frame(frameId: string): CdpFrame | null {
|
|
return this._frameTree.getById(frameId) || null;
|
|
}
|
|
|
|
async addExposedFunctionBinding(binding: Binding): Promise<void> {
|
|
this.#bindings.add(binding);
|
|
await Promise.all(
|
|
this.frames().map(async frame => {
|
|
return await frame.addExposedFunctionBinding(binding);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async removeExposedFunctionBinding(binding: Binding): Promise<void> {
|
|
this.#bindings.delete(binding);
|
|
await Promise.all(
|
|
this.frames().map(async frame => {
|
|
return await frame.removeExposedFunctionBinding(binding);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async evaluateOnNewDocument(
|
|
source: string,
|
|
): Promise<NewDocumentScriptEvaluation> {
|
|
const {identifier} = await this.mainFrame()
|
|
._client()
|
|
.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
source,
|
|
});
|
|
|
|
const preloadScript = new CdpPreloadScript(
|
|
this.mainFrame(),
|
|
identifier,
|
|
source,
|
|
);
|
|
|
|
this.#scriptsToEvaluateOnNewDocument.set(identifier, preloadScript);
|
|
|
|
await Promise.all(
|
|
this.frames().map(async frame => {
|
|
return await frame.addPreloadScript(preloadScript);
|
|
}),
|
|
);
|
|
|
|
return {identifier};
|
|
}
|
|
|
|
async removeScriptToEvaluateOnNewDocument(identifier: string): Promise<void> {
|
|
const preloadScript = this.#scriptsToEvaluateOnNewDocument.get(identifier);
|
|
if (!preloadScript) {
|
|
throw new Error(
|
|
`Script to evaluate on new document with id ${identifier} not found`,
|
|
);
|
|
}
|
|
|
|
this.#scriptsToEvaluateOnNewDocument.delete(identifier);
|
|
|
|
await Promise.all(
|
|
this.frames().map(frame => {
|
|
const identifier = preloadScript.getIdForFrame(frame);
|
|
if (!identifier) {
|
|
return;
|
|
}
|
|
return frame
|
|
._client()
|
|
.send('Page.removeScriptToEvaluateOnNewDocument', {
|
|
identifier,
|
|
})
|
|
.catch(debugError);
|
|
}),
|
|
);
|
|
}
|
|
|
|
onAttachedToTarget(target: CdpTarget): void {
|
|
if (target._getTargetInfo().type !== 'iframe') {
|
|
return;
|
|
}
|
|
|
|
const frame = this.frame(target._getTargetInfo().targetId);
|
|
if (frame) {
|
|
frame.updateClient(target._session()!);
|
|
}
|
|
this.setupEventListeners(target._session()!);
|
|
void this.initialize(target._session()!, frame);
|
|
}
|
|
|
|
_deviceRequestPromptManager(
|
|
client: CDPSession,
|
|
): CdpDeviceRequestPromptManager {
|
|
let manager = this.#deviceRequestPromptManagerMap.get(client);
|
|
if (manager === undefined) {
|
|
manager = new CdpDeviceRequestPromptManager(
|
|
client,
|
|
this.#timeoutSettings,
|
|
);
|
|
this.#deviceRequestPromptManagerMap.set(client, manager);
|
|
}
|
|
return manager;
|
|
}
|
|
|
|
#onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void {
|
|
const frame = this.frame(event.frameId);
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
frame._onLifecycleEvent(event.loaderId, event.name);
|
|
this.emit(FrameManagerEvent.LifecycleEvent, frame);
|
|
frame.emit(FrameEvent.LifecycleEvent, undefined);
|
|
}
|
|
|
|
#onFrameStartedLoading(frameId: string): void {
|
|
const frame = this.frame(frameId);
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
frame._onLoadingStarted();
|
|
}
|
|
|
|
#onFrameStoppedLoading(frameId: string): void {
|
|
const frame = this.frame(frameId);
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
frame._onLoadingStopped();
|
|
this.emit(FrameManagerEvent.LifecycleEvent, frame);
|
|
frame.emit(FrameEvent.LifecycleEvent, undefined);
|
|
}
|
|
|
|
#handleFrameTree(
|
|
session: CDPSession,
|
|
frameTree: Protocol.Page.FrameTree,
|
|
): void {
|
|
if (frameTree.frame.parentId) {
|
|
this.#onFrameAttached(
|
|
session,
|
|
frameTree.frame.id,
|
|
frameTree.frame.parentId,
|
|
);
|
|
}
|
|
if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) {
|
|
void this.#onFrameNavigated(frameTree.frame, 'Navigation');
|
|
} else {
|
|
this.#frameNavigatedReceived.delete(frameTree.frame.id);
|
|
}
|
|
|
|
if (!frameTree.childFrames) {
|
|
return;
|
|
}
|
|
|
|
for (const child of frameTree.childFrames) {
|
|
this.#handleFrameTree(session, child);
|
|
}
|
|
}
|
|
|
|
#onFrameAttached(
|
|
session: CDPSession,
|
|
frameId: string,
|
|
parentFrameId: string,
|
|
): void {
|
|
let frame = this.frame(frameId);
|
|
if (frame) {
|
|
const parentFrame = this.frame(parentFrameId);
|
|
if (session && parentFrame && frame.client !== parentFrame?.client) {
|
|
// If an OOP iframes becomes a normal iframe
|
|
// again it is first attached to the parent frame before the
|
|
// target is removed.
|
|
frame.updateClient(session);
|
|
}
|
|
return;
|
|
}
|
|
|
|
frame = new CdpFrame(this, frameId, parentFrameId, session);
|
|
this._frameTree.addFrame(frame);
|
|
this.emit(FrameManagerEvent.FrameAttached, frame);
|
|
}
|
|
|
|
async #onFrameNavigated(
|
|
framePayload: Protocol.Page.Frame,
|
|
navigationType: Protocol.Page.NavigationType,
|
|
): Promise<void> {
|
|
const frameId = framePayload.id;
|
|
const isMainFrame = !framePayload.parentId;
|
|
|
|
let frame = this._frameTree.getById(frameId);
|
|
|
|
// Detach all child frames first.
|
|
if (frame) {
|
|
for (const child of frame.childFrames()) {
|
|
this.#removeFramesRecursively(child);
|
|
}
|
|
}
|
|
|
|
// Update or create main frame.
|
|
if (isMainFrame) {
|
|
if (frame) {
|
|
// Update frame id to retain frame identity on cross-process navigation.
|
|
this._frameTree.removeFrame(frame);
|
|
frame._id = frameId;
|
|
} else {
|
|
// Initial main frame navigation.
|
|
frame = new CdpFrame(this, frameId, undefined, this.#client);
|
|
}
|
|
this._frameTree.addFrame(frame);
|
|
}
|
|
|
|
frame = await this._frameTree.waitForFrame(frameId);
|
|
frame._navigated(framePayload);
|
|
this.emit(FrameManagerEvent.FrameNavigated, frame);
|
|
frame.emit(FrameEvent.FrameNavigated, navigationType);
|
|
}
|
|
|
|
async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> {
|
|
const key = `${session.id()}:${name}`;
|
|
|
|
if (this.#isolatedWorlds.has(key)) {
|
|
return;
|
|
}
|
|
|
|
await session.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`,
|
|
worldName: name,
|
|
});
|
|
|
|
await Promise.all(
|
|
this.frames()
|
|
.filter(frame => {
|
|
return frame.client === session;
|
|
})
|
|
.map(frame => {
|
|
// Frames might be removed before we send this, so we don't want to
|
|
// throw an error.
|
|
return session
|
|
.send('Page.createIsolatedWorld', {
|
|
frameId: frame._id,
|
|
worldName: name,
|
|
grantUniveralAccess: true,
|
|
})
|
|
.catch(debugError);
|
|
}),
|
|
);
|
|
|
|
this.#isolatedWorlds.add(key);
|
|
}
|
|
|
|
#onFrameNavigatedWithinDocument(frameId: string, url: string): void {
|
|
const frame = this.frame(frameId);
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
frame._navigatedWithinDocument(url);
|
|
this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame);
|
|
frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined);
|
|
this.emit(FrameManagerEvent.FrameNavigated, frame);
|
|
frame.emit(FrameEvent.FrameNavigated, 'Navigation');
|
|
}
|
|
|
|
#onFrameDetached(
|
|
frameId: string,
|
|
reason: Protocol.Page.FrameDetachedEventReason,
|
|
): void {
|
|
const frame = this.frame(frameId);
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
switch (reason) {
|
|
case 'remove':
|
|
// Only remove the frame if the reason for the detached event is
|
|
// an actual removement of the frame.
|
|
// For frames that become OOP iframes, the reason would be 'swap'.
|
|
this.#removeFramesRecursively(frame);
|
|
break;
|
|
case 'swap':
|
|
this.emit(FrameManagerEvent.FrameSwapped, frame);
|
|
frame.emit(FrameEvent.FrameSwapped, undefined);
|
|
break;
|
|
}
|
|
}
|
|
|
|
#onExecutionContextCreated(
|
|
contextPayload: Protocol.Runtime.ExecutionContextDescription,
|
|
session: CDPSession,
|
|
): void {
|
|
const auxData = contextPayload.auxData as {frameId?: string} | undefined;
|
|
const frameId = auxData && auxData.frameId;
|
|
const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined;
|
|
let world: IsolatedWorld | undefined;
|
|
if (frame) {
|
|
// Only care about execution contexts created for the current session.
|
|
if (frame.client !== session) {
|
|
return;
|
|
}
|
|
if (contextPayload.auxData && contextPayload.auxData['isDefault']) {
|
|
world = frame.worlds[MAIN_WORLD];
|
|
} else if (contextPayload.name === UTILITY_WORLD_NAME) {
|
|
// In case of multiple sessions to the same target, there's a race between
|
|
// connections so we might end up creating multiple isolated worlds.
|
|
// We can use either.
|
|
world = frame.worlds[PUPPETEER_WORLD];
|
|
}
|
|
}
|
|
// If there is no world, the context is not meant to be handled by us.
|
|
if (!world) {
|
|
return;
|
|
}
|
|
const context = new ExecutionContext(
|
|
frame?.client || this.#client,
|
|
contextPayload,
|
|
world,
|
|
);
|
|
world.setContext(context);
|
|
}
|
|
|
|
#removeFramesRecursively(frame: CdpFrame): void {
|
|
for (const child of frame.childFrames()) {
|
|
this.#removeFramesRecursively(child);
|
|
}
|
|
frame[disposeSymbol]();
|
|
this._frameTree.removeFrame(frame);
|
|
this.emit(FrameManagerEvent.FrameDetached, frame);
|
|
frame.emit(FrameEvent.FrameDetached, frame);
|
|
}
|
|
}
|