chrome-devtools-frontend
Version:
Chrome DevTools UI
310 lines (271 loc) • 10.7 kB
text/typescript
// Copyright 2025 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 * as Trace from '../../../models/trace/trace.js';
interface TreemapNode {
/** Could be a url, a path component from a source map, or an arbitrary string. */
name: string;
resourceBytes: number;
/** Transfer size of the script. Only set for non-inline top-level script nodes. */
encodedBytes?: number;
/** If present, this module is a duplicate. String is normalized source path. See ScriptDuplication.normalizeSource */
duplicatedNormalizedModuleName?: string;
children?: TreemapNode[];
}
export type TreemapData = TreemapNode[];
type SourceData = Omit<TreemapNode, 'name'|'children'>;
/**
* Takes an UTF-8 string and returns a base64 encoded string. The UTF-8 bytes are
* gzipped before base64'd using CompressionStream.
*/
async function toCompressedBase64(string: string): Promise<string> {
let bytes = new TextEncoder().encode(string);
const cs = new CompressionStream('gzip');
const writer = cs.writable.getWriter();
void writer.write(bytes);
void writer.close();
const compAb = await new Response(cs.readable).arrayBuffer();
bytes = new Uint8Array(compAb);
let binaryString = '';
// This is ~25% faster than building the string one character at a time.
// https://jsbench.me/2gkoxazvjl
const chunkSize = 5000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binaryString += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binaryString);
}
/**
* Opens a new tab to an external page and sends data via base64 encoded url params.
*/
async function openTabWithUrlData(data: object, urlString: string, windowName: string): Promise<void> {
const url = new URL(urlString);
url.hash = await toCompressedBase64(JSON.stringify(data));
url.searchParams.set('gzip', '1');
window.open(url.toString(), windowName);
}
/**
* Opens a new tab to the treemap app and sends the data using URL.fragment
*/
export function openTreemap(treemapData: TreemapData, mainDocumentUrl: string, windowNameSuffix: string): void {
const treemapOptions = {
lhr: {
mainDocumentUrl,
audits: {
'script-treemap-data': {
details: {
type: 'treemap-data',
nodes: treemapData,
},
},
},
configSettings: {
locale: i18n.DevToolsLocale.DevToolsLocale.instance().locale,
},
},
initialView: 'duplicate-modules',
};
const url = 'https://googlechrome.github.io/lighthouse/treemap/';
const windowName = `treemap-${windowNameSuffix}`;
void openTabWithUrlData(treemapOptions, url, windowName);
}
/**
* Returns a tree data structure where leaf nodes are sources (ie. real files from
* source tree) from a source map, and non-leaf nodes are directories. Leaf nodes
* have data for bytes, coverage, etc., when available, and non-leaf nodes have the
* same data as the sum of all descendant leaf nodes.
*/
export function makeScriptNode(src: string, sourceRoot: string, sourcesData: Record<string, SourceData>): TreemapNode {
function newNode(name: string): TreemapNode {
return {
name,
resourceBytes: 0,
encodedBytes: undefined,
};
}
const sourceRootNode = newNode(sourceRoot);
/**
* Given a slash-delimited path, traverse the Node structure and increment
* the data provided for each node in the chain. Creates nodes as needed.
* Ex: path/to/file.js will find or create "path" on `node`, increment the data fields,
* and continue with "to", and so on.
*/
function addAllNodesInSourcePath(source: string, data: SourceData): void {
let node = sourceRootNode;
// Apply the data to the sourceRootNode.
sourceRootNode.resourceBytes += data.resourceBytes;
// Strip off the shared root.
const sourcePathSegments = source.replace(sourceRoot, '').split(/\/+/);
sourcePathSegments.forEach((sourcePathSegment, i) => {
if (sourcePathSegment.length === 0) {
return;
}
const isLeaf = i === sourcePathSegments.length - 1;
let child = node.children?.find(child => child.name === sourcePathSegment);
if (!child) {
child = newNode(sourcePathSegment);
node.children = node.children || [];
node.children.push(child);
}
node = child;
// Now that we've found or created the next node in the path, apply the data.
node.resourceBytes += data.resourceBytes;
// Only leaf nodes might have duplication data.
if (isLeaf && data.duplicatedNormalizedModuleName !== undefined) {
node.duplicatedNormalizedModuleName = data.duplicatedNormalizedModuleName;
}
});
}
// For every source file, apply the data to all components
// of the source path, creating nodes as necessary.
for (const [source, data] of Object.entries(sourcesData)) {
addAllNodesInSourcePath(source, data);
}
/**
* Collapse nodes that have only one child.
*/
function collapseAll(node: TreemapNode): void {
while (node.children && node.children.length === 1) {
const child = node.children[0];
node.name += '/' + child.name;
if (child.duplicatedNormalizedModuleName) {
node.duplicatedNormalizedModuleName = child.duplicatedNormalizedModuleName;
}
node.children = child.children;
}
if (node.children) {
for (const child of node.children) {
collapseAll(child);
}
}
}
collapseAll(sourceRootNode);
// If sourceRootNode.name is falsy (no defined sourceRoot + no collapsed common prefix),
// collapse the sourceRootNode children into the scriptNode.
// Otherwise, we add another node.
if (!sourceRootNode.name) {
return {
...sourceRootNode,
name: src,
children: sourceRootNode.children,
};
}
// Script node should be just the script src.
const scriptNode = {...sourceRootNode};
scriptNode.name = src;
scriptNode.children = [sourceRootNode];
return scriptNode;
}
function getNetworkRequestSizes(request: Trace.Types.Events.SyntheticNetworkRequest):
{resourceSize: number, transferSize: number, headersTransferSize: number} {
const resourceSize = request.args.data.decodedBodyLength;
const transferSize = request.args.data.encodedDataLength;
// TODO: add something like `responseHeadersTransferSize` to trace
// SyntheticNetworkRequest (see Lighthouse). For now, incorrectly include the size
// of the headers here.
const headersTransferSize = 0;
return {resourceSize, transferSize, headersTransferSize};
}
/**
* Returns an array of nodes, where the first level of nodes represents every script.
*
* Every external script has a node.
* All inline scripts are combined into a single node.
* If a script has a source map, that node will be created by makeScriptNode.
*
* Example return result:
* - index.html (inline scripts)
* - main.js
* - - webpack://
* - - - react.js
* - - - app.js
* - i-have-no-map.js
*/
export function createTreemapData(
scripts: Trace.Handlers.ModelHandlers.Scripts.ScriptsData,
duplication: Trace.Extras.ScriptDuplication.ScriptDuplication): TreemapData {
const nodes: TreemapNode[] = [];
const htmlNodesByFrameId = new Map<string, TreemapNode>();
for (const script of scripts.scripts) {
if (!script.url) {
continue;
}
const name = script.url;
const sizes = Trace.Handlers.ModelHandlers.Scripts.getScriptGeneratedSizes(script);
let node: TreemapNode;
if (script.sourceMap && sizes && !('errorMessage' in sizes)) {
// Create nodes for each module in a bundle.
const sourcesData: Record<string, SourceData> = {};
for (const [source, resourceBytes] of Object.entries(sizes.files)) {
const sourceData: SourceData = {
resourceBytes,
encodedBytes: undefined,
};
const key = Trace.Extras.ScriptDuplication.normalizeSource(source);
if (duplication.has(key)) {
sourceData.duplicatedNormalizedModuleName = key;
}
sourcesData[source] = sourceData;
}
if (sizes.unmappedBytes) {
const sourceData: SourceData = {
resourceBytes: sizes.unmappedBytes,
};
sourcesData['(unmapped)'] = sourceData;
}
node = makeScriptNode(script.url, script.url, sourcesData);
} else {
// No valid source map for this script, so we can only produce a single node.
node = {
name,
resourceBytes: script.content?.length ?? 0,
encodedBytes: undefined,
};
}
// If this is an inline script, place the node inside a top-level (aka depth-one)
// node. Also separate each iframe / the main page's inline scripts into their
// own top-level nodes.
if (script.inline) {
let htmlNode = htmlNodesByFrameId.get(script.frame);
if (!htmlNode) {
htmlNode = {
name,
resourceBytes: 0,
encodedBytes: undefined,
children: [],
};
htmlNodesByFrameId.set(script.frame, htmlNode);
nodes.push(htmlNode);
}
htmlNode.resourceBytes += node.resourceBytes;
node.name = script.content ? '(inline) ' + script.content.trimStart().substring(0, 15) + '…' : '(inline)';
htmlNode.children?.push(node);
} else {
// Non-inline scripts each have their own top-level node.
nodes.push(node);
if (script.request) {
const {transferSize, headersTransferSize} = getNetworkRequestSizes(script.request);
const bodyTransferSize = transferSize - headersTransferSize;
node.encodedBytes = bodyTransferSize;
} else {
node.encodedBytes = node.resourceBytes;
}
}
}
// For the HTML nodes, set encodedBytes to be the size of all the inline
// scripts multiplied by the average compression ratio of the HTML document.
for (const [frameId, node] of htmlNodesByFrameId) {
const script = scripts.scripts.find(
s => s.request?.args.data.resourceType === 'Document' && s.request?.args.data.frame === frameId);
if (script?.request) {
const {resourceSize, transferSize, headersTransferSize} = getNetworkRequestSizes(script.request);
const inlineScriptsPct = node.resourceBytes / resourceSize;
const bodyTransferSize = transferSize - headersTransferSize;
node.encodedBytes = Math.floor(bodyTransferSize * inlineScriptsPct);
} else {
node.encodedBytes = node.resourceBytes;
}
}
return nodes;
}