UNPKG

chrome-devtools-frontend

Version:
292 lines (254 loc) 13 kB
// Copyright 2018 The Chromium Authors. All rights reserved. // 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 ReportRenderer from './LighthouseReporterTypes.js'; /* eslint-disable jsdoc/check-alignment */ /** * @overview ┌────────────┐ │CDP Backend │ └────────────┘ │ ▲ │ │ parallelConnection ┌┐ ▼ │ ┌┐ ││ dispatchProtocolMessage sendProtocolMessage ││ ││ │ ▲ ││ ProtocolService ││ | │ ││ ││ sendWithResponse ▼ │ ││ ││ │ send onWorkerMessage ││ └┘ │ │ ▲ └┘ worker boundary - - - - - - - - ┼ - -│- - - - - - - - -│- - - - - - - - - - - - ┌┐ ▼ ▼ │ ┌┐ ││ onFrontendMessage notifyFrontendViaWorkerMessage ││ ││ │ ▲ ││ ││ ▼ │ ││ LighthouseWorkerService ││ Either ConnectionProxy or LegacyPort ││ ││ │ ▲ ││ ││ ┌─────────────────────┼─┼───────────────────────┐ ││ ││ │ Lighthouse ┌────▼──────┐ │ ││ ││ │ │connection │ │ ││ ││ │ └───────────┘ │ ││ └┘ └───────────────────────────────────────────────┘ └┘ * All messages traversing the worker boundary are action-wrapped. * All messages over the parallelConnection speak pure CDP. * All messages within ConnectionProxy/LegacyPort speak pure CDP. * The foundational CDP connection is `parallelConnection`. * All connections within the worker are not actual ParallelConnection's. */ /* eslint-enable jsdoc/check-alignment */ 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 { private mainSessionId?: string; private rootTargetId?: string; private parallelConnection?: ProtocolClient.InspectorBackend.Connection; private lighthouseWorkerPromise?: Promise<Worker>; private lighthouseMessageUpdateCallback?: ((arg0: string) => void); private removeDialogHandler?: () => void; private configForTesting?: object; 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, sessionId} = await rootChildTargetManager.createParallelConnection(message => { if (typeof message === 'string') { message = JSON.parse(message); } this.dispatchProtocolMessage(message); }); // 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.parallelConnection = connection; this.rootTargetId = await rootChildTargetManager.getParentTargetId(); 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 oldParallelConnection = this.parallelConnection; // 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.parallelConnection = undefined; if (oldLighthouseWorker) { (await oldLighthouseWorker).terminate(); } if (oldParallelConnection) { await oldParallelConnection.disconnect(); } await SDK.TargetManager.TargetManager.instance().resumeAllTargets(); this.removeDialogHandler?.(); } registerStatusCallback(callback: (arg0: string) => void): void { this.lighthouseMessageUpdateCallback = callback; } private dispatchProtocolMessage(message: string|object): 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). const protocolMessage = message as { sessionId?: string, method?: string, }; if (protocolMessage.sessionId || (protocolMessage.method?.startsWith('Target'))) { void this.send('dispatchProtocolMessage', {message}); } } 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 { if (this.parallelConnection) { this.parallelConnection.sendRawMessage(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; } }