chrome-devtools-frontend
Version:
Chrome DevTools UI
357 lines (319 loc) • 12.4 kB
text/typescript
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* This file is the implementation of a protocol `Connection` object
* which is central to the rehydrated devtools feature. The premise of
* this feature is that the enhanced traces will contain enough
* information to power this class with all metadata needed. This class
* then interacts with rehydrated devtools in a way that produces the
* equivalent result as live debugging session.
*
* It's much more of a state machine than the other Connection
* implementations, which simply interact with a network protocol in
* one way or another.
*
* Note on the methodology to derive runtime/debugger domain behavior below:
* We can use protocol monitor in the devtools to look at how dt-fe
* communicates with the backend, and it's also how majority of the behavior
* in the rehydrated sesion was derived at the first place. In the event of
* adding more support and capability to rehydrated session, developers will
* want to look at protocol monitor to imitate the behavior in a real session
*
*/
import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as i18n from '../i18n/i18n.js';
import type * as Platform from '../platform/platform.js';
import type * as ProtocolClient from '../protocol_client/protocol_client.js';
import * as EnhancedTraces from './EnhancedTracesParser.js';
import type {
ProtocolMessage, RehydratingExecutionContext, RehydratingScript, RehydratingTarget, ServerMessage} from
'./RehydratingObject.js';
import {TraceObject} from './TraceObject.js';
const UIStrings = {
/**
* @description Text that appears when no source text is available for the given script
*/
noSourceText: 'No source text available',
/**
* @description Text to indicate rehydrating connection cannot find host window
*/
noHostWindow: 'Can not find host window',
/**
* @description Text to indicate that there is an error loading the log
*/
errorLoadingLog: 'Error loading log',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/RehydratingConnection.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface RehydratingConnectionInterface {
postToFrontend: (arg: ServerMessage) => void;
}
export const enum RehydratingConnectionState {
UNINITIALIZED = 1,
INITIALIZED = 2,
REHYDRATED = 3,
}
export class RehydratingConnection implements ProtocolClient.InspectorBackend.Connection {
rehydratingConnectionState: RehydratingConnectionState = RehydratingConnectionState.UNINITIALIZED;
onDisconnect: ((arg0: string) => void)|null = null;
onMessage: ((arg0: Object) => void)|null = null;
trace: TraceObject|null = null;
sessions = new Map<number, RehydratingSessionBase>();
#onConnectionLost: (message: Platform.UIString.LocalizedString) => void;
#rehydratingWindow: Window&typeof globalThis;
#onReceiveHostWindowPayloadBound = this.onReceiveHostWindowPayload.bind(this);
constructor(onConnectionLost: (message: Platform.UIString.LocalizedString) => void) {
// If we're invoking this class, we're in the rehydrating pop-up window. Rename window for clarity.
this.#onConnectionLost = onConnectionLost;
this.#rehydratingWindow = window;
this.#setupMessagePassing();
}
#setupMessagePassing(): void {
this.#rehydratingWindow.addEventListener('message', this.#onReceiveHostWindowPayloadBound);
if (!this.#rehydratingWindow.opener) {
this.#onConnectionLost(i18nString(UIStrings.noHostWindow));
return;
}
this.#rehydratingWindow.opener.postMessage({type: 'REHYDRATING_WINDOW_READY'});
}
/**
* This is a callback for rehydrated session to receive payload from host window. Payload includes but not limited to
* the trace event and all necessary data to power a rehydrated session.
*/
onReceiveHostWindowPayload(event: MessageEvent): void {
if (event.data.type === 'REHYDRATING_TRACE_FILE') {
const traceJson = event.data.traceJson as string;
let trace;
try {
trace = new TraceObject(JSON.parse(traceJson));
} catch {
this.#onConnectionLost(i18nString(UIStrings.errorLoadingLog));
return;
}
void this.startHydration(trace);
}
this.#rehydratingWindow.removeEventListener('message', this.#onReceiveHostWindowPayloadBound);
}
async startHydration(trace: TraceObject): Promise<boolean> {
// OnMessage should've been set before hydration, and the connection should
// be initialized and not hydrated already.
if (!this.onMessage || this.rehydratingConnectionState !== RehydratingConnectionState.INITIALIZED) {
return false;
}
if (!('traceEvents' in trace)) {
console.error('RehydratingConnection failed to initialize due to missing trace events in payload');
return false;
}
this.trace = trace;
const enhancedTracesParser = new EnhancedTraces.EnhancedTracesParser(trace);
const hydratingData = enhancedTracesParser.data();
let sessionId = 0;
// Set up default rehydrating session.
this.sessions.set(sessionId, new RehydratingSessionBase(this));
for (const hydratingDataPerTarget of hydratingData) {
const target = hydratingDataPerTarget.target;
const executionContexts = hydratingDataPerTarget.executionContexts;
const scripts = hydratingDataPerTarget.scripts;
this.postToFrontend({
method: 'Target.targetCreated',
params: {
targetInfo: {
targetId: target.targetId,
type: target.type,
title: target.url,
url: target.url,
attached: false,
canAccessOpener: false,
},
},
});
sessionId += 1;
const session = new RehydratingSession(sessionId, target, executionContexts, scripts, this);
this.sessions.set(sessionId, session);
session.declareSessionAttachedToTarget();
}
await this.#onRehydrated();
return true;
}
async #onRehydrated(): Promise<void> {
if (!this.trace) {
return;
}
this.rehydratingConnectionState = RehydratingConnectionState.REHYDRATED;
// Use revealer to load trace into performance panel
await Common.Revealer.reveal(this.trace);
}
setOnMessage(onMessage: (arg0: Object|string) => void): void {
this.onMessage = onMessage;
this.rehydratingConnectionState = RehydratingConnectionState.INITIALIZED;
}
setOnDisconnect(onDisconnect: (arg0: string) => void): void {
this.onDisconnect = onDisconnect;
}
// The function "sendRawMessage" is typically devtools front-end
// sending message to the backend via CDP. In this case, given that Rehydrating
// connection is an emulation of devtool back-end, sendRawMessage here
// is in fact rehydrating connection directly handling and acting on the
// receieved message.
sendRawMessage(message: string|object): void {
if (typeof message === 'string') {
message = JSON.parse(message);
}
const data = message as ProtocolMessage;
if (typeof data.sessionId !== 'undefined') {
const session = this.sessions.get(data.sessionId);
if (session) {
session.handleFrontendMessageAsFakeCDPAgent(data);
} else {
console.error('Invalid SessionId: ' + data.sessionId);
}
} else {
this.sessions.get(0)?.handleFrontendMessageAsFakeCDPAgent(data);
}
}
// Posting rehydrating connection's message/response
// to devtools frontend through debugger protocol.
postToFrontend(arg: ServerMessage): void {
if (this.onMessage) {
this.onMessage(arg);
} else {
// onMessage should be set before the connection is rehydrated
console.error('onMessage was not initialized');
}
}
disconnect(): Promise<void> {
return Promise.reject();
}
}
// Default rehydrating session with default responses.
class RehydratingSessionBase {
connection: RehydratingConnectionInterface|null = null;
constructor(connection: RehydratingConnectionInterface) {
this.connection = connection;
}
sendMessageToFrontend(payload: ServerMessage): void {
this.connection?.postToFrontend(payload);
}
handleFrontendMessageAsFakeCDPAgent(data: ProtocolMessage): void {
// Send default response in default session.
this.sendMessageToFrontend({
id: data.id,
result: {},
});
}
}
export class RehydratingSession extends RehydratingSessionBase {
sessionId: number;
target: RehydratingTarget;
executionContexts: RehydratingExecutionContext[] = [];
scripts: RehydratingScript[] = [];
constructor(
sessionId: number, target: RehydratingTarget, executionContexts: RehydratingExecutionContext[],
scripts: RehydratingScript[], connection: RehydratingConnectionInterface) {
super(connection);
this.sessionId = sessionId;
this.target = target;
this.executionContexts = executionContexts;
this.scripts = scripts;
}
override sendMessageToFrontend(payload: ServerMessage, attachSessionId = true): void {
// Attach the session's Id to the message.
if (this.sessionId !== 0 && attachSessionId) {
payload.sessionId = this.sessionId;
}
super.sendMessageToFrontend(payload);
}
override handleFrontendMessageAsFakeCDPAgent(data: ProtocolMessage): void {
switch (data.method) {
case 'Runtime.enable':
this.handleRuntimeEnabled(data.id);
break;
case 'Debugger.enable':
this.handleDebuggerEnable(data.id);
break;
case 'Debugger.getScriptSource':
if (data.params) {
const params = data.params as Protocol.Debugger.GetScriptSourceRequest;
this.handleDebuggerGetScriptSource(data.id, params.scriptId);
}
break;
default:
this.sendMessageToFrontend({
id: data.id,
result: {},
});
break;
}
}
declareSessionAttachedToTarget(): void {
this.sendMessageToFrontend(
{
method: 'Target.attachedToTarget',
params: {
sessionId: this.sessionId,
waitingForDebugger: false,
targetInfo: {
targetId: this.target.targetId,
type: this.target.type,
title: this.target.url,
url: this.target.url,
attached: true,
canAccessOpener: false,
},
},
},
/* attachSessionId */ false);
}
// Runtime.Enable indicates that Runtime domain is flushing the event to communicate
// the current state with the backend. In rehydrating connection, we made up the artificial
// execution context to support the rehydrated session.
private handleRuntimeEnabled(id: number): void {
for (const executionContext of this.executionContexts) {
executionContext.name = executionContext.origin;
this.sendMessageToFrontend({
method: 'Runtime.executionContextCreated',
params: {
context: executionContext,
},
});
}
this.sendMessageToFrontend({
id,
result: {},
});
}
private handleDebuggerGetScriptSource(id: number, scriptId: Protocol.Runtime.ScriptId): void {
const script = this.scripts.find(script => script.scriptId === scriptId);
if (!script) {
console.error('No script for id: ' + scriptId);
return;
}
this.sendMessageToFrontend({
id,
result: {
scriptSource: typeof script.sourceText === 'undefined' ? i18nString(UIStrings.noSourceText) : script.sourceText,
},
});
}
// Debugger.Enable indicates that Debugger domain is flushing the event to communicate
// the current state with the backend. In rehydrating connection, we made up the artificial
// script parsed event to communicate the current script state and respond with a mock
// debugger id.
private handleDebuggerEnable(id: number): void {
for (const script of this.scripts) {
this.sendMessageToFrontend({
method: 'Debugger.scriptParsed',
params: script,
});
}
const mockDebuggerId = '7777777777777777777.8888888888888888888';
this.sendMessageToFrontend({
id,
result: {
debuggerId: mockDebuggerId,
},
});
}
}