UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

285 lines (238 loc) • 10.2 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview This class tracks multiple targets (the page itself and its OOPIFs) and allows consumers to * listen for protocol events before each target is resumed. */ import EventEmitter from 'events'; import log from 'lighthouse-logger'; import {ProtocolSession} from '../session.js'; /** * @typedef {{ * target: LH.Crdp.Target.TargetInfo, * cdpSession: LH.Puppeteer.CDPSession, * session: LH.Gatherer.ProtocolSession, * protocolListener: (event: unknown) => void, * }} TargetWithSession */ // Add protocol event types to EventEmitter. /** @typedef {{'protocolevent': [LH.Protocol.RawEventMessage]}} ProtocolEventMap */ /** @typedef {LH.Protocol.StrictEventEmitterClass<ProtocolEventMap>} ProtocolEventMessageEmitter */ const ProtocolEventEmitter = /** @type {ProtocolEventMessageEmitter} */ (EventEmitter); /** * Tracks targets (the page itself, its iframes, their iframes, etc) as they * appear and allows listeners to the flattened protocol events from all targets. */ class TargetManager extends ProtocolEventEmitter { /** @param {LH.Puppeteer.CDPSession} cdpSession */ constructor(cdpSession) { super(); this._enabled = false; this._rootCdpSession = cdpSession; this._mainFrameId = ''; /** * A map of target id to target/session information. Used to ensure unique * attached targets. * @type {Map<string, TargetWithSession>} */ this._targetIdToTargets = new Map(); /** @type {Map<string, LH.Crdp.Runtime.ExecutionContextDescription>} */ this._executionContextIdToDescriptions = new Map(); this._onSessionAttached = this._onSessionAttached.bind(this); this._onFrameNavigated = this._onFrameNavigated.bind(this); this._onExecutionContextCreated = this._onExecutionContextCreated.bind(this); this._onExecutionContextDestroyed = this._onExecutionContextDestroyed.bind(this); this._onExecutionContextsCleared = this._onExecutionContextsCleared.bind(this); } /** * @param {LH.Crdp.Page.FrameNavigatedEvent} frameNavigatedEvent */ async _onFrameNavigated(frameNavigatedEvent) { // Child frames are handled in `_onSessionAttached`. if (frameNavigatedEvent.frame.parentId) return; if (!this._enabled) return; // It's not entirely clear when this is necessary, but when the page switches processes on // navigating from about:blank to the `requestedUrl`, resetting `setAutoAttach` has been // necessary in the past. try { await this._rootCdpSession.send('Target.setAutoAttach', { autoAttach: true, flatten: true, waitForDebuggerOnStart: true, }); } catch (err) { // The page can be closed at the end of the run before this CDP function returns. // In these cases, just ignore the error since we won't need the page anyway. if (this._enabled) throw err; } } /** * @param {string} sessionId * @return {LH.Gatherer.ProtocolSession} */ _findSession(sessionId) { for (const {session, cdpSession} of this._targetIdToTargets.values()) { if (cdpSession.id() === sessionId) return session; } throw new Error(`session ${sessionId} not found`); } /** * @param {string} targetType * @return {targetType is LH.Protocol.TargetType} */ _isAcceptedTargetType(targetType) { return targetType === 'page' || targetType === 'iframe' || targetType === 'worker'; } /** * Returns the root session. * @return {LH.Gatherer.ProtocolSession} */ rootSession() { const rootSessionId = this._rootCdpSession.id(); return this._findSession(rootSessionId); } mainFrameExecutionContexts() { return [...this._executionContextIdToDescriptions.values()].filter(executionContext => { return executionContext.auxData.frameId === this._mainFrameId; }); } /** * @param {LH.Puppeteer.CDPSession} cdpSession */ async _onSessionAttached(cdpSession) { const newSession = new ProtocolSession(cdpSession); let targetType; try { const {targetInfo} = await newSession.sendCommand('Target.getTargetInfo'); targetType = targetInfo.type; // TODO: should detach from target in this case? // See pptr: https://github.com/puppeteer/puppeteer/blob/733cbecf487c71483bee8350e37030edb24bc021/src/common/Page.ts#L495-L526 if (!this._isAcceptedTargetType(targetType)) return; // No need to continue if target has already been seen. const targetId = targetInfo.targetId; if (this._targetIdToTargets.has(targetId)) return; newSession.setTargetInfo(targetInfo); const targetName = targetInfo.url || targetInfo.targetId; log.verbose('target-manager', `target ${targetName} attached`); const trueProtocolListener = this._getProtocolEventListener(targetType, newSession.id()); /** @type {(event: unknown) => void} */ // @ts-expect-error - pptr currently typed only for single arg emits. const protocolListener = trueProtocolListener; cdpSession.on('*', protocolListener); cdpSession.on('sessionattached', this._onSessionAttached); const targetWithSession = { target: targetInfo, cdpSession, session: newSession, protocolListener, }; this._targetIdToTargets.set(targetId, targetWithSession); // We want to receive information about network requests from iframes, so enable the Network domain. await newSession.sendCommand('Network.enable'); // We also want to receive information about subtargets of subtargets, so make sure we autoattach recursively. await newSession.sendCommand('Target.setAutoAttach', { autoAttach: true, flatten: true, waitForDebuggerOnStart: true, }); } catch (err) { // Sometimes targets can be closed before we even have a chance to listen to their network activity. if (/Target closed/.test(err.message)) return; // `Target.getTargetInfo` is not implemented for certain target types. // Lighthouse isn't interested in these targets anyway so we can just ignore them. if (/'Target.getTargetInfo' wasn't found/.test(err)) return; // Worker targets can be a bit fickle and we only enable them for diagnostic purposes. // We shouldn't throw a fatal error if there were issues attaching to them. if (targetType === 'worker') { log.warn('target-manager', `Issue attaching to worker target: ${err}`); return; } throw err; } finally { // Resume the target if it was paused, but if it's unnecessary, we don't care about the error. await newSession.sendCommandAndIgnore('Runtime.runIfWaitingForDebugger'); } } /** * @param {LH.Crdp.Runtime.ExecutionContextCreatedEvent} event */ _onExecutionContextCreated(event) { if (event.context.name.match(/^__puppeteer_utility_world__/)) return; if (event.context.name === 'lighthouse_isolated_context') return; this._executionContextIdToDescriptions.set(event.context.uniqueId, event.context); } /** * @param {LH.Crdp.Runtime.ExecutionContextDestroyedEvent} event */ _onExecutionContextDestroyed(event) { this._executionContextIdToDescriptions.delete(event.executionContextUniqueId); } _onExecutionContextsCleared() { this._executionContextIdToDescriptions.clear(); } /** * Returns a listener for all protocol events from session, and augments the * event with the sessionId. * @param {LH.Protocol.TargetType} targetType * @param {string} sessionId */ _getProtocolEventListener(targetType, sessionId) { /** * @template {keyof LH.Protocol.RawEventMessageRecord} EventName * @param {EventName} method * @param {LH.Protocol.RawEventMessageRecord[EventName]['params']} params */ const onProtocolEvent = (method, params) => { // Cast because tsc 4.7 still can't quite track the dependent parameters. const payload = /** @type {LH.Protocol.RawEventMessage} */ ( {method, params, targetType, sessionId}); this.emit('protocolevent', payload); }; return onProtocolEvent; } /** * @return {Promise<void>} */ async enable() { if (this._enabled) return; this._enabled = true; this._targetIdToTargets = new Map(); this._executionContextIdToDescriptions = new Map(); this._rootCdpSession.on('Page.frameNavigated', this._onFrameNavigated); this._rootCdpSession.on('Runtime.executionContextCreated', this._onExecutionContextCreated); this._rootCdpSession.on('Runtime.executionContextDestroyed', this._onExecutionContextDestroyed); this._rootCdpSession.on('Runtime.executionContextsCleared', this._onExecutionContextsCleared); await this._rootCdpSession.send('Page.enable'); await this._rootCdpSession.send('Runtime.enable'); this._mainFrameId = (await this._rootCdpSession.send('Page.getFrameTree')).frameTree.frame.id; // Start with the already attached root session. await this._onSessionAttached(this._rootCdpSession); } /** * @return {Promise<void>} */ async disable() { this._rootCdpSession.off('Page.frameNavigated', this._onFrameNavigated); this._rootCdpSession.off('Runtime.executionContextCreated', this._onExecutionContextCreated); this._rootCdpSession.off('Runtime.executionContextDestroyed', this._onExecutionContextDestroyed); this._rootCdpSession.off('Runtime.executionContextsCleared', this._onExecutionContextsCleared); for (const {cdpSession, protocolListener} of this._targetIdToTargets.values()) { cdpSession.off('*', protocolListener); cdpSession.off('sessionattached', this._onSessionAttached); } // Ignore failures on these in case the tab has crashed. await this._rootCdpSession.send('Page.disable').catch(_ => {}); await this._rootCdpSession.send('Runtime.disable').catch(_ => {}); this._enabled = false; this._targetIdToTargets = new Map(); this._executionContextIdToDescriptions = new Map(); this._mainFrameId = ''; } } export {TargetManager};