webperf-snippets 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -5
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +52 -30
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +15 -0
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +55 -25
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +59 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +22 -3
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1176 -0
- package/snippets/_shared/helpers.js +44 -0
- package/src/bin.js +55 -3
- package/src/crux.js +84 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +22 -1
- package/src/runner.js +4 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Prerender & Speculation Rules Inspector
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(() => {
|
|
5
|
+
const MAX_ITEMS = 50;
|
|
6
|
+
// Chrome limits eager prerenders per page; more than this is wasted or dropped
|
|
7
|
+
const EAGER_PRERENDER_LIMIT = 10;
|
|
8
|
+
|
|
9
|
+
// Activation of a prerendered page is only observable while the snippet listens
|
|
10
|
+
const state = { activated: !document.prerendering, activatedAt: null };
|
|
11
|
+
if (document.prerendering) {
|
|
12
|
+
document.addEventListener(
|
|
13
|
+
'prerenderingchange',
|
|
14
|
+
() => {
|
|
15
|
+
state.activated = true;
|
|
16
|
+
state.activatedAt = Math.round(performance.now());
|
|
17
|
+
console.log('%cPrerendered page activated', 'color: #22c55e; font-weight: bold;');
|
|
18
|
+
},
|
|
19
|
+
{ once: true }
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inspect = () => {
|
|
24
|
+
const issues = [];
|
|
25
|
+
const items = [];
|
|
26
|
+
const actions = {};
|
|
27
|
+
const supported = typeof window.HTMLScriptElement.supports === 'function'
|
|
28
|
+
&& window.HTMLScriptElement.supports('speculationrules');
|
|
29
|
+
|
|
30
|
+
const [nav] = performance.getEntriesByType('navigation');
|
|
31
|
+
const activationStart = Math.round(nav?.activationStart ?? 0);
|
|
32
|
+
const deliveryType = nav?.deliveryType ?? '';
|
|
33
|
+
|
|
34
|
+
const scripts = [...document.querySelectorAll('script[type="speculationrules"]')];
|
|
35
|
+
|
|
36
|
+
scripts.forEach((script, index) => {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(script.textContent);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
issues.push({
|
|
42
|
+
severity: 'error',
|
|
43
|
+
message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.`,
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [action, rules] of Object.entries(parsed ?? {})) {
|
|
49
|
+
if (!Array.isArray(rules)) continue;
|
|
50
|
+
actions[action] = (actions[action] ?? 0) + rules.length;
|
|
51
|
+
|
|
52
|
+
for (const rule of rules) {
|
|
53
|
+
const source = rule.source ?? (rule.where ? 'document' : 'list');
|
|
54
|
+
const urls = Array.isArray(rule.urls) ? rule.urls : [];
|
|
55
|
+
const eagerness = rule.eagerness ?? (source === 'document' ? 'conservative' : 'immediate');
|
|
56
|
+
items.push({
|
|
57
|
+
action,
|
|
58
|
+
source,
|
|
59
|
+
eagerness,
|
|
60
|
+
urls: urls.slice(0, 20),
|
|
61
|
+
urlCount: urls.length,
|
|
62
|
+
where: rule.where ?? null,
|
|
63
|
+
requires: rule.requires ?? [],
|
|
64
|
+
referrerPolicy: rule.referrer_policy ?? null,
|
|
65
|
+
scriptIndex: index,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const isPrerender = action === 'prerender' || action === 'prerender_until_script';
|
|
69
|
+
if (isPrerender && source === 'list' && (eagerness === 'immediate' || eagerness === 'eager') && urls.length > EAGER_PRERENDER_LIMIT) {
|
|
70
|
+
issues.push({
|
|
71
|
+
severity: 'warning',
|
|
72
|
+
message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (isPrerender && source === 'document' && (eagerness === 'immediate' || eagerness === 'eager')) {
|
|
76
|
+
issues.push({
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (scripts.length === 0) {
|
|
86
|
+
issues.push({ severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' });
|
|
87
|
+
} else if (items.length === 0 && !issues.some((i) => i.severity === 'error')) {
|
|
88
|
+
issues.push({ severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' });
|
|
89
|
+
}
|
|
90
|
+
if (!supported && scripts.length > 0) {
|
|
91
|
+
issues.push({ severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' });
|
|
92
|
+
}
|
|
93
|
+
if (activationStart > 0) {
|
|
94
|
+
issues.push({ severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
script: 'Speculation-Rules-Inspector',
|
|
99
|
+
status: 'ok',
|
|
100
|
+
count: items.length,
|
|
101
|
+
details: {
|
|
102
|
+
supported,
|
|
103
|
+
prerendering: document.prerendering === true,
|
|
104
|
+
activated: state.activated,
|
|
105
|
+
activatedAt: state.activatedAt,
|
|
106
|
+
activationStart,
|
|
107
|
+
deliveryType,
|
|
108
|
+
scriptCount: scripts.length,
|
|
109
|
+
actions,
|
|
110
|
+
},
|
|
111
|
+
items: items.slice(0, MAX_ITEMS),
|
|
112
|
+
issues,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
window.getSpeculationRulesInspection = inspect;
|
|
117
|
+
const result = inspect();
|
|
118
|
+
|
|
119
|
+
console.group('Speculation rules');
|
|
120
|
+
console.log(`Prerendering: ${result.details.prerendering}, activationStart: ${result.details.activationStart}ms, deliveryType: "${result.details.deliveryType}"`);
|
|
121
|
+
if (result.items.length > 0) console.table(result.items.map(({ action, source, eagerness, urlCount }) => ({ action, source, eagerness, urlCount })));
|
|
122
|
+
result.issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
|
|
123
|
+
console.groupEnd();
|
|
124
|
+
|
|
125
|
+
if (document.prerendering) {
|
|
126
|
+
return {
|
|
127
|
+
script: 'Speculation-Rules-Inspector',
|
|
128
|
+
status: 'tracking',
|
|
129
|
+
message: 'This page is prerendering. Call getSpeculationRulesInspection() after activation to see the final timings.',
|
|
130
|
+
getDataFn: 'getSpeculationRulesInspection',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
})();
|
|
@@ -2,17 +2,52 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
// Waiting time for the first byte of a resource: responseStart is measured from the
|
|
6
|
+
// start of the page, so subtract requestStart. Cross-origin resources without a
|
|
7
|
+
// Timing-Allow-Origin header report 0 for both and cannot be measured.
|
|
8
|
+
const waitTime = (entry) => entry.responseStart - entry.requestStart;
|
|
9
|
+
const hasTiming = (entry) => entry.responseStart > 0;
|
|
10
|
+
// @shared getRootDomain
|
|
11
|
+
function getRootDomain(hostname) {
|
|
12
|
+
const host = hostname.replace(/\.$/, "");
|
|
13
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
14
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
15
|
+
const parts = host.split(".");
|
|
16
|
+
if (parts.length <= 2) return host;
|
|
17
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
18
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
19
|
+
const tld = parts[parts.length - 1];
|
|
20
|
+
const sld = parts[parts.length - 2];
|
|
21
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
22
|
+
return parts.slice(-2).join(".");
|
|
23
|
+
}
|
|
24
|
+
// @end-shared getRootDomain
|
|
25
|
+
|
|
26
|
+
// @shared isFirstParty
|
|
27
|
+
function isFirstParty(hostname) {
|
|
28
|
+
return getRootDomain(hostname) === getRootDomain(location.hostname);
|
|
29
|
+
}
|
|
30
|
+
// @end-shared isFirstParty
|
|
31
|
+
|
|
32
|
+
const isThirdPartyUrl = (name) => {
|
|
33
|
+
try {
|
|
34
|
+
return !isFirstParty(new URL(name).hostname);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
5
40
|
new PerformanceObserver((entryList) => {
|
|
6
41
|
const entries = entryList.getEntries();
|
|
42
|
+
const restrictedCount = entries.filter((entry) => !hasTiming(entry)).length;
|
|
7
43
|
|
|
8
44
|
const resourcesData = entries
|
|
9
|
-
.filter(
|
|
45
|
+
.filter(hasTiming)
|
|
10
46
|
.map((entry) => {
|
|
11
|
-
const
|
|
12
|
-
const isThirdParty = url.hostname !== location.hostname;
|
|
47
|
+
const isThirdParty = isThirdPartyUrl(entry.name);
|
|
13
48
|
|
|
14
49
|
return {
|
|
15
|
-
ttfb: entry
|
|
50
|
+
ttfb: waitTime(entry),
|
|
16
51
|
duration: entry.duration,
|
|
17
52
|
type: entry.initiatorType,
|
|
18
53
|
thirdParty: isThirdParty,
|
|
@@ -44,6 +79,9 @@
|
|
|
44
79
|
console.log(` Average TTFB: ${avgTtfb.toFixed(0)}ms`);
|
|
45
80
|
console.log(` Fastest: ${minTtfb.toFixed(0)}ms | Slowest: ${maxTtfb.toFixed(0)}ms`);
|
|
46
81
|
console.log(` Third-party resources: ${thirdPartyCount}`);
|
|
82
|
+
if (restrictedCount > 0) {
|
|
83
|
+
console.log(`%c ⚠️ ${restrictedCount} resource(s) not measured: missing Timing-Allow-Origin header`, "color: #f59e0b;");
|
|
84
|
+
}
|
|
47
85
|
if (slowResources > 0) {
|
|
48
86
|
console.log(`%c ⚠️ Slow resources (>500ms): ${slowResources}`, "color: #f59e0b;");
|
|
49
87
|
}
|
|
@@ -82,34 +120,37 @@
|
|
|
82
120
|
});
|
|
83
121
|
|
|
84
122
|
// Synchronous return for agent
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
})
|
|
123
|
+
const allResources = performance.getEntriesByType("resource");
|
|
124
|
+
const corsRestrictedCount = allResources.filter((entry) => !hasTiming(entry)).length;
|
|
125
|
+
const resourcesSync = allResources
|
|
126
|
+
.filter(hasTiming)
|
|
127
|
+
.map((entry) => ({
|
|
128
|
+
url: entry.name,
|
|
129
|
+
ttfbMs: Math.round(waitTime(entry)),
|
|
130
|
+
durationMs: Math.round(entry.duration),
|
|
131
|
+
type: entry.initiatorType,
|
|
132
|
+
isThirdParty: isThirdPartyUrl(entry.name),
|
|
133
|
+
}))
|
|
97
134
|
.sort((a, b) => b.ttfbMs - a.ttfbMs);
|
|
98
135
|
if (resourcesSync.length === 0) {
|
|
99
|
-
return { script: "TTFB-Resources", status: "error", error: "No resources with TTFB data available" };
|
|
136
|
+
return { script: "TTFB-Resources", status: "error", error: "No resources with TTFB data available", corsLimitedAnalysis: corsRestrictedCount > 0, details: { corsRestrictedCount } };
|
|
100
137
|
}
|
|
101
138
|
const ttfbVals = resourcesSync.map((r) => r.ttfbMs);
|
|
139
|
+
// The list keeps the 50 slowest resources (already sorted); count and details cover all of them
|
|
140
|
+
const MAX_ITEMS = 50;
|
|
102
141
|
return {
|
|
103
142
|
script: "TTFB-Resources",
|
|
104
143
|
status: "ok",
|
|
105
144
|
count: resourcesSync.length,
|
|
145
|
+
corsLimitedAnalysis: corsRestrictedCount > 0,
|
|
106
146
|
details: {
|
|
107
147
|
avgTtfbMs: Math.round(ttfbVals.reduce((a, b) => a + b, 0) / ttfbVals.length),
|
|
108
148
|
maxTtfbMs: Math.max(...ttfbVals),
|
|
109
149
|
minTtfbMs: Math.min(...ttfbVals),
|
|
110
150
|
thirdPartyCount: resourcesSync.filter((r) => r.isThirdParty).length,
|
|
111
151
|
slowCount: resourcesSync.filter((r) => r.ttfbMs > 500).length,
|
|
152
|
+
corsRestrictedCount,
|
|
112
153
|
},
|
|
113
|
-
items: resourcesSync,
|
|
154
|
+
items: resourcesSync.slice(0, MAX_ITEMS),
|
|
114
155
|
};
|
|
115
156
|
})();
|
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
const waitEnd = Math.max((pageNav.workerStart || pageNav.fetchStart) - activationStart, 0);
|
|
10
10
|
const dnsStart = Math.max(pageNav.domainLookupStart - activationStart, 0);
|
|
11
11
|
const tcpStart = Math.max(pageNav.connectStart - activationStart, 0);
|
|
12
|
-
const sslStart = Math.max(pageNav.secureConnectionStart - activationStart, 0);
|
|
13
12
|
const tcpEnd = Math.max(pageNav.connectEnd - activationStart, 0);
|
|
13
|
+
// secureConnectionStart is 0 on plain HTTP and reused connections: no TLS phase
|
|
14
|
+
const sslStart =
|
|
15
|
+
pageNav.secureConnectionStart > 0
|
|
16
|
+
? Math.max(pageNav.secureConnectionStart - activationStart, 0)
|
|
17
|
+
: tcpEnd;
|
|
14
18
|
const responseStart = Math.max(pageNav.responseStart - activationStart, 0);
|
|
15
19
|
|
|
16
20
|
const formatMs = (ms) => ms.toFixed(2) + " ms";
|
|
@@ -90,8 +94,11 @@
|
|
|
90
94
|
const waitEndSync = Math.max((navSync.workerStart || navSync.fetchStart) - activStartSync, 0);
|
|
91
95
|
const dnsStartSync = Math.max(navSync.domainLookupStart - activStartSync, 0);
|
|
92
96
|
const tcpStartSync = Math.max(navSync.connectStart - activStartSync, 0);
|
|
93
|
-
const sslStartSync = Math.max(navSync.secureConnectionStart - activStartSync, 0);
|
|
94
97
|
const tcpEndSync = Math.max(navSync.connectEnd - activStartSync, 0);
|
|
98
|
+
const sslStartSync =
|
|
99
|
+
navSync.secureConnectionStart > 0
|
|
100
|
+
? Math.max(navSync.secureConnectionStart - activStartSync, 0)
|
|
101
|
+
: tcpEndSync;
|
|
95
102
|
const subPartsSync = [
|
|
96
103
|
{ key: "redirectWait", duration: waitEndSync },
|
|
97
104
|
{ key: "serviceWorkerCache", duration: dnsStartSync - waitEndSync },
|
package/snippets/Loading/TTFB.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
(() => {
|
|
5
5
|
new PerformanceObserver((entryList) => {
|
|
6
6
|
const [pageNav] = entryList.getEntriesByType("navigation");
|
|
7
|
-
|
|
7
|
+
// Prerendered pages: measure from activation, not from the start of the prerender
|
|
8
|
+
const ttfb = Math.max(0, pageNav.responseStart - (pageNav.activationStart || 0));
|
|
8
9
|
|
|
9
10
|
let rating, color;
|
|
10
11
|
if (ttfb <= 800) {
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
// Synchronous return for agent
|
|
31
32
|
const [nav] = performance.getEntriesByType("navigation");
|
|
32
33
|
if (!nav) return { script: "TTFB", status: "error", error: "No navigation entry" };
|
|
33
|
-
const value = Math.round(nav.responseStart);
|
|
34
|
+
const value = Math.round(Math.max(0, nav.responseStart - (nav.activationStart || 0)));
|
|
34
35
|
const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
|
|
35
36
|
return { script: "TTFB", status: "ok", metric: "TTFB", value, unit: "ms", rating,
|
|
36
37
|
thresholds: { good: 800, needsImprovement: 1800 } };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Third-Party Impact by Domain
|
|
2
|
+
// Groups third-party requests by root domain and reports requests, transfer size, render-blocking and main-thread time
|
|
3
|
+
// https://webperf-snippets.nucliweb.net
|
|
4
|
+
|
|
5
|
+
(() => {
|
|
6
|
+
const MAX_ITEMS = 50;
|
|
7
|
+
const LOAF_WARNING_MS = 250; // Script time in long animation frames above which one domain is a problem
|
|
8
|
+
const MAX_LISTED_DOMAINS = 5;
|
|
9
|
+
|
|
10
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
11
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
12
|
+
const OWN_DOMAINS = [];
|
|
13
|
+
|
|
14
|
+
// @shared formatBytes
|
|
15
|
+
function formatBytes(bytes) {
|
|
16
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
17
|
+
if (bytes === 0) return "0 B";
|
|
18
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
19
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
20
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
21
|
+
}
|
|
22
|
+
// @end-shared formatBytes
|
|
23
|
+
|
|
24
|
+
// A size that misses some resources is a lower bound, and one that misses all of them is unknown
|
|
25
|
+
const sizeLabel = (bytes, unknown, total) => {
|
|
26
|
+
if (total > 0 && unknown === total) return "unknown";
|
|
27
|
+
return (unknown > 0 ? "≥ " : "") + formatBytes(bytes);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// @shared getRootDomain
|
|
31
|
+
function getRootDomain(hostname) {
|
|
32
|
+
const host = hostname.replace(/\.$/, "");
|
|
33
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
34
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
35
|
+
const parts = host.split(".");
|
|
36
|
+
if (parts.length <= 2) return host;
|
|
37
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
38
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
39
|
+
const tld = parts[parts.length - 1];
|
|
40
|
+
const sld = parts[parts.length - 2];
|
|
41
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
42
|
+
return parts.slice(-2).join(".");
|
|
43
|
+
}
|
|
44
|
+
// @end-shared getRootDomain
|
|
45
|
+
|
|
46
|
+
// @shared isFirstParty
|
|
47
|
+
function isFirstParty(hostname) {
|
|
48
|
+
return getRootDomain(hostname) === getRootDomain(location.hostname);
|
|
49
|
+
}
|
|
50
|
+
// @end-shared isFirstParty
|
|
51
|
+
|
|
52
|
+
const ownRoots = new Set(
|
|
53
|
+
OWN_DOMAINS.map((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Root domain of an http(s) URL, or null for anything else (data:, blob:, empty)
|
|
57
|
+
const rootDomainOf = (url) => {
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(url);
|
|
60
|
+
return /^https?:$/.test(u.protocol) ? { root: getRootDomain(u.hostname), first: isFirstParty(u.hostname) || ownRoots.has(getRootDomain(u.hostname)) } : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const domains = new Map();
|
|
67
|
+
const domainEntry = (root) => {
|
|
68
|
+
if (!domains.has(root)) {
|
|
69
|
+
domains.set(root, { domain: root, requests: 0, transferBytes: 0, sizeUnknownCount: 0, renderBlocking: false, loafMs: null, loafScripts: 0 });
|
|
70
|
+
}
|
|
71
|
+
return domains.get(root);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let firstPartyRequests = 0;
|
|
75
|
+
let thirdPartyRequests = 0;
|
|
76
|
+
let thirdPartyBytes = 0;
|
|
77
|
+
let sizeUnknownCount = 0;
|
|
78
|
+
|
|
79
|
+
performance.getEntriesByType("resource").forEach((entry) => {
|
|
80
|
+
const parsed = rootDomainOf(entry.name);
|
|
81
|
+
if (!parsed) return;
|
|
82
|
+
if (parsed.first) {
|
|
83
|
+
firstPartyRequests++;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const d = domainEntry(parsed.root);
|
|
87
|
+
d.requests++;
|
|
88
|
+
thirdPartyRequests++;
|
|
89
|
+
|
|
90
|
+
// Cross-origin resources without Timing-Allow-Origin report every size as zero
|
|
91
|
+
const sizeKnown = entry.transferSize > 0 || entry.encodedBodySize > 0 || entry.decodedBodySize > 0;
|
|
92
|
+
if (sizeKnown) {
|
|
93
|
+
const bytes = entry.transferSize || entry.encodedBodySize || 0;
|
|
94
|
+
d.transferBytes += bytes;
|
|
95
|
+
thirdPartyBytes += bytes;
|
|
96
|
+
} else {
|
|
97
|
+
d.sizeUnknownCount++;
|
|
98
|
+
sizeUnknownCount++;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (entry.renderBlockingStatus === "blocking" && (entry.initiatorType === "script" || entry.initiatorType === "link")) {
|
|
102
|
+
d.renderBlocking = true;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Main-thread time: script time inside long animation frames, attributed by the script's source URL
|
|
107
|
+
const loafSupported = !!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame");
|
|
108
|
+
let thirdPartyLoafMs = null;
|
|
109
|
+
if (loafSupported) {
|
|
110
|
+
thirdPartyLoafMs = 0;
|
|
111
|
+
domains.forEach((d) => {
|
|
112
|
+
d.loafMs = 0;
|
|
113
|
+
});
|
|
114
|
+
performance.getEntriesByType("long-animation-frame").forEach((frame) => {
|
|
115
|
+
(frame.scripts || []).forEach((script) => {
|
|
116
|
+
const parsed = rootDomainOf(script.sourceURL);
|
|
117
|
+
if (!parsed || parsed.first) return;
|
|
118
|
+
const d = domainEntry(parsed.root);
|
|
119
|
+
d.loafMs = (d.loafMs || 0) + script.duration;
|
|
120
|
+
d.loafScripts++;
|
|
121
|
+
thirdPartyLoafMs += script.duration;
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
domains.forEach((d) => {
|
|
125
|
+
d.loafMs = Math.round(d.loafMs || 0);
|
|
126
|
+
});
|
|
127
|
+
thirdPartyLoafMs = Math.round(thirdPartyLoafMs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Render-blocking first, then main-thread time, then transfer size
|
|
131
|
+
const all = [...domains.values()].sort(
|
|
132
|
+
(a, b) =>
|
|
133
|
+
Number(b.renderBlocking) - Number(a.renderBlocking) ||
|
|
134
|
+
(b.loafMs || 0) - (a.loafMs || 0) ||
|
|
135
|
+
b.transferBytes - a.transferBytes ||
|
|
136
|
+
a.domain.localeCompare(b.domain)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const blocking = all.filter((d) => d.renderBlocking);
|
|
140
|
+
const heavy = all.filter((d) => (d.loafMs || 0) > LOAF_WARNING_MS);
|
|
141
|
+
const corsLimitedAnalysis = sizeUnknownCount > 0;
|
|
142
|
+
|
|
143
|
+
const listDomains = (list) => {
|
|
144
|
+
const names = list.slice(0, MAX_LISTED_DOMAINS).map((d) => d.domain);
|
|
145
|
+
return names.join(", ") + (list.length > MAX_LISTED_DOMAINS ? ` and ${list.length - MAX_LISTED_DOMAINS} more` : "");
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const issues = [];
|
|
149
|
+
if (blocking.length > 0) {
|
|
150
|
+
issues.push({
|
|
151
|
+
severity: "warning",
|
|
152
|
+
message: `${blocking.length} third-party domain(s) serve render-blocking resources (${listDomains(blocking)}); load them async or defer, or self-host them`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
heavy.slice(0, MAX_LISTED_DOMAINS).forEach((d) => {
|
|
156
|
+
issues.push({
|
|
157
|
+
severity: "warning",
|
|
158
|
+
message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
if (corsLimitedAnalysis) {
|
|
162
|
+
issues.push({
|
|
163
|
+
severity: "info",
|
|
164
|
+
message: `${sizeUnknownCount} third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.group("%c🌐 Third-Party Impact by Domain", "font-weight: bold; font-size: 14px;");
|
|
169
|
+
console.log(
|
|
170
|
+
`Third-party domains: ${all.length} | requests: ${thirdPartyRequests} | transfer: ${sizeLabel(thirdPartyBytes, sizeUnknownCount, thirdPartyRequests)} | first-party requests: ${firstPartyRequests}`
|
|
171
|
+
);
|
|
172
|
+
if (all.length > 0) {
|
|
173
|
+
console.table(
|
|
174
|
+
all.slice(0, MAX_ITEMS).map((d) => ({
|
|
175
|
+
Domain: d.domain,
|
|
176
|
+
Requests: d.requests,
|
|
177
|
+
Size: sizeLabel(d.transferBytes, d.sizeUnknownCount, d.requests),
|
|
178
|
+
"Render-blocking": d.renderBlocking ? "yes" : "no",
|
|
179
|
+
"Main thread": d.loafMs === null ? "n/a" : d.loafMs + " ms",
|
|
180
|
+
}))
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
console.log("%c✅ No third-party requests found", "color: #22c55e;");
|
|
184
|
+
}
|
|
185
|
+
if (!loafSupported) {
|
|
186
|
+
console.log("%cℹ️ Long Animation Frames are not supported; main-thread time is unavailable (Chrome 123+)", "color: #3b82f6;");
|
|
187
|
+
}
|
|
188
|
+
if (corsLimitedAnalysis) {
|
|
189
|
+
console.log("%cℹ️ Some third-party sizes are hidden (no Timing-Allow-Origin); sizes are a lower bound", "color: #3b82f6;");
|
|
190
|
+
}
|
|
191
|
+
if (OWN_DOMAINS.length === 0 && all.length > 0) {
|
|
192
|
+
console.log(
|
|
193
|
+
'%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
|
|
194
|
+
"color: #3b82f6;"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
console.groupEnd();
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
script: "Third-Party-Impact-by-Domain",
|
|
201
|
+
status: "ok",
|
|
202
|
+
count: all.length,
|
|
203
|
+
corsLimitedAnalysis,
|
|
204
|
+
details: {
|
|
205
|
+
thirdPartyRequests,
|
|
206
|
+
thirdPartyBytes,
|
|
207
|
+
thirdPartyLoafMs,
|
|
208
|
+
firstPartyRequests,
|
|
209
|
+
blockingDomains: blocking.length,
|
|
210
|
+
loafSupported,
|
|
211
|
+
sizeUnknownCount,
|
|
212
|
+
},
|
|
213
|
+
items: all.slice(0, MAX_ITEMS),
|
|
214
|
+
issues,
|
|
215
|
+
};
|
|
216
|
+
})();
|
|
@@ -378,6 +378,23 @@
|
|
|
378
378
|
|
|
379
379
|
console.groupEnd();
|
|
380
380
|
|
|
381
|
+
// One item per script (an issue replaces the valid entry of the same URL), at most 50
|
|
382
|
+
const itemsByUrl = new Map();
|
|
383
|
+
validPreloads.forEach((v) => itemsByUrl.set(v.url, {
|
|
384
|
+
url: v.shortUrl,
|
|
385
|
+
type: v.type === "dynamic" ? "dynamic" : v.type === "mitigated" ? "mitigated" : "blocking",
|
|
386
|
+
strategy: v.attributes || "blocking-script",
|
|
387
|
+
location: v.location || "unknown",
|
|
388
|
+
reviewNote: v.reviewNote || v.note || "",
|
|
389
|
+
}));
|
|
390
|
+
issues.forEach((i) => itemsByUrl.set(i.url, {
|
|
391
|
+
url: i.shortUrl,
|
|
392
|
+
type: "issue",
|
|
393
|
+
strategy: i.attributes || i.type,
|
|
394
|
+
location: i.location || "unknown",
|
|
395
|
+
reviewNote: i.fix || "",
|
|
396
|
+
}));
|
|
397
|
+
|
|
381
398
|
return {
|
|
382
399
|
script: "Validate-Preload-Async-Defer-Scripts",
|
|
383
400
|
status: "ok",
|
|
@@ -386,9 +403,10 @@
|
|
|
386
403
|
validCount: validPreloads.length,
|
|
387
404
|
issueCount: issues.length,
|
|
388
405
|
},
|
|
389
|
-
items:
|
|
390
|
-
|
|
406
|
+
items: Array.from(itemsByUrl.values()).slice(0, 50),
|
|
407
|
+
issues: issues.map(i => ({
|
|
408
|
+
severity: i.severity,
|
|
409
|
+
message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message,
|
|
391
410
|
})),
|
|
392
|
-
issues: issues.map(i => ({ severity: i.severity, message: i.message })),
|
|
393
411
|
};
|
|
394
412
|
})();
|
|
@@ -19,6 +19,7 @@ When TTFB is slow or the user asks "why is my server slow":
|
|
|
19
19
|
2. **TTFB-Sub-Parts.js** - Break down into DNS, connection, server time
|
|
20
20
|
3. **Service-Worker-Analysis.js** - Check for SW overhead impacting TTFB
|
|
21
21
|
4. **TTFB-Resources.js** - Identify slow third-party or API endpoints
|
|
22
|
+
5. **Server-Timing-Early-Hints.js** - Read backend phases from `Server-Timing` and check 103 Early Hints
|
|
22
23
|
|
|
23
24
|
### Font Loading Optimization
|
|
24
25
|
|
|
@@ -27,6 +28,15 @@ When the user asks about fonts, FOIT, FOUT, or font performance:
|
|
|
27
28
|
1. **Fonts-Preloaded-Loaded-and-used-above-the-fold.js** - Full font audit
|
|
28
29
|
2. **Resource-Hints-Validation.js** - Verify font preloads are correct
|
|
29
30
|
3. **Find-render-blocking-resources.js** - Check if fonts block rendering
|
|
31
|
+
4. **Webfont-Usage-Analyzer.js** - Find font faces that are unused, never loaded, or use a blocking `font-display`
|
|
32
|
+
|
|
33
|
+
### Text Compression Audit
|
|
34
|
+
|
|
35
|
+
When the user asks about compression, gzip, brotli, or large transfer sizes for text resources:
|
|
36
|
+
|
|
37
|
+
1. **Compression-Audit.js** - Find text resources served without compression and estimate savings
|
|
38
|
+
2. **Find-render-blocking-resources.js** - Prioritize uncompressed CSS/JS that block rendering
|
|
39
|
+
3. **TTFB-Resources.js** - Check server response of the uncompressed resources
|
|
30
40
|
|
|
31
41
|
### Script Performance Deep Dive
|
|
32
42
|
|
|
@@ -34,6 +44,7 @@ When scripts are suspected to slow down the page:
|
|
|
34
44
|
|
|
35
45
|
1. **Script-Loading.js** - Identify blocking scripts and loading strategy
|
|
36
46
|
2. **First-And-Third-Party-Script-Info.js** - Separate first vs third-party impact
|
|
47
|
+
- **Third-Party-Impact-by-Domain.js** - Rank third-party domains by render-blocking, main-thread time and size
|
|
37
48
|
3. **First-And-Third-Party-Script-Timings.js** - Diagnose slow script connections
|
|
38
49
|
4. **JS-Execution-Time-Breakdown.js** - Network vs parse/execution time
|
|
39
50
|
5. **Inline-Script-Info-and-Size.js** - Measure inline script overhead
|
|
@@ -48,6 +59,7 @@ When the user wants to optimize resource loading priorities:
|
|
|
48
59
|
3. **Priority-Hints-Audit.js** - Check fetchpriority usage
|
|
49
60
|
4. **Prefetch-Resource-Validation.js** - Validate prefetch strategy
|
|
50
61
|
5. **Validate-Preload-Async-Defer-Scripts.js** - Find conflicts
|
|
62
|
+
6. **Speculation-Rules-Inspector.js** - Inspect prefetch/prerender rules and prerender activation
|
|
51
63
|
|
|
52
64
|
### CSS Optimization Workflow
|
|
53
65
|
|
|
@@ -76,6 +88,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
76
88
|
- **If TTFB > 600ms** → Run **TTFB-Sub-Parts.js** to diagnose where time is spent
|
|
77
89
|
- **If Service Worker detected** → Run **Service-Worker-Analysis.js** to check for SW overhead
|
|
78
90
|
- **If TTFB varies significantly across resources** → Run **TTFB-Resources.js**
|
|
91
|
+
- **If server time dominates TTFB-Sub-Parts.js** → Run **Server-Timing-Early-Hints.js** to see which backend phase is slow
|
|
92
|
+
|
|
93
|
+
### After Server-Timing-Early-Hints.js
|
|
94
|
+
|
|
95
|
+
- **If no `Server-Timing` metrics found** → Recommend adding `Server-Timing` headers for database, cache and render phases
|
|
96
|
+
- **If cross-origin resources look hidden** → Recommend `Timing-Allow-Origin` on those responses
|
|
97
|
+
- **If no 103 Early Hints received and server time is high** → Recommend Early Hints for the main stylesheet and LCP image
|
|
79
98
|
|
|
80
99
|
### After FCP.js
|
|
81
100
|
|
|
@@ -109,6 +128,19 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
109
128
|
2. **Priority-Hints-Audit.js** (LCP candidate)
|
|
110
129
|
- **If preloads on async/defer scripts** → Run **Validate-Preload-Async-Defer-Scripts.js**
|
|
111
130
|
|
|
131
|
+
### After Compression-Audit.js
|
|
132
|
+
|
|
133
|
+
- **If uncompressed CSS/JS found** → Run **Find-render-blocking-resources.js** to see if they block rendering
|
|
134
|
+
- **If estimated savings > 100KB** → Recommend enabling gzip or brotli on the server or CDN
|
|
135
|
+
- **If corsLimitedAnalysis is true** → Report the result as a lower bound and suggest `Timing-Allow-Origin` on resources you control
|
|
136
|
+
|
|
137
|
+
### After Third-Party-Impact-by-Domain.js
|
|
138
|
+
|
|
139
|
+
- **If a domain is render-blocking** → Recommend `async`, `defer`, lazy loading or self-hosting; run **Script-Loading.js** for the loading strategy
|
|
140
|
+
- **If a domain's main-thread time > 250ms** → Run **Long-Animation-Frames-Script-Attribution.js** (webperf-interaction) to find the functions responsible, and recommend delaying or removing the script
|
|
141
|
+
- **If many domains with few requests each** → Recommend auditing tags and removing unused ones
|
|
142
|
+
- **If corsLimitedAnalysis is true** → Report sizes as a lower bound; use **First-And-Third-Party-Script-Timings.js** for timings of the hidden resources
|
|
143
|
+
|
|
112
144
|
### After Service-Worker-Analysis.js
|
|
113
145
|
|
|
114
146
|
- **If SW overhead > 100ms** → Recommend Navigation Preload API
|
|
@@ -119,7 +151,14 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
119
151
|
|
|
120
152
|
- **If fonts preloaded but not used above-the-fold** → Recommend removing preloads
|
|
121
153
|
- **If fonts used but not preloaded** → Recommend adding preload
|
|
122
|
-
- **If many font variants loaded** → Suggest subsetting or reducing variants
|
|
154
|
+
- **If many font variants loaded** → Suggest subsetting or reducing variants, and run **Webfont-Usage-Analyzer.js** to find the unused ones
|
|
155
|
+
|
|
156
|
+
### After Webfont-Usage-Analyzer.js
|
|
157
|
+
|
|
158
|
+
- **If fonts loaded but not used** → Recommend removing the `@font-face` declaration or the preload
|
|
159
|
+
- **If faces use `font-display: auto` or `block`** → Recommend `swap` or `optional`, then check **Find-render-blocking-resources.js**
|
|
160
|
+
- **If font bytes are high** → Suggest subsetting with `unicode-range` and WOFF2
|
|
161
|
+
- **If the LCP element is text** → Run **LCP.js** with the **webperf-core-web-vitals** skill
|
|
123
162
|
|
|
124
163
|
### After First-And-Third-Party-Script-Info.js
|
|
125
164
|
|
|
@@ -145,6 +184,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
145
184
|
- **If >10 prefetch hints** → Recommend reducing to critical resources
|
|
146
185
|
- **If individual prefetch > 500KB** → Question necessity
|
|
147
186
|
- **If total prefetch > 2MB** → Flag as mobile bandwidth concern
|
|
187
|
+
- **If few or no prefetch hints for likely next navigations** → Run **Speculation-Rules-Inspector.js** to check for prefetch/prerender rules
|
|
188
|
+
|
|
189
|
+
### After Speculation-Rules-Inspector.js
|
|
190
|
+
|
|
191
|
+
- **If no speculation rules found but the site has predictable navigations** → Recommend adding prefetch or prerender rules
|
|
192
|
+
- **If invalid JSON or wasteful rules (eager prerender of many URLs)** → Recommend fixing or lowering eagerness to `moderate` or `conservative`
|
|
193
|
+
- **If the page was prerendered (`activationStart` > 0)** → Subtract `activationStart` when interpreting **TTFB.js**, **FCP.js** and LCP results
|
|
148
194
|
|
|
149
195
|
### After Critical-CSS-Detection.js
|
|
150
196
|
|