chrome-devtools-frontend
Version:
Chrome DevTools UI
311 lines (271 loc) • 14.3 kB
text/typescript
// 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;
}
}