import {
ILifecycleEvents,
IPuppetFrame,
IPuppetFrameEvents,
} from '@secret-agent/interfaces/IPuppetFrame';
import { URL } from 'url';
import Protocol from 'devtools-protocol';
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
import { NavigationReason } from '@secret-agent/interfaces/INavigation';
import { IBoundLog } from '@secret-agent/interfaces/ILog';
import Resolvable from '@secret-agent/commons/Resolvable';
import ProtocolError from './ProtocolError';
import { DevtoolsSession } from './DevtoolsSession';
import ConsoleMessage from './ConsoleMessage';
import { DEFAULT_PAGE, ISOLATED_WORLD } from './FramesManager';
import { NavigationLoader } from './NavigationLoader';
import PageFrame = Protocol.Page.Frame;
const ContextNotFoundCode = -32000;
const InPageNavigationLoaderPrefix = 'inpage';
export default class Frame extends TypedEventEmitter<IPuppetFrameEvents> implements IPuppetFrame {
public get id(): string {
return this.internalFrame.id;
}
public get name(): string {
return this.internalFrame.name ?? '';
}
public get parentId(): string {
return this.internalFrame.parentId;
}
public url: string;
public get isDefaultUrl(): boolean {
return !this.url || this.url === ':' || this.url === DEFAULT_PAGE;
}
public get securityOrigin(): string {
if (!this.activeLoader?.isNavigationComplete || this.isDefaultUrl) return '';
let origin = this.internalFrame.securityOrigin;
if (!origin || origin === '://') {
this.internalFrame.securityOrigin = new URL(this.url).origin;
origin = this.internalFrame.securityOrigin;
}
return origin;
}
public navigationReason?: string;
public disposition?: string;
public get isAttached(): boolean {
return this.checkIfAttached();
}
public get activeLoader(): NavigationLoader {
return this.navigationLoadersById[this.activeLoaderId];
}
public activeLoaderId: string;
public navigationLoadersById: { [loaderId: string]: NavigationLoader } = {};
protected readonly logger: IBoundLog;
private isolatedWorldElementObjectId?: string;
private readonly parentFrame: Frame | null;
private readonly devtoolsSession: DevtoolsSession;
private defaultLoaderId: string;
private startedLoaderId: string;
private defaultContextId: number;
private isolatedContextId: number;
private readonly activeContextIds: Set<number>;
private internalFrame: PageFrame;
private closedWithError: Error;
private defaultContextCreated: Resolvable<void>;
private readonly checkIfAttached: () => boolean;
private inPageCounter = 0;
constructor(
internalFrame: PageFrame,
activeContextIds: Set<number>,
devtoolsSession: DevtoolsSession,
logger: IBoundLog,
checkIfAttached: () => boolean,
parentFrame: Frame | null,
) {
super();
this.activeContextIds = activeContextIds;
this.devtoolsSession = devtoolsSession;
this.logger = logger.createChild(module);
this.parentFrame = parentFrame;
this.checkIfAttached = checkIfAttached;
this.setEventsToLog(['frame-requested-navigation', 'frame-navigated', 'frame-lifecycle']);
this.storeEventsWithoutListeners = true;
this.onAttached(internalFrame);
}
public close(error: Error) {
this.cancelPendingEvents('Frame closed');
error ??= new CanceledPromiseError('Frame closed');
this.activeLoader.setNavigationResult(error);
this.defaultContextCreated?.reject(error);
this.closedWithError = error;
}
public async evaluate<T>(
expression: string,
isolateFromWebPageEnvironment?: boolean,
options?: { shouldAwaitExpression?: boolean; retriesWaitingForLoad?: number },
): Promise<T> {
if (this.closedWithError) throw this.closedWithError;
const startUrl = this.url;
const startOrigin = this.securityOrigin;
const contextId = await this.waitForActiveContextId(isolateFromWebPageEnvironment);
try {
const result: Protocol.Runtime.EvaluateResponse = await this.devtoolsSession.send(
'Runtime.evaluate',
{
expression,
contextId,
returnByValue: true,
awaitPromise: options?.shouldAwaitExpression ?? true,
},
this,
);
if (result.exceptionDetails) {
throw ConsoleMessage.exceptionToError(result.exceptionDetails);
}
const remote = result.result;
if (remote.objectId) this.devtoolsSession.disposeRemoteObject(remote);
return remote.value as T;
} catch (err) {
let retries = options?.retriesWaitingForLoad ?? 0;
if (
(!startOrigin || this.url !== startUrl) &&
this.getActiveContextId(isolateFromWebPageEnvironment) !== contextId
) {
retries += 1;
}
const isNotFoundError =
err.code === ContextNotFoundCode ||
(err as ProtocolError).remoteError?.code === ContextNotFoundCode;
if (isNotFoundError) {
if (retries > 0) {
return this.evaluate(expression, isolateFromWebPageEnvironment, {
shouldAwaitExpression: options?.shouldAwaitExpression,
retriesWaitingForLoad: retries - 1,
});
}
throw new CanceledPromiseError('The page context to evaluate javascript was not found');
}
throw err;
}
}
public async waitForLifecycleEvent(
event: keyof ILifecycleEvents = 'load',
loaderId?: string,
timeoutMs = 30e3,
): Promise<void> {
event ??= 'load';
timeoutMs ??= 30e3;
await this.waitForLoader(loaderId, timeoutMs);
const loader = this.navigationLoadersById[loaderId ?? this.activeLoaderId];
if (loader.lifecycle[event]) return;
await this.waitOn(
'frame-lifecycle',
x => {
if (loaderId && x.loader.id !== loaderId) return false;
return x.name === event;
},
timeoutMs,
);
}
public async setFileInputFiles(objectId: string, files: string[]): Promise<void> {
await this.devtoolsSession.send('DOM.setFileInputFiles', {
objectId,
files,
});
}
public async evaluateOnNode<T>(nodeId: string, expression: string): Promise<T> {
if (this.closedWithError) throw this.closedWithError;
try {
const result = await this.devtoolsSession.send('Runtime.callFunctionOn', {
functionDeclaration: `function executeRemoteFn() {
return ${expression};
}`,
returnByValue: true,
objectId: nodeId,
});
if (result.exceptionDetails) {
throw ConsoleMessage.exceptionToError(result.exceptionDetails);
}
const remote = result.result;
if (remote.objectId) this.devtoolsSession.disposeRemoteObject(remote);
return remote.value as T;
} catch (err) {
if (err instanceof CanceledPromiseError) return;
throw err;
}
}
public async getFrameElementNodeId(): Promise<string> {
try {
if (!this.parentFrame || this.isolatedWorldElementObjectId)
return this.isolatedWorldElementObjectId;
const owner = await this.devtoolsSession.send('DOM.getFrameOwner', { frameId: this.id });
this.isolatedWorldElementObjectId = await this.parentFrame.resolveNodeId(owner.backendNodeId);
return this.isolatedWorldElementObjectId;
} catch (error) {
this.logger.info('Failed to lookup isolated node', {
frameId: this.id,
error,
});
}
}
public async resolveNodeId(backendNodeId: number): Promise<string> {
const result = await this.devtoolsSession.send('DOM.resolveNode', {
backendNodeId,
executionContextId: this.getActiveContextId(true),
});
return result.object.objectId;
}
public initiateNavigation(url: string, loaderId: string): void {
this.setLoader(loaderId, url);
}
public requestedNavigation(url: string, reason: NavigationReason, disposition: string): void {
this.navigationReason = reason;
this.disposition = disposition;
this.emit('frame-requested-navigation', { frame: this, url, reason });
}
public onAttached(internalFrame: PageFrame): void {
this.internalFrame = internalFrame;
this.updateUrl();
if (!internalFrame.loaderId) return;
if (
this.isDefaultUrl &&
!this.defaultLoaderId &&
Object.keys(this.navigationLoadersById).length === 0
) {
this.defaultLoaderId = internalFrame.loaderId;
}
this.setLoader(internalFrame.loaderId);
if (this.url || internalFrame.unreachableUrl) {
this.navigationLoadersById[internalFrame.loaderId].setNavigationResult(internalFrame.url);
}
}
public onNavigated(frame: PageFrame): void {
this.internalFrame = frame;
this.updateUrl();
const loader = this.navigationLoadersById[frame.loaderId] ?? this.activeLoader;
if (frame.unreachableUrl) {
loader.setNavigationResult(
new Error(`Unreachable url for navigation "${frame.unreachableUrl}"`),
);
} else {
loader.setNavigationResult(frame.url);
}
this.emit('frame-navigated', { frame: this, loaderId: frame.loaderId });
}
public onNavigatedWithinDocument(url: string): void {
if (this.url === url) return;
if (url.startsWith(DEFAULT_PAGE)) url = DEFAULT_PAGE;
this.url = url;
const isDomLoaded = this.activeLoader?.lifecycle?.DOMContentLoaded;
const loaderId = `${InPageNavigationLoaderPrefix}${(this.inPageCounter += 1)}`;
this.setLoader(loaderId, url);
if (isDomLoaded) {
this.activeLoader.markLoaded();
}
this.emit('frame-navigated', { frame: this, navigatedInDocument: true, loaderId });
}
public onStoppedLoading(): void {
if (!this.startedLoaderId) return;
const loader = this.navigationLoadersById[this.startedLoaderId];
loader?.onStoppedLoading();
}
public async waitForLoader(loaderId?: string, timeoutMs?: number): Promise<Error | null> {
if (!loaderId) {
loaderId = this.activeLoaderId;
if (loaderId === this.defaultLoaderId) {
const frameLoader = await this.waitOn('frame-loader-created', null, timeoutMs ?? 60e3);
loaderId = frameLoader.loaderId;
}
}
const hasLoaderError = await this.navigationLoadersById[loaderId]?.navigationResolver;
if (hasLoaderError instanceof Error) return hasLoaderError;
if (!this.getActiveContextId(false)) {
await this.waitForDefaultContext();
}
}
public onLifecycleEvent(name: string, pageLoaderId?: string): void {
const loaderId = pageLoaderId ?? this.activeLoaderId;
if (name === 'init' && pageLoaderId) {
if (
this.activeLoaderId &&
this.activeLoaderId !== pageLoaderId &&
!this.activeLoader.lifecycle.init &&
!this.activeLoader.isNavigationComplete
) {
this.activeLoader.setNavigationResult(new CanceledPromiseError('Navigation canceled'));
}
this.startedLoaderId = pageLoaderId;
}
if (!this.navigationLoadersById[loaderId]) {
this.setLoader(loaderId);
}
this.navigationLoadersById[loaderId].onLifecycleEvent(name);
if (loaderId !== this.activeLoaderId) {
let checkLoaderForInPage = false;
for (const [historicalLoaderId, loader] of Object.entries(this.navigationLoadersById)) {
if (loaderId === historicalLoaderId) {
checkLoaderForInPage = true;
}
if (checkLoaderForInPage && historicalLoaderId.startsWith(InPageNavigationLoaderPrefix)) {
loader.onLifecycleEvent(name);
this.emit('frame-lifecycle', { frame: this, name, loader });
}
}
}
if (loaderId !== this.defaultLoaderId) {
this.emit('frame-lifecycle', {
frame: this,
name,
loader: this.navigationLoadersById[loaderId],
});
}
}
public hasContextId(executionContextId: number): boolean {
return (
this.defaultContextId === executionContextId || this.isolatedContextId === executionContextId
);
}
public removeContextId(executionContextId: number): void {
if (this.defaultContextId === executionContextId) this.defaultContextId = null;
if (this.isolatedContextId === executionContextId) this.isolatedContextId = null;
}
public clearContextIds(): void {
this.defaultContextId = null;
this.isolatedContextId = null;
}
public addContextId(executionContextId: number, isDefault: boolean): void {
if (isDefault) {
this.defaultContextId = executionContextId;
this.defaultContextCreated?.resolve();
} else {
this.isolatedContextId = executionContextId;
}
}
public getActiveContextId(isolatedContext: boolean): number | undefined {
let id: number;
if (isolatedContext) {
id = this.isolatedContextId;
} else {
id = this.defaultContextId;
}
if (id && this.activeContextIds.has(id)) return id;
}
public async waitForActiveContextId(isolatedContext = true): Promise<number> {
if (!this.isAttached) throw new Error('Execution Context is not available in detached frame');
const existing = this.getActiveContextId(isolatedContext);
if (existing) return existing;
if (isolatedContext) {
const context = await this.createIsolatedWorld();
await new Promise(setImmediate);
return context;
}
await this.waitForDefaultContext();
return this.getActiveContextId(isolatedContext);
}
public canEvaluate(isolatedFromWebPageEnvironment: boolean): boolean {
return this.getActiveContextId(isolatedFromWebPageEnvironment) !== undefined;
}
public toJSON() {
return {
id: this.id,
parentId: this.parentId,
name: this.name,
url: this.url,
navigationReason: this.navigationReason,
disposition: this.disposition,
activeLoader: this.activeLoader,
};
}
private setLoader(loaderId: string, url?: string): void {
if (!loaderId) return;
if (loaderId === this.activeLoaderId) return;
if (this.navigationLoadersById[loaderId]) return;
this.activeLoaderId = loaderId;
this.logger.info('Queuing new navigation loader', {
loaderId,
frameId: this.id,
});
this.navigationLoadersById[loaderId] = new NavigationLoader(loaderId, this.logger);
if (url) this.navigationLoadersById[loaderId].url = url;
this.emit('frame-loader-created', {
frame: this,
loaderId,
});
}
private async createIsolatedWorld(): Promise<number> {
try {
if (!this.isAttached) return;
const isolatedWorld = await this.devtoolsSession.send(
'Page.createIsolatedWorld',
{
frameId: this.id,
worldName: ISOLATED_WORLD,
grantUniveralAccess: true,
},
this,
);
const { executionContextId } = isolatedWorld;
if (!this.activeContextIds.has(executionContextId)) {
this.activeContextIds.add(executionContextId);
this.addContextId(executionContextId, false);
this.getFrameElementNodeId().catch(() => null);
}
return executionContextId;
} catch (error) {
if (error instanceof CanceledPromiseError) {
return;
}
if (error instanceof ProtocolError) {
if (error.remoteError?.code === ContextNotFoundCode) {
if (!this.isAttached) return;
}
}
this.logger.warn('Failed to create isolated world.', {
frameId: this.id,
error,
});
}
}
private async waitForDefaultContext(): Promise<void> {
if (this.getActiveContextId(false)) return;
this.defaultContextCreated = new Resolvable<void>();
await this.defaultContextCreated.promise.catch(err => {
if (err instanceof CanceledPromiseError) return;
throw err;
});
}
private updateUrl(): void {
if (this.internalFrame.url) {
this.url = this.internalFrame.url + (this.internalFrame.urlFragment ?? '');
} else {
this.url = undefined;
}
}
}