webperf-snippets 0.2.0 → 0.4.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 +142 -14
- 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 +141 -43
- 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 +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -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 +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- 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 +86 -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 +244 -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 +74 -11
- 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 +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- package/src/workflows/loading.js +9 -0
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
new PerformanceObserver((list) => {
|
|
19
19
|
const [entry] = list.getEntries();
|
|
20
20
|
|
|
21
|
+
// loadEventEnd is 0 until the load event finishes: the phases would be meaningless
|
|
22
|
+
if (!entry.loadEventEnd) {
|
|
23
|
+
console.log("%c⏳ The page is still loading. Run this snippet again after the load event.", "color: #f59e0b;");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
const formatMs = (ms) => ms.toFixed(2) + " ms";
|
|
22
28
|
const formatBar = (ms, total) => {
|
|
23
29
|
const pct = total > 0 ? (ms / total) * 100 : 0;
|
|
@@ -104,7 +110,7 @@
|
|
|
104
110
|
// Key milestones
|
|
105
111
|
console.log("");
|
|
106
112
|
console.log("%cKey Milestones:", "font-weight: bold;");
|
|
107
|
-
console.log(` TTFB (Time to First Byte): ${entry.responseStart.toFixed(0)}ms`);
|
|
113
|
+
console.log(` TTFB (Time to First Byte): ${Math.max(0, entry.responseStart - (entry.activationStart || 0)).toFixed(0)}ms`);
|
|
108
114
|
console.log(` DOMContentLoaded: ${entry.domContentLoadedEventEnd.toFixed(0)}ms`);
|
|
109
115
|
console.log(` Load Complete: ${entry.loadEventEnd.toFixed(0)}ms`);
|
|
110
116
|
|
|
@@ -142,6 +148,13 @@
|
|
|
142
148
|
// Synchronous return for agent
|
|
143
149
|
const [navSync] = performance.getEntriesByType("navigation");
|
|
144
150
|
if (!navSync) return { script: "Event-Processing-Time", status: "error", error: "No navigation entry" };
|
|
151
|
+
if (!navSync.loadEventEnd) {
|
|
152
|
+
return {
|
|
153
|
+
script: "Event-Processing-Time",
|
|
154
|
+
status: "tracking",
|
|
155
|
+
message: "The page is still loading. Run this snippet again after the load event.",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
145
158
|
const totalTimeSync = navSync.loadEventEnd - navSync.startTime;
|
|
146
159
|
const networkTimeSync = (navSync.responseEnd || 0) - navSync.startTime;
|
|
147
160
|
const ratingSync = totalTimeSync <= 2500 ? "good" : totalTimeSync <= 4000 ? "needs-improvement" : "poor";
|
|
@@ -169,7 +182,7 @@
|
|
|
169
182
|
details: {
|
|
170
183
|
networkTimeMs: Math.round(networkTimeSync),
|
|
171
184
|
processingTimeMs: Math.round(totalTimeSync - networkTimeSync),
|
|
172
|
-
ttfbMs: Math.round(navSync.responseStart),
|
|
185
|
+
ttfbMs: Math.round(Math.max(0, navSync.responseStart - (navSync.activationStart || 0))),
|
|
173
186
|
domContentLoadedMs: Math.round(navSync.domContentLoadedEventEnd),
|
|
174
187
|
loadCompleteMs: Math.round(navSync.loadEventEnd),
|
|
175
188
|
phases: Object.fromEntries(phasesSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
|
package/snippets/Loading/FCP.js
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
|
|
19
19
|
if (!fcpEntry) return;
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
22
|
+
// Prerendered pages: measure from activation, not from the start of the prerender
|
|
23
|
+
const activationStart = navEntry?.activationStart || 0;
|
|
24
|
+
const fcpTime = Math.max(0, fcpEntry.startTime - activationStart);
|
|
22
25
|
const rating = valueToRating(fcpTime);
|
|
23
26
|
const { icon, color } = RATING[rating];
|
|
24
27
|
|
|
25
|
-
const
|
|
26
|
-
const ttfb = navEntry?.responseStart ?? 0;
|
|
28
|
+
const ttfb = Math.max(0, (navEntry?.responseStart ?? 0) - activationStart);
|
|
27
29
|
|
|
28
30
|
// Render-blocking resources (Chrome 107+, requires renderBlockingStatus API)
|
|
29
31
|
const resources = performance.getEntriesByType("resource");
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
);
|
|
33
35
|
|
|
34
36
|
const lastBlockingEnd = blockingResources.length
|
|
35
|
-
? Math.max(...blockingResources.map((r) => r.responseEnd))
|
|
37
|
+
? Math.max(...blockingResources.map((r) => r.responseEnd)) - activationStart
|
|
36
38
|
: ttfb;
|
|
37
39
|
|
|
38
40
|
console.group(
|
|
@@ -87,8 +89,25 @@
|
|
|
87
89
|
// Synchronous return for agent
|
|
88
90
|
const fcpEntrySync = performance.getEntriesByName("first-contentful-paint")[0];
|
|
89
91
|
if (!fcpEntrySync) return { script: "FCP", status: "error", error: "No FCP entry yet" };
|
|
90
|
-
const
|
|
92
|
+
const navEntrySync = performance.getEntriesByType("navigation")[0];
|
|
93
|
+
const activationStartSync = navEntrySync?.activationStart || 0;
|
|
94
|
+
const fcpTimeSync = Math.max(0, fcpEntrySync.startTime - activationStartSync);
|
|
91
95
|
const ratingSync = valueToRating(fcpTimeSync);
|
|
96
|
+
|
|
97
|
+
// Phases, measured from activation like the value: TTFB, then the wait for the last
|
|
98
|
+
// render-blocking resource, then the rest until the paint
|
|
99
|
+
const ttfbSync = Math.max(0, (navEntrySync?.responseStart ?? 0) - activationStartSync);
|
|
100
|
+
const blockingSync = performance
|
|
101
|
+
.getEntriesByType("resource")
|
|
102
|
+
.filter((r) => r.renderBlockingStatus === "blocking");
|
|
103
|
+
const lastBlockingEndSync = Math.max(
|
|
104
|
+
ttfbSync,
|
|
105
|
+
...blockingSync.map((r) => r.responseEnd - activationStartSync)
|
|
106
|
+
);
|
|
107
|
+
const ttfbMs = Math.round(ttfbSync);
|
|
108
|
+
const renderBlockingLoadMs = Math.round(lastBlockingEndSync - ttfbSync);
|
|
109
|
+
const renderDelayMs = Math.max(0, Math.round(fcpTimeSync) - ttfbMs - renderBlockingLoadMs);
|
|
110
|
+
|
|
92
111
|
return {
|
|
93
112
|
script: "FCP",
|
|
94
113
|
status: "ok",
|
|
@@ -97,5 +116,20 @@
|
|
|
97
116
|
unit: "ms",
|
|
98
117
|
rating: ratingSync,
|
|
99
118
|
thresholds: { good: 1800, needsImprovement: 3000 },
|
|
119
|
+
details: {
|
|
120
|
+
ttfbMs,
|
|
121
|
+
renderBlockingLoadMs,
|
|
122
|
+
renderDelayMs,
|
|
123
|
+
blockingResourceCount: blockingSync.length,
|
|
124
|
+
},
|
|
125
|
+
// Render-blocking resources, the last to finish first (at most 50)
|
|
126
|
+
items: [...blockingSync]
|
|
127
|
+
.sort((a, b) => b.responseEnd - a.responseEnd)
|
|
128
|
+
.slice(0, 50)
|
|
129
|
+
.map((r) => ({
|
|
130
|
+
url: r.name.split("/").pop().split("?")[0] || r.name,
|
|
131
|
+
type: r.initiatorType === "link" ? "CSS" : "JS",
|
|
132
|
+
durationMs: Math.round(r.duration),
|
|
133
|
+
})),
|
|
100
134
|
};
|
|
101
135
|
})();
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
selector: image.selector,
|
|
247
247
|
lazyType: image.lazyType,
|
|
248
248
|
dimensions: image.dimensions,
|
|
249
|
-
position: image.position,
|
|
249
|
+
position: `${image.position.top}px, ${image.position.left}px`,
|
|
250
250
|
distanceFromEdge: image.distanceFromEdge,
|
|
251
251
|
src: image.src,
|
|
252
252
|
srcset: image.srcset,
|
|
@@ -78,13 +78,15 @@
|
|
|
78
78
|
return 0;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// @shared formatBytes
|
|
81
82
|
function formatBytes(bytes) {
|
|
82
|
-
if (bytes ===
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
86
|
-
return
|
|
83
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
84
|
+
if (bytes === 0) return "0 B";
|
|
85
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
86
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
87
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
87
88
|
}
|
|
89
|
+
// @end-shared formatBytes
|
|
88
90
|
|
|
89
91
|
// Find LCP candidate (largest visible image) to exclude from recommendations
|
|
90
92
|
const allViewportImages = Array.from(document.querySelectorAll("img")).filter(
|
|
@@ -2,34 +2,55 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
6
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
7
|
+
const OWN_DOMAINS = [];
|
|
8
|
+
|
|
5
9
|
// Auto-detect first-party: same root domain (handles subdomains)
|
|
10
|
+
// @shared getRootDomain
|
|
6
11
|
function getRootDomain(hostname) {
|
|
7
|
-
const
|
|
8
|
-
//
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
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(".");
|
|
18
23
|
}
|
|
24
|
+
// @end-shared getRootDomain
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
// @shared isFirstParty
|
|
22
27
|
function isFirstParty(hostname) {
|
|
23
|
-
|
|
28
|
+
const root = getRootDomain(hostname);
|
|
29
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
30
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
31
|
+
}
|
|
32
|
+
// @end-shared isFirstParty
|
|
33
|
+
|
|
34
|
+
// @shared logOwnDomainsHint
|
|
35
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
36
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
37
|
+
console.log(
|
|
38
|
+
'%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.',
|
|
39
|
+
"color: #3b82f6;"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
24
42
|
}
|
|
43
|
+
// @end-shared logOwnDomainsHint
|
|
25
44
|
|
|
45
|
+
// @shared formatBytes
|
|
26
46
|
function formatBytes(bytes) {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
31
|
-
return (bytes / Math.pow(
|
|
47
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
48
|
+
if (bytes === 0) return "0 B";
|
|
49
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
50
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
51
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
32
52
|
}
|
|
53
|
+
// @end-shared formatBytes
|
|
33
54
|
|
|
34
55
|
// Gather script data
|
|
35
56
|
const scripts = performance
|
|
@@ -46,6 +67,8 @@
|
|
|
46
67
|
firstParty,
|
|
47
68
|
duration: r.duration,
|
|
48
69
|
transferSize: r.transferSize || 0,
|
|
70
|
+
// Cross-origin scripts without Timing-Allow-Origin report every size as 0
|
|
71
|
+
sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
|
|
49
72
|
startTime: r.startTime,
|
|
50
73
|
responseEnd: r.responseEnd,
|
|
51
74
|
renderBlocking: r.renderBlockingStatus === "blocking",
|
|
@@ -216,12 +239,22 @@
|
|
|
216
239
|
console.groupEnd();
|
|
217
240
|
}
|
|
218
241
|
|
|
242
|
+
logOwnDomainsHint(thirdParty.length);
|
|
219
243
|
console.groupEnd();
|
|
220
244
|
|
|
245
|
+
// The list keeps the 50 scripts to look at first (render-blocking, then third-party, then the largest); count and details cover all of them
|
|
246
|
+
const MAX_ITEMS = 50;
|
|
247
|
+
const rankedScripts = [...scripts].sort(
|
|
248
|
+
(a, b) =>
|
|
249
|
+
Number(b.renderBlocking) - Number(a.renderBlocking) ||
|
|
250
|
+
Number(a.firstParty) - Number(b.firstParty) ||
|
|
251
|
+
b.transferSize - a.transferSize
|
|
252
|
+
);
|
|
221
253
|
return {
|
|
222
254
|
script: "First-And-Third-Party-Script-Info",
|
|
223
255
|
status: "ok",
|
|
224
256
|
count: totalScripts,
|
|
257
|
+
corsLimitedAnalysis: scripts.some((s) => !s.sizeKnown),
|
|
225
258
|
details: {
|
|
226
259
|
firstPartyCount: firstParty.length,
|
|
227
260
|
thirdPartyCount: thirdParty.length,
|
|
@@ -230,16 +263,21 @@
|
|
|
230
263
|
thirdPartySizeBytes: thirdMetrics.totalSize,
|
|
231
264
|
thirdPartyBlockingCount: thirdMetrics.blocking,
|
|
232
265
|
thirdPartyHostCount: thirdMetrics.hosts.length,
|
|
266
|
+
sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
|
|
233
267
|
},
|
|
234
|
-
items:
|
|
268
|
+
items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
|
|
235
269
|
shortName: s.shortName,
|
|
236
270
|
host: s.host,
|
|
237
271
|
firstParty: s.firstParty,
|
|
238
272
|
transferBytes: s.transferSize,
|
|
273
|
+
sizeKnown: s.sizeKnown,
|
|
239
274
|
durationMs: Math.round(s.duration),
|
|
240
275
|
renderBlocking: s.renderBlocking,
|
|
241
276
|
})),
|
|
242
277
|
issues: [
|
|
278
|
+
...(scripts.some((s) => !s.sizeKnown)
|
|
279
|
+
? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
|
|
280
|
+
: []),
|
|
243
281
|
...(thirdMetrics.blocking > 0
|
|
244
282
|
? [
|
|
245
283
|
{
|
|
@@ -2,24 +2,45 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
6
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
7
|
+
const OWN_DOMAINS = [];
|
|
8
|
+
|
|
5
9
|
// Auto-detect first-party by root domain
|
|
10
|
+
// @shared getRootDomain
|
|
6
11
|
function getRootDomain(hostname) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(".");
|
|
16
23
|
}
|
|
24
|
+
// @end-shared getRootDomain
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
// @shared isFirstParty
|
|
20
27
|
function isFirstParty(hostname) {
|
|
21
|
-
|
|
28
|
+
const root = getRootDomain(hostname);
|
|
29
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
30
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
22
31
|
}
|
|
32
|
+
// @end-shared isFirstParty
|
|
33
|
+
|
|
34
|
+
// @shared logOwnDomainsHint
|
|
35
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
36
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
37
|
+
console.log(
|
|
38
|
+
'%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.',
|
|
39
|
+
"color: #3b82f6;"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// @end-shared logOwnDomainsHint
|
|
23
44
|
|
|
24
45
|
// Gather script timing data
|
|
25
46
|
const scripts = performance
|
|
@@ -47,6 +68,12 @@
|
|
|
47
68
|
timings.tcp = r.secureConnectionStart - r.connectStart;
|
|
48
69
|
}
|
|
49
70
|
|
|
71
|
+
// Without Timing-Allow-Origin the browser reports the phases as 0, so response would be
|
|
72
|
+
// the whole duration since the epoch. Only the total is real, the phases are unknown.
|
|
73
|
+
if (!hasTiming) {
|
|
74
|
+
for (const phase of ["dns", "tcp", "tls", "request", "response"]) timings[phase] = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
return {
|
|
51
78
|
name: r.name,
|
|
52
79
|
shortName: url.pathname.split("/").pop() || url.hostname,
|
|
@@ -92,6 +119,7 @@
|
|
|
92
119
|
const thirdStats = calcStats(thirdParty);
|
|
93
120
|
|
|
94
121
|
// Format helpers
|
|
122
|
+
const roundOrNull = (ms) => (ms === null ? null : Math.round(ms));
|
|
95
123
|
const formatMs = (ms) => (ms > 0 ? ms.toFixed(1) + "ms" : "-");
|
|
96
124
|
const formatBar = (value, max) => {
|
|
97
125
|
if (value <= 0 || max <= 0) return "";
|
|
@@ -270,12 +298,17 @@
|
|
|
270
298
|
console.groupEnd();
|
|
271
299
|
}
|
|
272
300
|
|
|
301
|
+
logOwnDomainsHint(thirdParty.length);
|
|
273
302
|
console.groupEnd();
|
|
274
303
|
|
|
304
|
+
// The list keeps the 50 slowest scripts; count and details cover all of them
|
|
305
|
+
const MAX_ITEMS = 50;
|
|
306
|
+
const slowestScripts = [...scripts].sort((a, b) => b.total - a.total);
|
|
275
307
|
return {
|
|
276
308
|
script: "First-And-Third-Party-Script-Timings",
|
|
277
309
|
status: "ok",
|
|
278
310
|
count: scripts.length,
|
|
311
|
+
corsLimitedAnalysis: thirdStats.withoutTiming > 0,
|
|
279
312
|
details: {
|
|
280
313
|
firstPartyCount: firstParty.length,
|
|
281
314
|
thirdPartyCount: thirdParty.length,
|
|
@@ -284,7 +317,7 @@
|
|
|
284
317
|
firstPartyAvgTotalMs: Math.round(firstStats.stats.total?.avg || 0),
|
|
285
318
|
thirdPartyAvgTotalMs: Math.round(thirdStats.stats.total?.avg || 0),
|
|
286
319
|
},
|
|
287
|
-
items:
|
|
320
|
+
items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: roundOrNull(s.dns), tcpMs: roundOrNull(s.tcp), requestMs: roundOrNull(s.request), responseMs: roundOrNull(s.response), hasTiming: s.hasTiming })),
|
|
288
321
|
issues: [
|
|
289
322
|
...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
|
|
290
323
|
...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
6
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
7
|
+
const OWN_DOMAINS = [];
|
|
8
|
+
|
|
5
9
|
// Helper to extract font filename from URL
|
|
6
10
|
function getFontName(url) {
|
|
7
11
|
try {
|
|
@@ -12,10 +16,45 @@
|
|
|
12
16
|
}
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
//
|
|
19
|
+
// @shared getRootDomain
|
|
20
|
+
function getRootDomain(hostname) {
|
|
21
|
+
const host = hostname.replace(/\.$/, "");
|
|
22
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
23
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
24
|
+
const parts = host.split(".");
|
|
25
|
+
if (parts.length <= 2) return host;
|
|
26
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
27
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
28
|
+
const tld = parts[parts.length - 1];
|
|
29
|
+
const sld = parts[parts.length - 2];
|
|
30
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
31
|
+
return parts.slice(-2).join(".");
|
|
32
|
+
}
|
|
33
|
+
// @end-shared getRootDomain
|
|
34
|
+
|
|
35
|
+
// @shared isFirstParty
|
|
36
|
+
function isFirstParty(hostname) {
|
|
37
|
+
const root = getRootDomain(hostname);
|
|
38
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
39
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
40
|
+
}
|
|
41
|
+
// @end-shared isFirstParty
|
|
42
|
+
|
|
43
|
+
// @shared logOwnDomainsHint
|
|
44
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
45
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
46
|
+
console.log(
|
|
47
|
+
'%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.',
|
|
48
|
+
"color: #3b82f6;"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// @end-shared logOwnDomainsHint
|
|
53
|
+
|
|
54
|
+
// Check if URL is third-party (a different root domain)
|
|
16
55
|
function isThirdParty(url) {
|
|
17
56
|
try {
|
|
18
|
-
return new URL(url).hostname
|
|
57
|
+
return !isFirstParty(new URL(url).hostname);
|
|
19
58
|
} catch {
|
|
20
59
|
return false;
|
|
21
60
|
}
|
|
@@ -291,6 +330,7 @@
|
|
|
291
330
|
console.log("4. Self-host fonts when possible for better control");
|
|
292
331
|
console.log("5. Subset fonts to include only needed characters");
|
|
293
332
|
console.log("6. Use WOFF2 format for best compression");
|
|
333
|
+
logOwnDomainsHint(preloadedFonts.filter((f) => f.thirdParty).length);
|
|
294
334
|
console.groupEnd();
|
|
295
335
|
|
|
296
336
|
console.groupEnd();
|
|
@@ -305,9 +345,18 @@
|
|
|
305
345
|
usedAboveFoldCount: usedFonts.length,
|
|
306
346
|
preloadedNotUsedCount: preloadedNotUsed.length,
|
|
307
347
|
usedNotPreloadedCount: usedNotPreloaded.length,
|
|
348
|
+
// The preload links themselves (at most 20)
|
|
349
|
+
preloadedFonts: preloadedFonts.slice(0, 20).map(f => ({
|
|
350
|
+
family: f.name.replace(/\.(woff2?|ttf|otf|eot)$/i, ""),
|
|
351
|
+
href: f.href,
|
|
352
|
+
fontType: f.type,
|
|
353
|
+
crossorigin: f.crossorigin || "",
|
|
354
|
+
thirdParty: f.thirdParty,
|
|
355
|
+
})),
|
|
308
356
|
},
|
|
309
|
-
|
|
310
|
-
|
|
357
|
+
// The Visualizer reads items (loaded fonts) and usedFonts
|
|
358
|
+
items: uniqueLoadedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
|
|
359
|
+
usedFonts: usedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
|
|
311
360
|
issues: [
|
|
312
361
|
...preloadedNotUsed.map(f => ({ severity: "warning", message: `Preloaded but not used above fold: ${f.name}` })),
|
|
313
362
|
...usedNotPreloaded.map(f => ({ severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})` })),
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
5
|
+
// @shared formatBytes
|
|
6
|
+
function formatBytes(bytes) {
|
|
7
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
6
8
|
if (bytes === 0) return "0 B";
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
10
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
11
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
12
|
+
}
|
|
13
|
+
// @end-shared formatBytes
|
|
12
14
|
|
|
13
15
|
const formatKB = (bytes) => (bytes / 1024).toFixed(2);
|
|
14
16
|
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
5
|
+
// @shared formatBytes
|
|
6
|
+
function formatBytes(bytes) {
|
|
7
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
6
8
|
if (bytes === 0) return "0 B";
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
10
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
11
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
12
|
+
}
|
|
13
|
+
// @end-shared formatBytes
|
|
12
14
|
|
|
13
15
|
const formatKB = (bytes) => (bytes / 1024).toFixed(2);
|
|
14
16
|
|
|
@@ -305,6 +307,11 @@
|
|
|
305
307
|
|
|
306
308
|
console.groupEnd();
|
|
307
309
|
|
|
310
|
+
// The list keeps the 50 largest executable scripts, parser-blocking ones first; count and details cover all of them
|
|
311
|
+
const MAX_ITEMS = 50;
|
|
312
|
+
const rankedScripts = [...executableScripts].sort(
|
|
313
|
+
(a, b) => Number(b.isParserBlocking) - Number(a.isParserBlocking) || b.size - a.size
|
|
314
|
+
);
|
|
308
315
|
return {
|
|
309
316
|
script: "Inline-Script-Info-and-Size",
|
|
310
317
|
status: "ok",
|
|
@@ -317,7 +324,7 @@
|
|
|
317
324
|
parserBlockingCount: parserBlockingScripts.length,
|
|
318
325
|
parserBlockingInHeadCount: parserBlockingInHead.length,
|
|
319
326
|
},
|
|
320
|
-
items:
|
|
327
|
+
items: rankedScripts.slice(0, MAX_ITEMS).map(s => ({ index: s.index, sizeBytes: s.size, category: s.category, inHead: s.inHead, isParserBlocking: s.isParserBlocking, isAnalytics: s.isAnalytics, isConfig: s.isConfig })),
|
|
321
328
|
issues: issues.map(i => ({ severity: i.type === "blocking" ? "error" : "warning", message: i.message })),
|
|
322
329
|
};
|
|
323
330
|
})();
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
5
|
const formatMs = (ms) => (ms > 0 ? ms.toFixed(0) + "ms" : "-");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
// @shared formatBytes
|
|
7
|
+
function formatBytes(bytes) {
|
|
8
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
9
|
+
if (bytes === 0) return "0 B";
|
|
10
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
11
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
12
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
13
|
+
}
|
|
14
|
+
// @end-shared formatBytes
|
|
13
15
|
const formatBar = (value, max, width = 16) => {
|
|
14
16
|
if (value <= 0 || max <= 0) return "░".repeat(width);
|
|
15
17
|
const filled = Math.min(Math.round((value / max) * width), width);
|
|
@@ -100,6 +102,10 @@
|
|
|
100
102
|
// Large script threshold (50KB compressed = good candidate for splitting)
|
|
101
103
|
const SPLIT_THRESHOLD = 50 * 1024;
|
|
102
104
|
const splitCandidates = scripts.filter((s) => s.decodedSize > SPLIT_THRESHOLD);
|
|
105
|
+
// Bundles over 1 MB decoded; declared here because the console output and the return use it
|
|
106
|
+
const criticalBundles = scripts
|
|
107
|
+
.filter((s) => !s.corsRestricted && s.decodedSize > 1024 * 1024)
|
|
108
|
+
.sort((a, b) => b.decodedSize - a.decodedSize);
|
|
103
109
|
|
|
104
110
|
// 5. Output
|
|
105
111
|
console.group(
|
|
@@ -195,7 +201,6 @@
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
// Critical bundle warning (> 1 MB decoded)
|
|
198
|
-
const criticalBundles = scripts.filter((s) => !s.corsRestricted && s.decodedSize > 1024 * 1024);
|
|
199
204
|
if (criticalBundles.length > 0) {
|
|
200
205
|
console.log("");
|
|
201
206
|
console.log("%c🚨 Critically large bundles (> 1 MB decoded):", "color: #ef4444; font-weight: bold;");
|
|
@@ -313,6 +318,7 @@
|
|
|
313
318
|
script: "JS-Execution-Time-Breakdown",
|
|
314
319
|
status: "ok",
|
|
315
320
|
count: scripts.length,
|
|
321
|
+
corsLimitedAnalysis: scripts.some((s) => s.corsRestricted),
|
|
316
322
|
details: {
|
|
317
323
|
blockingCount: blocking.length,
|
|
318
324
|
nonBlockingCount: nonBlocking.length,
|
|
@@ -324,13 +330,20 @@
|
|
|
324
330
|
domInteractiveMs: Math.round(domInteractive),
|
|
325
331
|
domContentLoadedMs: Math.round(domContentLoaded),
|
|
326
332
|
loadEventMs: Math.round(loadEvent),
|
|
333
|
+
criticalBundles: criticalBundles.slice(0, 20).map((s) => ({
|
|
334
|
+
shortName: s.shortName,
|
|
335
|
+
decodedBytes: s.decodedSize,
|
|
336
|
+
transferBytes: s.transferSize,
|
|
337
|
+
estimatedParseMobileMs: s.estimatedParseMobile,
|
|
338
|
+
estimatedParseDesktopMs: s.estimatedParseDesktop,
|
|
339
|
+
})),
|
|
327
340
|
},
|
|
328
341
|
items: scripts.slice(0, 50).map(s => ({ shortName: s.shortName, isBlocking: s.isBlocking, downloadMs: Math.round(s.downloadDuration), estimatedParseMobileMs: s.estimatedParseMobile, transferBytes: s.transferSize, decodedBytes: s.decodedSize, corsRestricted: s.corsRestricted })),
|
|
329
342
|
issues: [
|
|
330
343
|
...(blocking.length > 0 ? [{ severity: "error", message: `${blocking.length} render-blocking script(s) delay HTML parsing` }] : []),
|
|
331
344
|
...(hasHighParseTime ? [{ severity: "warning", message: `High estimated parse cost (~${Math.round(totalEstParse)}ms on mobile)` }] : []),
|
|
332
|
-
...(splitCandidates.length > 0 ? [{ severity: "warning", message: `${splitCandidates.length} script(s) over 50KB decoded
|
|
333
|
-
...(poorCompression ? [{ severity: "warning", message: `Low compression ratio (${compressionRatio.toFixed(1)}x)
|
|
345
|
+
...(splitCandidates.length > 0 ? [{ severity: "warning", message: `${splitCandidates.length} script(s) over 50KB decoded. Consider code splitting.` }] : []),
|
|
346
|
+
...(poorCompression ? [{ severity: "warning", message: `Low compression ratio (${compressionRatio.toFixed(1)}x). Enable Brotli or gzip.` }] : []),
|
|
334
347
|
],
|
|
335
348
|
};
|
|
336
349
|
})();
|
|
@@ -331,6 +331,9 @@
|
|
|
331
331
|
fetchpriority: el.getAttribute("fetchpriority"),
|
|
332
332
|
resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "",
|
|
333
333
|
})),
|
|
334
|
-
issues: issues.map(i => ({
|
|
334
|
+
issues: issues.map(i => ({
|
|
335
|
+
severity: i.severity,
|
|
336
|
+
message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message,
|
|
337
|
+
})),
|
|
335
338
|
};
|
|
336
339
|
})();
|