chrome-devtools-frontend
Version:
Chrome DevTools UI
535 lines (489 loc) • 22.1 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.
import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import type * as Platform from '../platform/platform.js';
import {UserVisibleError} from '../platform/platform.js';
import type {
HydratingDataPerTarget, RehydratingExecutionContext, RehydratingResource, RehydratingScript, RehydratingTarget} from
'./RehydratingObject.js';
import type {SourceMapV3} from './SourceMap.js';
import type {TraceObject} from './TraceObject.js';
interface EventBase {
cat: string;
pid: number;
args: {data: object};
name: string;
}
/**
* While called 'TargetRundown', this event is emitted for each script that is compiled or evaluated.
* Within EnhancedTraceParser, this event is used to construct targets and execution contexts (and to associate scripts to frames).
*
* See `inspector_target_rundown_event::Data` https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/inspector/inspector_trace_events.cc;l=1189-1232;drc=48d6f7175422b2c969c14258f9f8d5b196c28d18
*/
export interface RundownScriptCompiled extends EventBase {
cat: 'disabled-by-default-devtools.target-rundown';
name: 'ScriptCompiled'|'ModuleEvaluated';
args: {
data: {
frame: Protocol.Page.FrameId,
frameType: 'page'|'iframe',
url: string,
/**
* Older traces were a number, but this is an unsigned 64 bit value, so that was a bug.
* New traces use string instead. See https://crbug.com/447654178.
*/
isolate: string|number,
/** AKA V8ContextToken. https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/inspector/inspector_trace_events.cc;l=1229;drc=3c88f61e18b043e70c225d8d57c77832a85e7f58 */
v8context: string,
origin: string,
scriptId: number,
/** script->World().isMainWorld() */
isDefault?: boolean,
contextType?: 'default'|'isolated'|'worker',
},
};
}
/**
* When profiling starts, all currently loaded scripts are emitted via this event.
*
* See `Script::TraceScriptRundown()` https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/script.cc;l=184-220;drc=328f6c467b940f322544567740c9c871064d045c
*/
export interface RundownScript extends EventBase {
cat: 'disabled-by-default-devtools.v8-source-rundown';
name: 'ScriptCatchup';
args: {
data: {
/**
* Older traces were a number, but this is an unsigned 64 bit value, so that was a bug.
* New traces use string instead. See https://crbug.com/447654178.
*/
isolate: string|number,
executionContextId: Protocol.Runtime.ExecutionContextId,
scriptId: number,
isModule: boolean,
/** aka HasSourceURLComment */
hasSourceUrl: boolean,
// These don't actually get set in v8.
url?: string,
hash?: string,
/** value of the sourceURL comment. */
sourceUrl?: string,
/* value of the sourceMappingURL comment */
sourceMapUrl?: string,
/** If true, the source map url was a data URL, so the `sourceMapUrl` was removed. */
sourceMapUrlElided?: boolean,
startLine?: number,
startColumn?: number,
endLine?: number,
endColumn?: number,
},
};
}
export interface RundownScriptSource extends EventBase {
cat: 'disabled-by-default-devtools.v8-source-rundown-sources';
name: 'ScriptCatchup'|'LargeScriptCatchup'|'TooLargeScriptCatchup';
args: {
data: {
isolate: number,
scriptId: number,
length?: number,
sourceText?: string,
},
};
}
interface TracingStartedInBrowser extends EventBase {
cat: 'disabled-by-default-devtools.timeline';
args: {
data: {
frames: [{
frame: Protocol.Page.FrameId,
isInPrimaryMainFrame: boolean,
isOutermostMainFrame: boolean,
parent: Protocol.Page.FrameId,
processId: number,
url: string,
pid: number,
}],
},
};
}
interface FunctionCall extends EventBase {
cat: 'devtools.timeline';
args: {
data: {
frame: Protocol.Page.FrameId,
scriptId: Protocol.Runtime.ScriptId,
/**
* Older traces were a number, but this is an unsigned 64 bit value, so that was a bug.
* New traces use string instead. See https://crbug.com/447654178.
*/
isolate?: string|number,
},
};
}
export class EnhancedTracesParser {
#trace: TraceObject;
#scriptRundownEvents: RundownScript[] = [];
#scriptToV8Context: Map<string, string> = new Map<string, string>();
#scriptToFrame: Map<string, Protocol.Page.FrameId> = new Map<string, Protocol.Page.FrameId>();
#scriptToScriptSource: Map<string, string> = new Map<string, string>();
#largeScriptToScriptSource: Map<string, string[]> = new Map<string, string[]>();
#scriptToSourceLength: Map<string, number> = new Map<string, number>();
#targets: RehydratingTarget[] = [];
#executionContexts: RehydratingExecutionContext[] = [];
#scripts: RehydratingScript[] = [];
#resources: RehydratingResource[] = [];
static readonly enhancedTraceVersion: number = 1;
constructor(trace: TraceObject) {
this.#trace = trace;
// Initialize with the trace provided.
try {
this.parseEnhancedTrace();
} catch (e) {
throw new UserVisibleError.UserVisibleError(e);
}
}
parseEnhancedTrace(): void {
for (const event of this.#trace.traceEvents) {
if (this.isTracingStartedInBrowser(event)) {
// constructs all targets by devtools.timeline TracingStartedInBrowser
const data = event.args?.data;
for (const frame of data.frames) {
if (frame.url === 'about:blank') {
continue;
}
if (!frame.isInPrimaryMainFrame) {
continue;
}
const frameId = frame.frame as string as Protocol.Target.TargetID;
if (!this.#targets.find(target => target.targetId === frameId)) {
const frameType = frame.isOutermostMainFrame ? 'page' : 'iframe';
this.#targets.push({
targetId: frameId,
type: frameType,
pid: frame.processId,
url: frame.url,
});
}
}
} else if (this.isFunctionCallEvent(event)) {
// constructs all script to frame mapping with devtools.timeline FunctionCall
const data = event.args?.data;
if (data.isolate) {
this.#scriptToFrame.set(this.getScriptIsolateId(data.isolate, data.scriptId), data.frame);
}
} else if (this.isRundownScriptCompiled(event)) {
// Set up script to v8 context mapping
const data = event.args?.data;
this.#scriptToV8Context.set(this.getScriptIsolateId(data.isolate, data.scriptId), data.v8context);
this.#scriptToFrame.set(this.getScriptIsolateId(data.isolate, data.scriptId), data.frame);
// All the targets should've been added by the TracingStartedInBrowser event, but just in case we're missing some there
const frameId = data.frame as string as Protocol.Target.TargetID;
if (!this.#targets.find(target => target.targetId === frameId)) {
this.#targets.push({
targetId: frameId,
type: data.frameType,
isolate: String(data.isolate),
pid: event.pid,
url: data.url,
});
}
// Add execution context, need to put back execution context id with info from other traces
if (!this.#executionContexts.find(executionContext => executionContext.v8Context === data.v8context)) {
this.#executionContexts.push({
id: -1 as Protocol.Runtime.ExecutionContextId,
origin: data.origin,
v8Context: data.v8context,
auxData: {
frameId: data.frame,
isDefault: data.isDefault,
type: data.contextType,
},
isolate: String(data.isolate),
name: data.origin,
uniqueId: `${data.v8context}-${data.isolate}`,
});
}
} else if (this.isRundownScript(event)) {
this.#scriptRundownEvents.push(event);
const data = event.args.data;
// Add script
if (!this.#scripts.find(
script => script.scriptId === String(data.scriptId) && script.isolate === String(data.isolate))) {
this.#scripts.push({
scriptId: String(data.scriptId) as Protocol.Runtime.ScriptId,
isolate: String(data.isolate),
buildId: '',
executionContextId: data.executionContextId,
startLine: data.startLine ?? 0,
startColumn: data.startColumn ?? 0,
endLine: data.endLine ?? 0,
endColumn: data.endColumn ?? 0,
hash: data.hash ?? '',
isModule: data.isModule,
url: data.url ?? '',
hasSourceURL: data.hasSourceUrl,
sourceURL: data.sourceUrl ?? '',
sourceMapURL: data.sourceMapUrl,
pid: event.pid,
});
}
} else if (this.isRundownScriptSource(event)) {
// Set up script to source text and length mapping
const data = event.args.data;
const scriptIsolateId = this.getScriptIsolateId(data.isolate, data.scriptId);
if ('splitIndex' in data && 'splitCount' in data) {
if (!this.#largeScriptToScriptSource.has(scriptIsolateId)) {
this.#largeScriptToScriptSource.set(scriptIsolateId, new Array(data.splitCount).fill('') as string[]);
}
const splittedSource = this.#largeScriptToScriptSource.get(scriptIsolateId);
if (splittedSource && data.sourceText) {
splittedSource[data.splitIndex as number] = data.sourceText;
}
} else {
if (data.sourceText) {
this.#scriptToScriptSource.set(scriptIsolateId, data.sourceText);
}
if (data.length) {
this.#scriptToSourceLength.set(scriptIsolateId, data.length);
}
}
}
}
}
data(): HydratingDataPerTarget[] {
// Put back execution context id
const v8ContextToExecutionContextId: Map<string, Protocol.Runtime.ExecutionContextId> =
new Map<string, Protocol.Runtime.ExecutionContextId>();
this.#scriptRundownEvents.forEach(scriptRundownEvent => {
const data = scriptRundownEvent.args.data;
const v8Context = this.#scriptToV8Context.get(this.getScriptIsolateId(data.isolate, data.scriptId));
if (v8Context) {
v8ContextToExecutionContextId.set(v8Context, data.executionContextId);
}
});
this.#executionContexts.forEach(executionContext => {
if (executionContext.v8Context) {
const id = v8ContextToExecutionContextId.get(executionContext.v8Context);
if (id) {
executionContext.id = id;
}
}
});
// Put back script source text and length
this.#scripts.forEach(script => {
const scriptIsolateId = this.getScriptIsolateId(script.isolate, script.scriptId);
if (this.#scriptToScriptSource.has(scriptIsolateId)) {
script.sourceText = this.#scriptToScriptSource.get(scriptIsolateId);
script.length = this.#scriptToSourceLength.get(scriptIsolateId);
} else if (this.#largeScriptToScriptSource.has(scriptIsolateId)) {
const splittedSources = this.#largeScriptToScriptSource.get(scriptIsolateId);
if (splittedSources) {
script.sourceText = splittedSources.join('');
script.length = script.sourceText.length;
}
}
// put in the aux data
const linkedExecutionContext = this.#executionContexts.find(
context => context.id === script.executionContextId && context.isolate === script.isolate);
if (linkedExecutionContext) {
script.executionContextAuxData = linkedExecutionContext.auxData;
// If a script successfully mapped to an execution context and aux data, link script to frame
if (script.executionContextAuxData?.frameId) {
this.#scriptToFrame.set(scriptIsolateId, script.executionContextAuxData?.frameId);
}
}
});
for (const script of this.#scripts) {
// Resolve the source map from the provided metadata.
// If no map is found for a given source map url, no source map is passed to the debugger model.
// Encoded as a data url so that the debugger model makes no network request.
// NOTE: consider passing directly as object and hacking `parsedScriptSource` in DebuggerModel.ts to handle
// this fake event. Would avoid a lot of wasteful (de)serialization. Maybe add SDK.Script.hydratedSourceMap.
this.resolveSourceMap(script);
}
this.#resources = this.#trace.metadata.resources ?? [];
return this.groupContextsAndScriptsUnderTarget(
this.#targets, this.#executionContexts, this.#scripts, this.#resources);
}
private resolveSourceMap(script: RehydratingScript): void {
if (script.sourceMapURL?.startsWith('data:')) {
return;
}
const sourceMap = this.getSourceMapFromMetadata(script);
if (!sourceMap) {
return;
}
// Note: this encoding + re-parsing overhead cost ~10ms per 1MB of JSON on my
// Mac M1 Pro.
// See https://crrev.com/c/6490409/comments/f294c12a_69781e24
const payload = encodeURIComponent(JSON.stringify(sourceMap));
script.sourceMapURL = `data:application/json;charset=utf-8,${payload}`;
}
private getSourceMapFromMetadata(script: RehydratingScript): SourceMapV3|undefined {
const {hasSourceURL, sourceURL, url, sourceMapURL, isolate, scriptId} = script;
if (!sourceMapURL || !this.#trace.metadata.sourceMaps) {
return;
}
const frame =
this.#scriptToFrame.get(this.getScriptIsolateId(isolate, scriptId)) as string as Protocol.Target.TargetID;
if (!frame) {
return;
}
const target = this.#targets.find(t => t.targetId === frame);
if (!target) {
return;
}
let resolvedSourceUrl = url;
if (hasSourceURL && sourceURL) {
const targetUrl = target.url as Platform.DevToolsPath.UrlString;
resolvedSourceUrl = Common.ParsedURL.ParsedURL.completeURL(targetUrl, sourceURL) ?? sourceURL;
}
// Resolve the source map url. The value given by v8 may be relative, so resolve it here.
// This process should match the one in `SourceMapManager.attachSourceMap`.
const resolvedSourceMapUrl =
Common.ParsedURL.ParsedURL.completeURL(resolvedSourceUrl as Platform.DevToolsPath.UrlString, sourceMapURL);
if (!resolvedSourceMapUrl) {
return;
}
const {sourceMap} = this.#trace.metadata.sourceMaps.find(m => m.sourceMapUrl === resolvedSourceMapUrl) ?? {};
return sourceMap;
}
private getScriptIsolateId(isolate: number|string, scriptId: Protocol.Runtime.ScriptId|number): string {
return `${scriptId}@${isolate}`;
}
private getExecutionContextIsolateId(isolate: number|string, executionContextId: Protocol.Runtime.ExecutionContextId):
string {
return `${executionContextId}@${isolate}`;
}
private isTraceEvent(event: unknown): event is EventBase {
return 'cat' in (event as EventBase) && 'pid' in (event as EventBase) && 'args' in (event as EventBase) &&
'data' in (event as EventBase).args;
}
private isRundownScriptCompiled(event: unknown): event is RundownScriptCompiled {
return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.target-rundown';
}
private isRundownScript(event: unknown): event is RundownScript {
return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.v8-source-rundown';
}
private isRundownScriptSource(event: unknown): event is RundownScriptSource {
return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.v8-source-rundown-sources';
}
private isTracingStartedInBrowser(event: unknown): event is TracingStartedInBrowser {
return this.isTraceEvent(event) && event.cat === 'disabled-by-default-devtools.timeline' &&
event.name === 'TracingStartedInBrowser';
}
private isFunctionCallEvent(event: unknown): event is FunctionCall {
return this.isTraceEvent(event) && event.cat === 'devtools.timeline' && event.name === 'FunctionCall';
}
private groupContextsAndScriptsUnderTarget(
targets: RehydratingTarget[], executionContexts: RehydratingExecutionContext[], scripts: RehydratingScript[],
resources: RehydratingResource[]): HydratingDataPerTarget[] {
const data: HydratingDataPerTarget[] = [];
const targetIds = new Set<Protocol.Target.TargetID>();
const targetToExecutionContexts: Map<string, RehydratingExecutionContext[]> =
new Map<Protocol.Target.TargetID, RehydratingExecutionContext[]>();
// We want to keep track of how each execution context is linked to targets so we may use this
// information to link scripts with no target to a target
const executionContextIsolateToTarget: Map<string, Protocol.Target.TargetID> =
new Map<string, Protocol.Target.TargetID>();
const targetToScripts: Map<Protocol.Target.TargetID, RehydratingScript[]> =
new Map<Protocol.Target.TargetID, RehydratingScript[]>();
const orphanScripts: RehydratingScript[] = [];
const targetToResources: Map<Protocol.Target.TargetID, RehydratingResource[]> =
new Map<Protocol.Target.TargetID, RehydratingResource[]>();
// Initialize all the mapping needed
for (const target of targets) {
targetIds.add(target.targetId);
targetToExecutionContexts.set(target.targetId, []);
targetToScripts.set(target.targetId, []);
targetToResources.set(target.targetId, []);
}
// Put all of the known execution contexts under respective targets
for (const executionContext of executionContexts) {
const frameId = executionContext.auxData?.frameId as string as Protocol.Target.TargetID;
if (frameId && targetIds.has(frameId)) {
targetToExecutionContexts.get(frameId)?.push(executionContext);
executionContextIsolateToTarget.set(
this.getExecutionContextIsolateId(executionContext.isolate, executionContext.id), frameId);
} else {
console.error('Execution context can\'t be linked to a target', executionContext);
}
}
// Put all of the scripts under respective targets with collected information
for (const script of scripts) {
const scriptExecutionContextIsolateId =
this.getExecutionContextIsolateId(script.isolate, script.executionContextId);
const scriptFrameId = script.executionContextAuxData?.frameId as string as Protocol.Target.TargetID;
if (script.executionContextAuxData?.frameId && targetIds.has(scriptFrameId)) {
targetToScripts.get(scriptFrameId)?.push(script);
executionContextIsolateToTarget.set(scriptExecutionContextIsolateId, scriptFrameId);
} else if (this.#scriptToFrame.has(this.getScriptIsolateId(script.isolate, script.scriptId))) {
const targetId = this.#scriptToFrame.get(this.getScriptIsolateId(script.isolate, script.scriptId)) as string as
Protocol.Target.TargetID;
if (targetId) {
targetToScripts.get(targetId)?.push(script);
executionContextIsolateToTarget.set(scriptExecutionContextIsolateId, targetId);
}
} else {
// These scripts are not linked to any target
orphanScripts.push(script);
}
}
// If a script is not linked to a target, use executionContext@isolate to link to a target
// Using PID is the last resort
for (const orphanScript of orphanScripts) {
const orphanScriptExecutionContextIsolateId =
this.getExecutionContextIsolateId(orphanScript.isolate, orphanScript.executionContextId);
const frameId = executionContextIsolateToTarget.get(orphanScriptExecutionContextIsolateId);
if (frameId) {
// Found a link via execution context, use it.
targetToScripts.get(frameId)?.push(orphanScript);
} else if (orphanScript.pid) {
const target = targets.find(target => target.pid === orphanScript.pid);
if (target) {
targetToScripts.get(target.targetId)?.push(orphanScript);
}
} else {
console.error('Script can\'t be linked to any target', orphanScript);
}
}
for (const resource of resources) {
const frameId = resource.frame as Protocol.Target.TargetID;
if (targetIds.has(frameId)) {
targetToResources.get(frameId)?.push(resource);
}
}
// Now all the scripts are linked to a target, we want to make sure all the scripts are pointing to a valid
// execution context. If not, we will create an artificial execution context for the script
for (const target of targets) {
const targetId = target.targetId;
const executionContexts = targetToExecutionContexts.get(targetId) || [];
const scripts = targetToScripts.get(targetId) || [];
const resources = targetToResources.get(targetId) || [];
for (const script of scripts) {
if (!executionContexts.find(context => context.id === script.executionContextId)) {
const artificialContext: RehydratingExecutionContext = {
id: script.executionContextId,
origin: '',
v8Context: '',
name: '',
auxData: {
frameId: targetId as string as Protocol.Page.FrameId,
isDefault: false,
type: 'type',
},
isolate: script.isolate,
uniqueId: `${targetId}-${script.isolate}`,
};
executionContexts.push(artificialContext);
}
}
// Finally, we put all the information into the data structure we want to return as.
data.push({target, executionContexts, scripts, resources});
}
return data;
}
}