chrome-devtools-frontend
Version:
Chrome DevTools UI
241 lines (206 loc) • 9.23 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 Platform from '../../../core/platform/platform.js';
import * as Handlers from '../handlers/handlers.js';
import * as Helpers from '../helpers/helpers.js';
import type * as Lantern from '../lantern/lantern.js';
import type * as Types from '../types/types.js';
import {
InsightCategory,
InsightKeys,
type InsightModel,
type InsightSetContext,
type MetricSavings,
type PartialInsightModel,
} from './types.js';
export const UIStrings = {
/**
* @description Title of an insight that recommends using HTTP/2 over HTTP/1.1 because of the performance benefits. "HTTP" should not be translated.
*/
title: 'Modern HTTP',
/**
* @description Description of an insight that recommends recommends using HTTP/2 over HTTP/1.1 because of the performance benefits. "HTTP" should not be translated.
*/
description:
'HTTP/2 and HTTP/3 offer many benefits over HTTP/1.1, such as multiplexing. [Learn more about using modern HTTP](https://developer.chrome.com/docs/lighthouse/best-practices/uses-http2/).',
/**
* @description Column header for a table where each cell represents a network request.
*/
request: 'Request',
/**
* @description Column header for a table where each cell represents the protocol of a network request.
*/
protocol: 'Protocol',
/**
* @description Text explaining that there were not requests that were slowed down by using HTTP/1.1. "HTTP/1.1" should not be translated.
*/
noOldProtocolRequests: 'No requests used HTTP/1.1'
} as const;
const str_ = i18n.i18n.registerUIStrings('models/trace/insights/ModernHTTP.ts', UIStrings);
export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export type UseModernHTTPInsightModel = InsightModel<typeof UIStrings, {
requests: Types.Events.SyntheticNetworkRequest[],
}>;
/**
* Determines whether a network request is a "static resource" that would benefit from H2 multiplexing.
* XHRs, tracking pixels, etc generally don't benefit as much because they aren't requested en-masse
* for the same origin at the exact same time.
*/
function isMultiplexableStaticAsset(
request: Types.Events.SyntheticNetworkRequest, entityMappings: Handlers.Helpers.EntityMappings,
firstPartyEntity: Handlers.Helpers.Entity|null): boolean {
if (!Helpers.Network.STATIC_RESOURCE_TYPES.has(request.args.data.resourceType)) {
return false;
}
// Resources from third-parties that are less than 100 bytes are usually tracking pixels, not actual resources.
// They can masquerade as static types though (gifs, documents, etc)
if (request.args.data.decodedBodyLength < 100) {
const entity = entityMappings.entityByEvent.get(request);
if (entity) {
// Third-party assets are multiplexable in their first-party context.
if (firstPartyEntity?.name === entity.name) {
return true;
}
// Skip recognizable third-parties' requests.
if (!entity.isUnrecognized) {
return false;
}
}
}
return true;
}
/**
* Determine the set of resources that aren't HTTP/2 but should be.
* We're a little conservative about what we surface for a few reasons:
*
* - The simulator approximation of HTTP/2 is a little more generous than reality.
* - There's a bit of debate surrounding HTTP/2 due to its worse performance in environments with high packet loss. [1][2][3]
* - It's something that you'd have absolutely zero control over with a third-party (can't defer to fix it for example).
*
* Therefore, we only surface requests that were...
*
* - Served over HTTP/1.1 or earlier
* - Served over an origin that serves at least 6 static asset requests
* (if there aren't more requests than browser's max/host, multiplexing isn't as big a deal)
* - Not served on localhost (h2 is a pain to deal with locally & and CI)
*
* [1] https://news.ycombinator.com/item?id=19086639
* [2] https://www.twilio.com/blog/2017/10/http2-issues.html
* [3] https://www.cachefly.com/http-2-is-not-a-magic-bullet/
*/
export function determineNonHttp2Resources(
requests: Types.Events.SyntheticNetworkRequest[], entityMappings: Handlers.Helpers.EntityMappings,
firstPartyEntity: Handlers.Helpers.Entity|null): Types.Events.SyntheticNetworkRequest[] {
const nonHttp2Resources: Types.Events.SyntheticNetworkRequest[] = [];
const groupedByOrigin = new Map<string, Types.Events.SyntheticNetworkRequest[]>();
for (const record of requests) {
const url = new URL(record.args.data.url);
if (!isMultiplexableStaticAsset(record, entityMappings, firstPartyEntity)) {
continue;
}
if (Helpers.Network.isSyntheticNetworkRequestLocalhost(record)) {
continue;
}
const originRequests = Platform.MapUtilities.getWithDefault(groupedByOrigin, url.origin, () => []);
originRequests.push(record);
}
const seenURLs = new Set<string>();
for (const request of requests) {
// Skip duplicates.
if (seenURLs.has(request.args.data.url)) {
continue;
}
// Check if record is not served through the service worker, servicer worker uses http/1.1 as a protocol.
// These can generate false positives (bug: https://github.com/GoogleChrome/lighthouse/issues/7158).
if (request.args.data.fromServiceWorker) {
continue;
}
// Test the protocol to see if it was http/1.1.
const isOldHttp = /HTTP\/[01][.\d]?/i.test(request.args.data.protocol);
if (!isOldHttp) {
continue;
}
const url = new URL(request.args.data.url);
// Check if the origin has enough requests to bother flagging.
const group = groupedByOrigin.get(url.origin) || [];
if (group.length < 6) {
continue;
}
seenURLs.add(request.args.data.url);
nonHttp2Resources.push(request);
}
return nonHttp2Resources;
}
/**
* Computes the estimated effect of all results being converted to http/2 on the provided graph.
*/
function computeWasteWithGraph(
urlsToChange: Set<string>, graph: Lantern.Graph.Node, simulator: Lantern.Simulation.Simulator): Types.Timing.Milli {
const simulationBefore = simulator.simulate(graph);
// Update all the protocols to reflect implementing our recommendations
const originalProtocols = new Map();
graph.traverse(node => {
if (node.type !== 'network') {
return;
}
if (!urlsToChange.has(node.request.url)) {
return;
}
originalProtocols.set(node.request.requestId, node.request.protocol);
node.request.protocol = 'h2';
});
const simulationAfter = simulator.simulate(graph);
// Restore the original protocol after we've done our simulation
graph.traverse(node => {
if (node.type !== 'network') {
return;
}
const originalProtocol = originalProtocols.get(node.request.requestId);
if (originalProtocol === undefined) {
return;
}
node.request.protocol = originalProtocol;
});
const savings = simulationBefore.timeInMs - simulationAfter.timeInMs;
return Platform.NumberUtilities.floor(savings, 1 / 10) as Types.Timing.Milli;
}
function computeMetricSavings(
nonHttp2Requests: Types.Events.SyntheticNetworkRequest[], context: InsightSetContext): MetricSavings|undefined {
if (!context.navigation || !context.lantern) {
return;
}
const urlsToChange = new Set(nonHttp2Requests.map(r => r.args.data.url));
const fcpGraph = context.lantern.metrics.firstContentfulPaint.optimisticGraph;
const lcpGraph = context.lantern.metrics.largestContentfulPaint.optimisticGraph;
return {
FCP: computeWasteWithGraph(urlsToChange, fcpGraph, context.lantern.simulator),
LCP: computeWasteWithGraph(urlsToChange, lcpGraph, context.lantern.simulator),
};
}
function finalize(partialModel: PartialInsightModel<UseModernHTTPInsightModel>): UseModernHTTPInsightModel {
return {
insightKey: InsightKeys.IMAGE_DELIVERY,
strings: UIStrings,
title: i18nString(UIStrings.title),
description: i18nString(UIStrings.description),
category: InsightCategory.LCP,
state: partialModel.requests.length > 0 ? 'fail' : 'pass',
...partialModel,
relatedEvents: partialModel.requests,
};
}
export function generateInsight(
parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContext): UseModernHTTPInsightModel {
const isWithinContext = (event: Types.Events.Event): boolean => Helpers.Timing.eventIsInBounds(event, context.bounds);
const contextRequests = parsedTrace.NetworkRequests.byTime.filter(isWithinContext);
const entityMappings = parsedTrace.NetworkRequests.entityMappings;
const firstPartyUrl = context.navigation?.args.data?.documentLoaderURL ?? parsedTrace.Meta.mainFrameURL;
const firstPartyEntity = Handlers.Helpers.getEntityForUrl(firstPartyUrl, entityMappings.createdEntityCache);
const nonHttp2Requests = determineNonHttp2Resources(contextRequests, entityMappings, firstPartyEntity ?? null);
return finalize({
requests: nonHttp2Requests,
metricSavings: computeMetricSavings(nonHttp2Requests, context),
});
}