Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
963 lines
No EOL
40 KiB
Text
963 lines
No EOL
40 KiB
Text
// 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 Helpers from '../helpers/helpers.js';
|
|
import * as Types from '../types/types.js';
|
|
import { InsightCategory, InsightKeys, } from './types.js';
|
|
export const UIStrings = {
|
|
/**
|
|
* @description Title of an insight that recommends avoiding chaining critical requests.
|
|
*/
|
|
title: 'Network dependency tree',
|
|
/**
|
|
* @description Description of an insight that recommends avoiding chaining critical requests.
|
|
*/
|
|
description: '[Avoid chaining critical requests](https://developer.chrome.com/docs/lighthouse/performance/critical-request-chains) by reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load.',
|
|
/**
|
|
* @description Description of the warning that recommends avoiding chaining critical requests.
|
|
*/
|
|
warningDescription: 'Avoid chaining critical requests by reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load.',
|
|
/**
|
|
* @description Text status indicating that there isn't long chaining critical network requests.
|
|
*/
|
|
noNetworkDependencyTree: 'No rendering tasks impacted by network dependencies',
|
|
/**
|
|
* @description Text for the maximum critical path latency. This refers to the longest chain of network requests that
|
|
* the browser must download before it can render the page.
|
|
*/
|
|
maxCriticalPathLatency: 'Max critical path latency:',
|
|
/** Label for a column in a data table; entries will be the network request */
|
|
columnRequest: 'Request',
|
|
/** Label for a column in a data table; entries will be the time from main document till current network request. */
|
|
columnTime: 'Time',
|
|
/**
|
|
* @description Title of the table of the detected preconnect origins.
|
|
*/
|
|
preconnectOriginsTableTitle: 'Preconnected origins',
|
|
/**
|
|
* @description Description of the table of the detected preconnect origins.
|
|
*/
|
|
preconnectOriginsTableDescription: '[preconnect](https://developer.chrome.com/docs/lighthouse/performance/uses-rel-preconnect/) hints help the browser establish a connection earlier in the page load, saving time when the first request for that origin is made. The following are the origins that the page preconnected to.',
|
|
/**
|
|
* @description Text status indicating that there isn't any preconnected origins.
|
|
*/
|
|
noPreconnectOrigins: 'no origins were preconnected',
|
|
/**
|
|
* @description A warning message that is shown when found more than 4 preconnected links. "preconnect" should not be translated.
|
|
*/
|
|
tooManyPreconnectLinksWarning: 'More than 4 `preconnect` connections were found. These should be used sparingly and only to the most important origins.',
|
|
/**
|
|
* @description A warning message that is shown when the user added preconnect for some unnecessary origins. "preconnect" should not be translated.
|
|
*/
|
|
unusedWarning: 'Unused preconnect. Only use `preconnect` for origins that the page is likely to request.',
|
|
/**
|
|
* @description A warning message that is shown when the user forget to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preconnect links. "preconnect" should not be translated.
|
|
* */
|
|
crossoriginWarning: 'Unused preconnect. Check that the `crossorigin` attribute is used properly.',
|
|
/**
|
|
* @description Label for a column in a data table; entries will be the source of the origin.
|
|
*/
|
|
columnSource: 'Source',
|
|
/**
|
|
* @description Text status indicating that there isn't preconnect candidates.
|
|
*/
|
|
noPreconnectCandidates: 'No additional origins are good candidates for preconnecting',
|
|
/**
|
|
* @description Title of the table that shows the origins that the page should have preconnected to.
|
|
*/
|
|
estSavingTableTitle: 'Preconnect candidates',
|
|
/**
|
|
* @description Description of the table that recommends preconnecting to the origins to save time. "preconnect" should not be translated.
|
|
*/
|
|
estSavingTableDescription: 'Add [preconnect](https://developer.chrome.com/docs/lighthouse/performance/uses-rel-preconnect/) hints to your most important origins, but try to use no more than 4.',
|
|
/**
|
|
* @description Label for a column in a data table; entries will be the origin of a web resource
|
|
*/
|
|
columnOrigin: 'Origin',
|
|
/**
|
|
* @description Label for a column in a data table; entries will be the number of milliseconds the user could reduce page load by if they implemented the suggestions.
|
|
*/
|
|
columnWastedMs: 'Est LCP savings',
|
|
};
|
|
// const str_ = i18n.i18n.registerUIStrings('models/trace/insights/NetworkDependencyTree.ts', UIStrings);
|
|
export const i18nString = (i18nId, values) => ({i18nId, values}); // i18n.i18n.getLocalizedString.bind(undefined, str_);
|
|
// XHRs are fetched at High priority, but we exclude them, as they are unlikely to be critical
|
|
// Images are also non-critical.
|
|
const nonCriticalResourceTypes = new Set([
|
|
"Image" /* Protocol.Network.ResourceType.Image */,
|
|
"XHR" /* Protocol.Network.ResourceType.XHR */,
|
|
"Fetch" /* Protocol.Network.ResourceType.Fetch */,
|
|
"EventSource" /* Protocol.Network.ResourceType.EventSource */,
|
|
]);
|
|
// Preconnect establishes a "clean" socket. Chrome's socket manager will keep an unused socket
|
|
// around for 10s. Meaning, the time delta between processing preconnect a request should be <10s,
|
|
// otherwise it's wasted. We add a 5s margin so we are sure to capture all key requests.
|
|
// @see https://github.com/GoogleChrome/lighthouse/issues/3106#issuecomment-333653747
|
|
const PRECONNECT_SOCKET_MAX_IDLE_IN_MS = Types.Timing.Milli(15_000);
|
|
const IGNORE_THRESHOLD_IN_MILLISECONDS = Types.Timing.Milli(50);
|
|
export const TOO_MANY_PRECONNECTS_THRESHOLD = 4;
|
|
function finalize(partialModel) {
|
|
return {
|
|
insightKey: InsightKeys.NETWORK_DEPENDENCY_TREE,
|
|
strings: UIStrings,
|
|
title: i18nString(UIStrings.title),
|
|
description: i18nString(UIStrings.description),
|
|
category: InsightCategory.LCP,
|
|
state: partialModel.fail ? 'fail' : 'pass',
|
|
...partialModel,
|
|
};
|
|
}
|
|
function isCritical(request, context) {
|
|
// The main resource is always critical.
|
|
if (request.args.data.requestId === context.navigationId) {
|
|
return true;
|
|
}
|
|
// Treat any preloaded resource as non-critical
|
|
if (request.args.data.isLinkPreload) {
|
|
return false;
|
|
}
|
|
// Iframes are considered High Priority but they are not render blocking
|
|
const isIframe = request.args.data.resourceType === "Document" /* Protocol.Network.ResourceType.Document */ &&
|
|
request.args.data.frame !== context.frameId;
|
|
if (nonCriticalResourceTypes.has(request.args.data.resourceType) || isIframe ||
|
|
// Treat any missed images, primarily favicons, as non-critical resources
|
|
request.args.data.mimeType.startsWith('image/')) {
|
|
return false;
|
|
}
|
|
// Requests that have no initiatorRequest are typically ambiguous late-load assets.
|
|
// Even on the off chance they were important, we don't have any parent to display for them.
|
|
const initiatorUrl = request.args.data.initiator?.url || Helpers.Trace.getZeroIndexedStackTraceInEventPayload(request)?.at(0)?.url;
|
|
if (!initiatorUrl) {
|
|
return false;
|
|
}
|
|
const isBlocking = Helpers.Network.isSyntheticNetworkRequestEventRenderBlocking(request);
|
|
const isHighPriority = Helpers.Network.isSyntheticNetworkRequestHighPriority(request);
|
|
return isHighPriority || isBlocking;
|
|
}
|
|
function findMaxLeafNode(node) {
|
|
if (node.children.length === 0) {
|
|
return node;
|
|
}
|
|
let maxLeaf = node.children[0];
|
|
for (const child of node.children) {
|
|
const leaf = findMaxLeafNode(child);
|
|
if (leaf.timeFromInitialRequest > maxLeaf.timeFromInitialRequest) {
|
|
maxLeaf = leaf;
|
|
}
|
|
}
|
|
return maxLeaf;
|
|
}
|
|
function sortRecursively(nodes) {
|
|
for (const node of nodes) {
|
|
if (node.children.length > 0) {
|
|
node.children.sort((nodeA, nodeB) => {
|
|
const leafA = findMaxLeafNode(nodeA);
|
|
const leafB = findMaxLeafNode(nodeB);
|
|
return leafB.timeFromInitialRequest - leafA.timeFromInitialRequest;
|
|
});
|
|
sortRecursively(node.children);
|
|
}
|
|
}
|
|
}
|
|
function generateNetworkDependencyTree(context) {
|
|
const rootNodes = [];
|
|
const relatedEvents = new Map();
|
|
let maxTime = Types.Timing.Micro(0);
|
|
let fail = false;
|
|
let longestChain = [];
|
|
function addChain(path) {
|
|
if (path.length === 0) {
|
|
return;
|
|
}
|
|
if (path.length >= 2) {
|
|
fail = true;
|
|
}
|
|
const initialRequest = path[0];
|
|
const lastRequest = path[path.length - 1];
|
|
const totalChainTime = Types.Timing.Micro(lastRequest.ts + lastRequest.dur - initialRequest.ts);
|
|
if (totalChainTime > maxTime) {
|
|
maxTime = totalChainTime;
|
|
longestChain = path;
|
|
}
|
|
let currentNodes = rootNodes;
|
|
for (let depth = 0; depth < path.length; ++depth) {
|
|
const request = path[depth];
|
|
// find the request
|
|
let found = currentNodes.find(node => node.request === request);
|
|
if (!found) {
|
|
const timeFromInitialRequest = Types.Timing.Micro(request.ts + request.dur - initialRequest.ts);
|
|
found = {
|
|
request,
|
|
timeFromInitialRequest,
|
|
children: [],
|
|
relatedRequests: new Set(),
|
|
};
|
|
currentNodes.push(found);
|
|
}
|
|
path.forEach(request => found?.relatedRequests.add(request));
|
|
// TODO(b/372897712): When RelatedInsight supports markdown, remove
|
|
// UIStrings.warningDescription and use UIStrings.description.
|
|
relatedEvents.set(request, depth < 2 ? [] : [i18nString(UIStrings.warningDescription)]);
|
|
currentNodes = found.children;
|
|
}
|
|
}
|
|
// By default `traverse` will discover nodes in BFS-order regardless of dependencies, but
|
|
// here we need traversal in a topological sort order. We'll visit a node only when its
|
|
// dependencies have been met.
|
|
const seenNodes = new Set();
|
|
function getNextNodes(node) {
|
|
return node.getDependents().filter(n => n.getDependencies().every(d => seenNodes.has(d)));
|
|
}
|
|
context.lantern?.graph.traverse((node, traversalPath) => {
|
|
seenNodes.add(node);
|
|
if (node.type !== 'network') {
|
|
return;
|
|
}
|
|
const networkNode = node;
|
|
if (!isCritical(networkNode.rawRequest, context)) {
|
|
return;
|
|
}
|
|
const networkPath = traversalPath.filter(node => node.type === 'network').reverse().map(node => node.rawRequest);
|
|
// Ignore if some ancestor is not a critical request.
|
|
if (networkPath.some(request => (!isCritical(request, context)))) {
|
|
return;
|
|
}
|
|
// Ignore non-network things (like data urls).
|
|
if (node.isNonNetworkProtocol) {
|
|
return;
|
|
}
|
|
addChain(networkPath);
|
|
}, getNextNodes);
|
|
// Mark the longest chain
|
|
if (longestChain.length > 0) {
|
|
let currentNodes = rootNodes;
|
|
for (const request of longestChain) {
|
|
const found = currentNodes.find(node => node.request === request);
|
|
if (found) {
|
|
found.isLongest = true;
|
|
currentNodes = found.children;
|
|
}
|
|
else {
|
|
console.error('Some request in the longest chain is not found');
|
|
}
|
|
}
|
|
}
|
|
sortRecursively(rootNodes);
|
|
return {
|
|
rootNodes,
|
|
maxTime,
|
|
fail,
|
|
relatedEvents,
|
|
};
|
|
}
|
|
function getSecurityOrigin(url) {
|
|
const parsedURL = new ParsedURL(url);
|
|
return parsedURL.securityOrigin();
|
|
}
|
|
function handleLinkResponseHeaderPart(trimmedPart) {
|
|
if (!trimmedPart) {
|
|
// Skip empty string
|
|
return null;
|
|
}
|
|
// Extract URL
|
|
const urlStart = trimmedPart.indexOf('<');
|
|
const urlEnd = trimmedPart.indexOf('>');
|
|
if (urlStart !== 0 || urlEnd === -1 || urlEnd <= urlStart) {
|
|
// Skip parts without a valid URI (must start with '<' and have a closing '>')
|
|
return null;
|
|
}
|
|
const url = trimmedPart.substring(urlStart + 1, urlEnd).trim();
|
|
if (!url) {
|
|
// Skip empty url
|
|
return null;
|
|
}
|
|
// Extract parameters string (everything after '>')
|
|
const paramsString = trimmedPart.substring(urlEnd + 1).trim();
|
|
if (paramsString) {
|
|
const params = paramsString.split(';');
|
|
for (const param of params) {
|
|
const trimmedParam = param.trim();
|
|
if (!trimmedParam) {
|
|
continue;
|
|
}
|
|
const eqIndex = trimmedParam.indexOf('=');
|
|
if (eqIndex === -1) {
|
|
// Skip malformed parameters without an '='
|
|
continue;
|
|
}
|
|
const paramName = trimmedParam.substring(0, eqIndex).trim().toLowerCase();
|
|
let paramValue = trimmedParam.substring(eqIndex + 1).trim();
|
|
// Remove quotes from value if present
|
|
if (paramValue.startsWith('"') && paramValue.endsWith('"')) {
|
|
paramValue = paramValue.substring(1, paramValue.length - 1);
|
|
}
|
|
if (paramName === 'rel' && paramValue === 'preconnect') {
|
|
// Found 'rel=preconnect', no need to process other parameters for this link
|
|
return { url, headerText: trimmedPart };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* Parses an HTTP Link header string into an array of url and related header text.
|
|
*
|
|
* Export the function for test purpose.
|
|
* @param linkHeaderValue The value of the HTTP Link header (e.g., '</style.css>; rel=preload; as=style, <https://example.com>; rel="preconnect"').
|
|
* @returns An array of url and header text objects if it contains `rel=preconnect`.
|
|
*/
|
|
export function handleLinkResponseHeader(linkHeaderValue) {
|
|
if (!linkHeaderValue) {
|
|
return [];
|
|
}
|
|
const preconnectedOrigins = [];
|
|
for (let i = 0; i < linkHeaderValue.length;) {
|
|
const firstUrlEnd = linkHeaderValue.indexOf('>', i);
|
|
if (firstUrlEnd === -1) {
|
|
break;
|
|
}
|
|
const commaIndex = linkHeaderValue.indexOf(',', firstUrlEnd);
|
|
const partEnd = commaIndex !== -1 ? commaIndex : linkHeaderValue.length;
|
|
const part = linkHeaderValue.substring(i, partEnd);
|
|
// This shouldn't be necessary, but we had a bug that created an infinite loop so
|
|
// let's guard against that.
|
|
// See crbug.com/431239629
|
|
if (partEnd + 1 <= i) {
|
|
console.warn('unexpected infinite loop, bailing');
|
|
break;
|
|
}
|
|
i = partEnd + 1;
|
|
const preconnectedOrigin = handleLinkResponseHeaderPart(part.trim());
|
|
if (preconnectedOrigin) {
|
|
preconnectedOrigins.push(preconnectedOrigin);
|
|
}
|
|
}
|
|
return preconnectedOrigins;
|
|
}
|
|
// Export the function for test purpose.
|
|
export function generatePreconnectedOrigins(parsedTrace, context, contextRequests, preconnectCandidates) {
|
|
const preconnectedOrigins = [];
|
|
for (const event of parsedTrace.NetworkRequests.linkPreconnectEvents) {
|
|
preconnectedOrigins.push({
|
|
node_id: event.args.data.node_id,
|
|
frame: event.args.data.frame,
|
|
url: event.args.data.url,
|
|
// For each origin the page wanted to preconnect to:
|
|
// - if we found no network requests to that origin at all then we issue a unused warning
|
|
unused: !contextRequests.some(request => getSecurityOrigin(event.args.data.url) === getSecurityOrigin(request.args.data.url)),
|
|
// - else (we found network requests to the same origin) and if some of those network requests is too slow (if
|
|
// they are preconnect candidates), then we issue a unused warning with crossorigin hint
|
|
crossorigin: preconnectCandidates.some(candidate => candidate.origin === getSecurityOrigin(event.args.data.url)),
|
|
source: 'DOM',
|
|
});
|
|
}
|
|
const documentRequest = parsedTrace.NetworkRequests.byId.get(context.navigationId);
|
|
documentRequest?.args.data.responseHeaders?.forEach(header => {
|
|
if (header.name.toLowerCase() === 'link') {
|
|
const preconnectedOriginsFromResponseHeader = handleLinkResponseHeader(header.value); // , documentRequest);
|
|
preconnectedOriginsFromResponseHeader?.forEach(origin => preconnectedOrigins.push({
|
|
url: origin.url,
|
|
headerText: origin.headerText,
|
|
request: documentRequest,
|
|
// For each origin the page wanted to preconnect to:
|
|
// - if we found no network requests to that origin at all then we issue a unused warning
|
|
unused: !contextRequests.some(request => getSecurityOrigin(origin.url) === getSecurityOrigin(request.args.data.url)),
|
|
// - else (we found network requests to the same origin) and if some of those network requests is too slow (if
|
|
// they are preconnect candidates), then we issue a unused warning with crossorigin hint
|
|
crossorigin: preconnectCandidates.some(candidate => candidate.origin === getSecurityOrigin(origin.url)),
|
|
source: 'ResponseHeader',
|
|
}));
|
|
}
|
|
});
|
|
return preconnectedOrigins;
|
|
}
|
|
function hasValidTiming(request) {
|
|
return !!request.args.data.timing && request.args.data.timing.connectEnd >= 0 &&
|
|
request.args.data.timing.connectStart >= 0;
|
|
}
|
|
function hasAlreadyConnectedToOrigin(request) {
|
|
const { timing } = request.args.data;
|
|
if (!timing) {
|
|
return false;
|
|
}
|
|
// When these values are given as -1, that means the page has
|
|
// a connection for this origin and paid these costs already.
|
|
if (timing.dnsStart === -1 && timing.dnsEnd === -1 && timing.connectStart === -1 && timing.connectEnd === -1) {
|
|
return true;
|
|
}
|
|
// Less understood: if the connection setup took no time at all, consider
|
|
// it the same as the above. It is unclear if this is correct, or is even possible.
|
|
if (timing.dnsEnd - timing.dnsStart === 0 && timing.connectEnd - timing.connectStart === 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function socketStartTimeIsBelowThreshold(request, mainResource) {
|
|
const timeSinceMainEnd = Math.max(0, request.args.data.syntheticData.sendStartTime - mainResource.args.data.syntheticData.finishTime);
|
|
return Helpers.Timing.microToMilli(timeSinceMainEnd) < PRECONNECT_SOCKET_MAX_IDLE_IN_MS;
|
|
}
|
|
function candidateRequestsByOrigin(parsedTrace, mainResource, contextRequests, lcpGraphURLs) {
|
|
const origins = new Map();
|
|
contextRequests.forEach(request => {
|
|
if (!hasValidTiming(request)) {
|
|
return;
|
|
}
|
|
// Filter out all resources that are loaded by the document. Connections are already early.
|
|
if (parsedTrace.NetworkRequests.eventToInitiator.get(request) === mainResource) {
|
|
return;
|
|
}
|
|
const url = new URL(request.args.data.url);
|
|
// Filter out urls that do not have an origin (data, file, etc).
|
|
if (url.origin === 'null') {
|
|
return;
|
|
}
|
|
const mainOrigin = new URL(mainResource.args.data.url).origin;
|
|
// Filter out all resources that have the same origin. We're already connected.
|
|
if (url.origin === mainOrigin) {
|
|
return;
|
|
}
|
|
// Filter out anything that wasn't part of LCP. Only recommend important origins.
|
|
if (!lcpGraphURLs.has(request.args.data.url)) {
|
|
return;
|
|
}
|
|
// Filter out all resources where origins are already resolved.
|
|
if (hasAlreadyConnectedToOrigin(request)) {
|
|
return;
|
|
}
|
|
// Make sure the requests are below the PRECONNECT_SOCKET_MAX_IDLE_IN_MS (15s) mark.
|
|
if (!socketStartTimeIsBelowThreshold(request, mainResource)) {
|
|
return;
|
|
}
|
|
const originRequests = Platform.MapUtilities.getWithDefault(origins, url.origin, () => []);
|
|
originRequests.push(request);
|
|
});
|
|
return origins;
|
|
}
|
|
// Export the function for test purpose.
|
|
export function generatePreconnectCandidates(parsedTrace, context, contextRequests) {
|
|
if (!context.lantern) {
|
|
return [];
|
|
}
|
|
const documentRequest = parsedTrace.NetworkRequests.byId.get(context.navigationId);
|
|
if (!documentRequest) {
|
|
return [];
|
|
}
|
|
const { rtt, additionalRttByOrigin } = context.lantern.simulator.getOptions();
|
|
const lcpGraph = context.lantern.metrics.largestContentfulPaint.pessimisticGraph;
|
|
const fcpGraph = context.lantern.metrics.firstContentfulPaint.pessimisticGraph;
|
|
const lcpGraphURLs = new Set();
|
|
lcpGraph.traverse(node => {
|
|
if (node.type === 'network') {
|
|
lcpGraphURLs.add(node.request.url);
|
|
}
|
|
});
|
|
const fcpGraphURLs = new Set();
|
|
fcpGraph.traverse(node => {
|
|
if (node.type === 'network') {
|
|
fcpGraphURLs.add(node.request.url);
|
|
}
|
|
});
|
|
const groupedOrigins = candidateRequestsByOrigin(parsedTrace, documentRequest, contextRequests, lcpGraphURLs);
|
|
let maxWastedLcp = Types.Timing.Milli(0);
|
|
let maxWastedFcp = Types.Timing.Milli(0);
|
|
let preconnectCandidates = [];
|
|
groupedOrigins.forEach(requests => {
|
|
const firstRequestOfOrigin = requests[0];
|
|
// Skip the origin if we don't have timing information
|
|
if (!firstRequestOfOrigin.args.data.timing) {
|
|
return;
|
|
}
|
|
const firstRequestOfOriginParsedURL = new ParsedURL(firstRequestOfOrigin.args.data.url);
|
|
const origin = firstRequestOfOriginParsedURL.securityOrigin();
|
|
// Approximate the connection time with the duration of TCP (+potentially SSL) handshake
|
|
// DNS time can be large but can also be 0 if a commonly used origin that's cached, so make
|
|
// no assumption about DNS.
|
|
const additionalRtt = additionalRttByOrigin.get(origin) ?? 0;
|
|
let connectionTime = Types.Timing.Milli(rtt + additionalRtt);
|
|
// TCP Handshake will be at least 2 RTTs for TLS connections
|
|
if (firstRequestOfOriginParsedURL.scheme === 'https') {
|
|
connectionTime = Types.Timing.Milli(connectionTime * 2);
|
|
}
|
|
const timeBetweenMainResourceAndDnsStart = Types.Timing.Micro(firstRequestOfOrigin.args.data.syntheticData.sendStartTime -
|
|
documentRequest.args.data.syntheticData.finishTime +
|
|
Helpers.Timing.milliToMicro(firstRequestOfOrigin.args.data.timing.dnsStart));
|
|
const wastedMs = Math.min(connectionTime, Helpers.Timing.microToMilli(timeBetweenMainResourceAndDnsStart));
|
|
if (wastedMs < IGNORE_THRESHOLD_IN_MILLISECONDS) {
|
|
return;
|
|
}
|
|
maxWastedLcp = Math.max(wastedMs, maxWastedLcp);
|
|
if (fcpGraphURLs.has(firstRequestOfOrigin.args.data.url)) {
|
|
maxWastedFcp = Math.max(wastedMs, maxWastedFcp);
|
|
}
|
|
preconnectCandidates.push({
|
|
origin,
|
|
wastedMs,
|
|
});
|
|
});
|
|
preconnectCandidates = preconnectCandidates.sort((a, b) => b.wastedMs - a.wastedMs);
|
|
return preconnectCandidates.slice(0, TOO_MANY_PRECONNECTS_THRESHOLD);
|
|
}
|
|
export function generateInsight(parsedTrace, context) {
|
|
if (!context.navigation) {
|
|
return finalize({
|
|
rootNodes: [],
|
|
maxTime: 0,
|
|
fail: false,
|
|
preconnectedOrigins: [],
|
|
preconnectCandidates: [],
|
|
});
|
|
}
|
|
const { rootNodes, maxTime, fail, relatedEvents, } = generateNetworkDependencyTree(context);
|
|
const isWithinContext = (event) => Helpers.Timing.eventIsInBounds(event, context.bounds);
|
|
const contextRequests = parsedTrace.NetworkRequests.byTime.filter(isWithinContext);
|
|
const preconnectCandidates = generatePreconnectCandidates(parsedTrace, context, contextRequests);
|
|
const preconnectedOrigins = generatePreconnectedOrigins(parsedTrace, context, contextRequests, preconnectCandidates);
|
|
return finalize({
|
|
rootNodes,
|
|
maxTime,
|
|
fail,
|
|
relatedEvents,
|
|
preconnectedOrigins,
|
|
preconnectCandidates,
|
|
});
|
|
}
|
|
export function createOverlays(model) {
|
|
function walk(nodes, overlays) {
|
|
nodes.forEach(node => {
|
|
overlays.push({
|
|
type: 'ENTRY_OUTLINE',
|
|
entry: node.request,
|
|
outlineReason: 'ERROR',
|
|
});
|
|
walk(node.children, overlays);
|
|
});
|
|
}
|
|
const overlays = [];
|
|
walk(model.rootNodes, overlays);
|
|
return overlays;
|
|
}
|
|
// the rest of this file is copied from core/common/common.js, which can't be bundled right now.
|
|
/**
|
|
* http://tools.ietf.org/html/rfc3986#section-5.2.4
|
|
*/
|
|
export function normalizePath(path) {
|
|
if (path.indexOf('..') === -1 && path.indexOf('.') === -1) {
|
|
return path;
|
|
}
|
|
// Remove leading slash (will be added back below) so we
|
|
// can handle all (including empty) segments consistently.
|
|
const segments = (path[0] === '/' ? path.substring(1) : path).split('/');
|
|
const normalizedSegments = [];
|
|
for (const segment of segments) {
|
|
if (segment === '.') {
|
|
continue;
|
|
}
|
|
else if (segment === '..') {
|
|
normalizedSegments.pop();
|
|
}
|
|
else {
|
|
normalizedSegments.push(segment);
|
|
}
|
|
}
|
|
let normalizedPath = normalizedSegments.join('/');
|
|
if (path[0] === '/' && normalizedPath) {
|
|
normalizedPath = '/' + normalizedPath;
|
|
}
|
|
if (normalizedPath[normalizedPath.length - 1] !== '/' &&
|
|
((path[path.length - 1] === '/') || (segments[segments.length - 1] === '.') ||
|
|
(segments[segments.length - 1] === '..'))) {
|
|
normalizedPath = normalizedPath + '/';
|
|
}
|
|
return normalizedPath;
|
|
}
|
|
export function schemeIs(url, scheme) {
|
|
try {
|
|
return (new URL(url)).protocol === scheme;
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
export class ParsedURL {
|
|
isValid;
|
|
url;
|
|
scheme;
|
|
user;
|
|
host;
|
|
port;
|
|
path;
|
|
queryParams;
|
|
fragment;
|
|
folderPathComponents;
|
|
lastPathComponent;
|
|
blobInnerScheme;
|
|
constructor(url) {
|
|
this.isValid = false;
|
|
this.url = url;
|
|
this.scheme = '';
|
|
this.user = '';
|
|
this.host = '';
|
|
this.port = '';
|
|
this.path = '';
|
|
this.queryParams = '';
|
|
this.fragment = '';
|
|
this.folderPathComponents = '';
|
|
this.lastPathComponent = '';
|
|
const isBlobUrl = this.url.startsWith('blob:');
|
|
const urlToMatch = isBlobUrl ? url.substring(5) : url;
|
|
const match = urlToMatch.match(ParsedURL.urlRegex());
|
|
if (match) {
|
|
this.isValid = true;
|
|
if (isBlobUrl) {
|
|
this.blobInnerScheme = match[2].toLowerCase();
|
|
this.scheme = 'blob';
|
|
}
|
|
else {
|
|
this.scheme = match[2].toLowerCase();
|
|
}
|
|
this.user = match[3] ?? '';
|
|
this.host = match[4] ?? '';
|
|
this.port = match[5] ?? '';
|
|
this.path = match[6] ?? '/';
|
|
this.queryParams = match[7] ?? '';
|
|
this.fragment = match[8] ?? '';
|
|
}
|
|
else {
|
|
if (this.url.startsWith('data:')) {
|
|
this.scheme = 'data';
|
|
return;
|
|
}
|
|
if (this.url.startsWith('blob:')) {
|
|
this.scheme = 'blob';
|
|
return;
|
|
}
|
|
if (this.url === 'about:blank') {
|
|
this.scheme = 'about';
|
|
return;
|
|
}
|
|
this.path = this.url;
|
|
}
|
|
const lastSlashExceptTrailingIndex = this.path.lastIndexOf('/', this.path.length - 2);
|
|
if (lastSlashExceptTrailingIndex !== -1) {
|
|
this.lastPathComponent = this.path.substring(lastSlashExceptTrailingIndex + 1);
|
|
}
|
|
else {
|
|
this.lastPathComponent = this.path;
|
|
}
|
|
const lastSlashIndex = this.path.lastIndexOf('/');
|
|
if (lastSlashIndex !== -1) {
|
|
this.folderPathComponents = this.path.substring(0, lastSlashIndex);
|
|
}
|
|
}
|
|
static fromString(string) {
|
|
const parsedURL = new ParsedURL(string.toString());
|
|
if (parsedURL.isValid) {
|
|
return parsedURL;
|
|
}
|
|
return null;
|
|
}
|
|
static preEncodeSpecialCharactersInPath(path) {
|
|
// Based on net::FilePathToFileURL. Ideally we would handle
|
|
// '\\' as well on non-Windows file systems.
|
|
for (const specialChar of ['%', ';', '#', '?', ' ']) {
|
|
(path) = path.replaceAll(specialChar, encodeURIComponent(specialChar));
|
|
}
|
|
return path;
|
|
}
|
|
static rawPathToEncodedPathString(path) {
|
|
const partiallyEncoded = ParsedURL.preEncodeSpecialCharactersInPath(path);
|
|
if (path.startsWith('/')) {
|
|
return new URL(partiallyEncoded, 'file:///').pathname;
|
|
}
|
|
// URL prepends a '/'
|
|
return new URL('/' + partiallyEncoded, 'file:///').pathname.substr(1);
|
|
}
|
|
/**
|
|
* @param name Must not be encoded
|
|
*/
|
|
static encodedFromParentPathAndName(parentPath, name) {
|
|
return ParsedURL.concatenate(parentPath, '/', ParsedURL.preEncodeSpecialCharactersInPath(name));
|
|
}
|
|
/**
|
|
* @param name Must not be encoded
|
|
*/
|
|
static urlFromParentUrlAndName(parentUrl, name) {
|
|
return ParsedURL.concatenate(parentUrl, '/', ParsedURL.preEncodeSpecialCharactersInPath(name));
|
|
}
|
|
static encodedPathToRawPathString(encPath) {
|
|
return decodeURIComponent(encPath);
|
|
}
|
|
static rawPathToUrlString(fileSystemPath) {
|
|
let preEncodedPath = ParsedURL.preEncodeSpecialCharactersInPath(fileSystemPath.replace(/\\/g, '/'));
|
|
preEncodedPath = preEncodedPath.replace(/\\/g, '/');
|
|
if (!preEncodedPath.startsWith('file://')) {
|
|
if (preEncodedPath.startsWith('/')) {
|
|
preEncodedPath = 'file://' + preEncodedPath;
|
|
}
|
|
else {
|
|
preEncodedPath = 'file:///' + preEncodedPath;
|
|
}
|
|
}
|
|
return new URL(preEncodedPath).toString();
|
|
}
|
|
static relativePathToUrlString(relativePath, baseURL) {
|
|
const preEncodedPath = ParsedURL.preEncodeSpecialCharactersInPath(relativePath.replace(/\\/g, '/'));
|
|
return new URL(preEncodedPath, baseURL).toString();
|
|
}
|
|
static urlToRawPathString(fileURL, isWindows) {
|
|
console.assert(fileURL.startsWith('file://'), 'This must be a file URL.');
|
|
const decodedFileURL = decodeURIComponent(fileURL);
|
|
if (isWindows) {
|
|
return decodedFileURL.substr('file:///'.length).replace(/\//g, '\\');
|
|
}
|
|
return decodedFileURL.substr('file://'.length);
|
|
}
|
|
static sliceUrlToEncodedPathString(url, start) {
|
|
return url.substring(start);
|
|
}
|
|
static substr(devToolsPath, from, length) {
|
|
return devToolsPath.substr(from, length);
|
|
}
|
|
static substring(devToolsPath, start, end) {
|
|
return devToolsPath.substring(start, end);
|
|
}
|
|
static prepend(prefix, devToolsPath) {
|
|
return prefix + devToolsPath;
|
|
}
|
|
static concatenate(devToolsPath, ...appendage) {
|
|
return devToolsPath.concat(...appendage);
|
|
}
|
|
static trim(devToolsPath) {
|
|
return devToolsPath.trim();
|
|
}
|
|
static slice(devToolsPath, start, end) {
|
|
return devToolsPath.slice(start, end);
|
|
}
|
|
static join(devToolsPaths, separator) {
|
|
return devToolsPaths.join(separator);
|
|
}
|
|
static split(devToolsPath, separator, limit) {
|
|
return devToolsPath.split(separator, limit);
|
|
}
|
|
static toLowerCase(devToolsPath) {
|
|
return devToolsPath.toLowerCase();
|
|
}
|
|
static isValidUrlString(str) {
|
|
return new ParsedURL(str).isValid;
|
|
}
|
|
static urlWithoutHash(url) {
|
|
const hashIndex = url.indexOf('#');
|
|
if (hashIndex !== -1) {
|
|
return url.substr(0, hashIndex);
|
|
}
|
|
return url;
|
|
}
|
|
static urlRegex() {
|
|
if (ParsedURL.urlRegexInstance) {
|
|
return ParsedURL.urlRegexInstance;
|
|
}
|
|
// RegExp groups:
|
|
// 1 - scheme, hostname, ?port
|
|
// 2 - scheme (using the RFC3986 grammar)
|
|
// 3 - ?user:password
|
|
// 4 - hostname
|
|
// 5 - ?port
|
|
// 6 - ?path
|
|
// 7 - ?query
|
|
// 8 - ?fragment
|
|
const schemeRegex = /([A-Za-z][A-Za-z0-9+.-]*):\/\//;
|
|
const userRegex = /(?:([A-Za-z0-9\-._~%!$&'()*+,;=:]*)@)?/;
|
|
const hostRegex = /((?:\[::\d?\])|(?:[^\s\/:]*))/;
|
|
const portRegex = /(?::([\d]+))?/;
|
|
const pathRegex = /(\/[^#?]*)?/;
|
|
const queryRegex = /(?:\?([^#]*))?/;
|
|
const fragmentRegex = /(?:#(.*))?/;
|
|
ParsedURL.urlRegexInstance = new RegExp('^(' + schemeRegex.source + userRegex.source + hostRegex.source + portRegex.source + ')' + pathRegex.source +
|
|
queryRegex.source + fragmentRegex.source + '$');
|
|
return ParsedURL.urlRegexInstance;
|
|
}
|
|
static extractPath(url) {
|
|
const parsedURL = this.fromString(url);
|
|
return (parsedURL ? parsedURL.path : '');
|
|
}
|
|
static extractOrigin(url) {
|
|
const parsedURL = this.fromString(url);
|
|
return parsedURL ? parsedURL.securityOrigin() : '';
|
|
}
|
|
static extractExtension(url) {
|
|
url = ParsedURL.urlWithoutHash(url);
|
|
const indexOfQuestionMark = url.indexOf('?');
|
|
if (indexOfQuestionMark !== -1) {
|
|
url = url.substr(0, indexOfQuestionMark);
|
|
}
|
|
const lastIndexOfSlash = url.lastIndexOf('/');
|
|
if (lastIndexOfSlash !== -1) {
|
|
url = url.substr(lastIndexOfSlash + 1);
|
|
}
|
|
const lastIndexOfDot = url.lastIndexOf('.');
|
|
if (lastIndexOfDot !== -1) {
|
|
url = url.substr(lastIndexOfDot + 1);
|
|
const lastIndexOfPercent = url.indexOf('%');
|
|
if (lastIndexOfPercent !== -1) {
|
|
return url.substr(0, lastIndexOfPercent);
|
|
}
|
|
return url;
|
|
}
|
|
return '';
|
|
}
|
|
static extractName(url) {
|
|
let index = url.lastIndexOf('/');
|
|
const pathAndQuery = index !== -1 ? url.substr(index + 1) : url;
|
|
index = pathAndQuery.indexOf('?');
|
|
return index < 0 ? pathAndQuery : pathAndQuery.substr(0, index);
|
|
}
|
|
static completeURL(baseURL, href) {
|
|
// Return special URLs as-is.
|
|
if (href.startsWith('data:') || href.startsWith('blob:') || href.startsWith('javascript:') ||
|
|
href.startsWith('mailto:')) {
|
|
return href;
|
|
}
|
|
// Return absolute URLs with normalized path and other components as-is.
|
|
const trimmedHref = href.trim();
|
|
const parsedHref = this.fromString(trimmedHref);
|
|
if (parsedHref?.scheme) {
|
|
const securityOrigin = parsedHref.securityOrigin();
|
|
const pathText = normalizePath(parsedHref.path);
|
|
const queryText = parsedHref.queryParams && `?${parsedHref.queryParams}`;
|
|
const fragmentText = parsedHref.fragment && `#${parsedHref.fragment}`;
|
|
return securityOrigin + pathText + queryText + fragmentText;
|
|
}
|
|
const parsedURL = this.fromString(baseURL);
|
|
if (!parsedURL) {
|
|
return null;
|
|
}
|
|
if (parsedURL.isDataURL()) {
|
|
return href;
|
|
}
|
|
if (href.length > 1 && href.charAt(0) === '/' && href.charAt(1) === '/') {
|
|
// href starts with "//" which is a full URL with the protocol dropped (use the baseURL protocol).
|
|
return parsedURL.scheme + ':' + href;
|
|
}
|
|
const securityOrigin = parsedURL.securityOrigin();
|
|
const pathText = parsedURL.path;
|
|
const queryText = parsedURL.queryParams ? '?' + parsedURL.queryParams : '';
|
|
// Empty href resolves to a URL without fragment.
|
|
if (!href.length) {
|
|
return securityOrigin + pathText + queryText;
|
|
}
|
|
if (href.charAt(0) === '#') {
|
|
return securityOrigin + pathText + queryText + href;
|
|
}
|
|
if (href.charAt(0) === '?') {
|
|
return securityOrigin + pathText + href;
|
|
}
|
|
const hrefMatches = href.match(/^[^#?]*/);
|
|
if (!hrefMatches || !href.length) {
|
|
throw new Error('Invalid href');
|
|
}
|
|
let hrefPath = hrefMatches[0];
|
|
const hrefSuffix = href.substring(hrefPath.length);
|
|
if (hrefPath.charAt(0) !== '/') {
|
|
hrefPath = parsedURL.folderPathComponents + '/' + hrefPath;
|
|
}
|
|
return securityOrigin + normalizePath(hrefPath) + hrefSuffix;
|
|
}
|
|
static splitLineAndColumn(string) {
|
|
// Only look for line and column numbers in the path to avoid matching port numbers.
|
|
const beforePathMatch = string.match(ParsedURL.urlRegex());
|
|
let beforePath = '';
|
|
let pathAndAfter = string;
|
|
if (beforePathMatch) {
|
|
beforePath = beforePathMatch[1];
|
|
pathAndAfter = string.substring(beforePathMatch[1].length);
|
|
}
|
|
const lineColumnRegEx = /(?::(\d+))?(?::(\d+))?$/;
|
|
const lineColumnMatch = lineColumnRegEx.exec(pathAndAfter);
|
|
let lineNumber;
|
|
let columnNumber;
|
|
console.assert(Boolean(lineColumnMatch));
|
|
if (!lineColumnMatch) {
|
|
return { url: string, lineNumber: 0, columnNumber: 0 };
|
|
}
|
|
if (typeof (lineColumnMatch[1]) === 'string') {
|
|
lineNumber = parseInt(lineColumnMatch[1], 10);
|
|
// Immediately convert line and column to 0-based numbers.
|
|
lineNumber = isNaN(lineNumber) ? undefined : lineNumber - 1;
|
|
}
|
|
if (typeof (lineColumnMatch[2]) === 'string') {
|
|
columnNumber = parseInt(lineColumnMatch[2], 10);
|
|
columnNumber = isNaN(columnNumber) ? undefined : columnNumber - 1;
|
|
}
|
|
let url = beforePath + pathAndAfter.substring(0, pathAndAfter.length - lineColumnMatch[0].length);
|
|
if (lineColumnMatch[1] === undefined && lineColumnMatch[2] === undefined) {
|
|
const wasmCodeOffsetRegex = /wasm-function\[\d+\]:0x([a-z0-9]+)$/g;
|
|
const wasmCodeOffsetMatch = wasmCodeOffsetRegex.exec(pathAndAfter);
|
|
if (wasmCodeOffsetMatch && typeof (wasmCodeOffsetMatch[1]) === 'string') {
|
|
url = ParsedURL.removeWasmFunctionInfoFromURL(url);
|
|
columnNumber = parseInt(wasmCodeOffsetMatch[1], 16);
|
|
columnNumber = isNaN(columnNumber) ? undefined : columnNumber;
|
|
}
|
|
}
|
|
return { url, lineNumber, columnNumber };
|
|
}
|
|
static removeWasmFunctionInfoFromURL(url) {
|
|
const wasmFunctionRegEx = /:wasm-function\[\d+\]/;
|
|
const wasmFunctionIndex = url.search(wasmFunctionRegEx);
|
|
if (wasmFunctionIndex === -1) {
|
|
return url;
|
|
}
|
|
return ParsedURL.substring(url, 0, wasmFunctionIndex);
|
|
}
|
|
static beginsWithWindowsDriveLetter(url) {
|
|
return /^[A-Za-z]:/.test(url);
|
|
}
|
|
static beginsWithScheme(url) {
|
|
return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(url);
|
|
}
|
|
static isRelativeURL(url) {
|
|
return !this.beginsWithScheme(url) || this.beginsWithWindowsDriveLetter(url);
|
|
}
|
|
isAboutBlank() {
|
|
return this.url === 'about:blank';
|
|
}
|
|
isDataURL() {
|
|
return this.scheme === 'data';
|
|
}
|
|
extractDataUrlMimeType() {
|
|
const regexp = /^data:((?<type>\w+)\/(?<subtype>\w+))?(;base64)?,/;
|
|
const match = this.url.match(regexp);
|
|
return {
|
|
type: match?.groups?.type,
|
|
subtype: match?.groups?.subtype,
|
|
};
|
|
}
|
|
isBlobURL() {
|
|
return this.url.startsWith('blob:');
|
|
}
|
|
lastPathComponentWithFragment() {
|
|
return this.lastPathComponent + (this.fragment ? '#' + this.fragment : '');
|
|
}
|
|
domain() {
|
|
if (this.isDataURL()) {
|
|
return 'data:';
|
|
}
|
|
return this.host + (this.port ? ':' + this.port : '');
|
|
}
|
|
securityOrigin() {
|
|
if (this.isDataURL()) {
|
|
return 'data:';
|
|
}
|
|
const scheme = this.isBlobURL() ? this.blobInnerScheme : this.scheme;
|
|
return scheme + '://' + this.domain();
|
|
}
|
|
urlWithoutScheme() {
|
|
if (this.scheme && this.url.startsWith(this.scheme + '://')) {
|
|
return this.url.substring(this.scheme.length + 3);
|
|
}
|
|
return this.url;
|
|
}
|
|
static urlRegexInstance = null;
|
|
}
|
|
//# sourceMappingURL=NetworkDependencyTree.js.map |