UNPKG

chrome-devtools-frontend

Version:
311 lines (271 loc) 14.3 kB
// Copyright 2018 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as i18n from '../../core/i18n/i18n.js'; import type * as Platform from '../../core/platform/platform.js'; import type * as ProtocolClient from '../../core/protocol_client/protocol_client.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import type * as ReportRenderer from './LighthouseReporterTypes.js'; /** * @file * ┌───────────────┐ * │ CDPConnection │ * └───────────────┘ * │ ▲ * │ │ * ┌┐ ▼ │ ┌┐ * ││ dispatchProtocolMessage sendProtocolMessage ││ * ││ │ ▲ ││ * ProtocolService ││ | │ ││ * ││ sendWithResponse ▼ │ ││ * ││ │ send onWorkerMessage ││ * └┘ │ │ ▲ └┘ * worker boundary - - - - - - - - ┼ - -│- - - - - - - - -│- - - - - - - - - - - - * ┌┐ ▼ ▼ │ ┌┐ * ││ onFrontendMessage notifyFrontendViaWorkerMessage ││ * ││ │ ▲ ││ * ││ ▼ │ ││ * LighthouseWorkerService ││ WorkerConnectionTransport ││ * ││ │ ▲ ││ * ││ ▼ │ ││ * ││ CDPConnection ││ * ││ │ ▲ ││ * ││ ┌─────────────────────┼─┼───────────────────────┐ ││ * ││ │ Lighthouse ┌────▼──────┐ │ ││ * ││ │ │connection │ │ ││ * ││ │ └───────────┘ │ ││ * └┘ └───────────────────────────────────────────────┘ └┘ * * - All messages traversing the worker boundary are action-wrapped. * - All messages over the CDPConnection speak pure CDP. * - Within the worker we also use a 'CDPConnection' but with a custom * transport called WorkerConnectionTransport. * - All messages within WorkerConnectionTransport/LegacyPort speak pure CDP. */ let lastId = 1; export interface LighthouseRun { inspectedURL: Platform.DevToolsPath.UrlString; categoryIDs: string[]; flags: { formFactor: (string|undefined), mode: string, }; } /** * ProtocolService manages a connection between the frontend (Lighthouse panel) and the Lighthouse worker. */ export class ProtocolService implements ProtocolClient.CDPConnection.CDPConnectionObserver { private mainSessionId?: Protocol.Target.SessionID; private rootTargetId?: string; private rootTarget?: SDK.Target.Target; private lighthouseWorkerPromise?: Promise<Worker>; private lighthouseMessageUpdateCallback?: ((arg0: string) => void); private removeDialogHandler?: () => void; private configForTesting?: object; private connection?: ProtocolClient.CDPConnection.CDPConnection; async attach(): Promise<void> { await SDK.TargetManager.TargetManager.instance().suspendAllTargets(); const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (!mainTarget) { throw new Error('Unable to find main target required for Lighthouse'); } const rootTarget = SDK.TargetManager.TargetManager.instance().rootTarget(); if (!rootTarget) { throw new Error('Could not find the root target'); } const childTargetManager = mainTarget.model(SDK.ChildTargetManager.ChildTargetManager); if (!childTargetManager) { throw new Error('Unable to find child target manager required for Lighthouse'); } const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel.ResourceTreeModel); if (!resourceTreeModel) { throw new Error('Unable to find resource tree model required for Lighthouse'); } const rootChildTargetManager = rootTarget.model(SDK.ChildTargetManager.ChildTargetManager); if (!rootChildTargetManager) { throw new Error('Could not find the child target manager class for the root target'); } const connection = rootTarget.router()?.connection; if (!connection) { throw new Error('Expected root target to have a session router'); } const rootTargetId = await rootChildTargetManager.getParentTargetId(); const {sessionId} = await rootTarget.targetAgent().invoke_attachToTarget({targetId: rootTargetId, flatten: true}); this.connection = connection; this.connection.observe(this); // Lighthouse implements its own dialog handler like this, however its lifecycle ends when // the internal Lighthouse session is disposed. // // If the page is reloaded near the end of the run (e.g. bfcache testing), the Lighthouse // internal session can be disposed before a dialog message appears. This allows the dialog // to block important Lighthouse teardown operations in LighthouseProtocolService. // // To ensure the teardown operations can proceed, we need a dialog handler which lasts until // the LighthouseProtocolService detaches. const dialogHandler = (): void => { void mainTarget.pageAgent().invoke_handleJavaScriptDialog({accept: true}); }; resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.JavaScriptDialogOpening, dialogHandler); this.removeDialogHandler = () => resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.JavaScriptDialogOpening, dialogHandler); this.rootTargetId = rootTargetId; this.rootTarget = rootTarget; this.mainSessionId = sessionId; } getLocales(): readonly string[] { return [i18n.DevToolsLocale.DevToolsLocale.instance().locale]; } async startTimespan(currentLighthouseRun: LighthouseRun): Promise<void> { const {inspectedURL, categoryIDs, flags} = currentLighthouseRun; if (!this.mainSessionId || !this.rootTargetId) { throw new Error('Unable to get target info required for Lighthouse'); } await this.sendWithResponse('startTimespan', { url: inspectedURL, categoryIDs, flags, config: this.configForTesting, locales: this.getLocales(), mainSessionId: this.mainSessionId, rootTargetId: this.rootTargetId, }); } async collectLighthouseResults(currentLighthouseRun: LighthouseRun): Promise<ReportRenderer.RunnerResult> { const {inspectedURL, categoryIDs, flags} = currentLighthouseRun; if (!this.mainSessionId || !this.rootTargetId) { throw new Error('Unable to get target info required for Lighthouse'); } let mode = flags.mode as string; if (mode === 'timespan') { mode = 'endTimespan'; } return await this.sendWithResponse(mode, { url: inspectedURL, categoryIDs, flags, config: this.configForTesting, locales: this.getLocales(), mainSessionId: this.mainSessionId, rootTargetId: this.rootTargetId, }); } async detach(): Promise<void> { const oldLighthouseWorker = this.lighthouseWorkerPromise; const oldRootTarget = this.rootTarget; // When detaching, make sure that we remove the old promises, before we // perform any async cleanups. That way, if there is a message coming from // lighthouse while we are in the process of cleaning up, we shouldn't deliver // them to the backend. this.lighthouseWorkerPromise = undefined; this.rootTarget = undefined; this.connection?.unobserve(this); this.connection = undefined; if (oldLighthouseWorker) { (await oldLighthouseWorker).terminate(); } if (oldRootTarget && this.mainSessionId) { await oldRootTarget.targetAgent().invoke_detachFromTarget({sessionId: this.mainSessionId}); } await SDK.TargetManager.TargetManager.instance().resumeAllTargets(); this.removeDialogHandler?.(); } registerStatusCallback(callback: (arg0: string) => void): void { this.lighthouseMessageUpdateCallback = callback; } onEvent<T extends ProtocolClient.CDPConnection.Event>(event: ProtocolClient.CDPConnection.CDPEvent<T>): void { this.dispatchProtocolMessage(event); } private dispatchProtocolMessage(message: ProtocolClient.CDPConnection.CDPReceivableMessage): void { // A message without a sessionId is the main session of the main target (call it "Main session"). // A parallel connection and session was made that connects to the same main target (call it "Lighthouse session"). // Messages from the "Lighthouse session" have a sessionId. // Without some care, there is a risk of sending the same events for the same main frame to Lighthouse–the backend // will create events for the "Main session" and the "Lighthouse session". // The workaround–only send message to Lighthouse if: // * the message has a sessionId (is not for the "Main session") // * the message does not have a sessionId (is for the "Main session"), but only for the Target domain // (to kickstart autoAttach in LH). if (message.sessionId || ('method' in message && message.method?.startsWith('Target'))) { void this.send('dispatchProtocolMessage', {message}); } } onDisconnect(): void { // Do nothing. } private initWorker(): Promise<Worker> { this.lighthouseWorkerPromise = new Promise<Worker>(resolve => { const workerUrl = new URL('../../entrypoints/lighthouse_worker/lighthouse_worker.js', import.meta.url); const remoteBaseSearchParam = new URL(self.location.href).searchParams.get('remoteBase'); if (remoteBaseSearchParam) { // Allows Lighthouse worker to fetch remote locale files. workerUrl.searchParams.set('remoteBase', remoteBaseSearchParam); } const worker = new Worker(workerUrl, {type: 'module'}); worker.addEventListener('message', event => { if (event.data === 'workerReady') { resolve(worker); return; } this.onWorkerMessage(event); }); }); return this.lighthouseWorkerPromise; } private async ensureWorkerExists(): Promise<Worker> { let worker: Worker; if (!this.lighthouseWorkerPromise) { worker = await this.initWorker(); } else { worker = await this.lighthouseWorkerPromise; } return worker; } private onWorkerMessage(event: MessageEvent): void { const lighthouseMessage = event.data; if (lighthouseMessage.action === 'statusUpdate') { if (this.lighthouseMessageUpdateCallback && lighthouseMessage.args && 'message' in lighthouseMessage.args) { this.lighthouseMessageUpdateCallback(lighthouseMessage.args.message as string); } } else if (lighthouseMessage.action === 'sendProtocolMessage') { if (lighthouseMessage.args && 'message' in lighthouseMessage.args) { this.sendProtocolMessage(lighthouseMessage.args.message as string); } } } private sendProtocolMessage(message: string): void { const {id, method, params, sessionId} = JSON.parse(message); // CDPConnection manages it's own message IDs and it's important, otherwise we'd clash // with the rest of the DevTools traffic. // Instead, we ignore the ID coming from the worker when sending the command, but // patch it back in when sending the response back to the worker. void this.connection?.send(method, params, sessionId).then(response => { const message = 'result' in response ? {id, sessionId, result: response.result} : {id, sessionId, error: response.error}; this.dispatchProtocolMessage(message); }); } private async send(action: string, args: Record<string, string|string[]|object> = {}): Promise<void> { const worker = await this.ensureWorkerExists(); const messageId = lastId++; worker.postMessage({id: messageId, action, args: {...args, id: messageId}}); } /** sendWithResponse currently only handles the original startLighthouse request and LHR-filled response. */ private async sendWithResponse(action: string, args: Record<string, string|string[]|object|undefined> = {}): Promise<ReportRenderer.RunnerResult> { const worker = await this.ensureWorkerExists(); const messageId = lastId++; const messageResult = new Promise<ReportRenderer.RunnerResult>(resolve => { const workerListener = (event: MessageEvent): void => { const lighthouseMessage = event.data; if (lighthouseMessage.id === messageId) { worker.removeEventListener('message', workerListener); resolve(lighthouseMessage.result); } }; worker.addEventListener('message', workerListener); }); worker.postMessage({id: messageId, action, args: {...args, id: messageId}}); return await messageResult; } }