webperf-snippets 0.1.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 +185 -20
- 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/Find-render-blocking-resources.js +11 -13
- 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 +86 -57
- package/src/crux.js +84 -0
- package/src/decision-tree.js +11 -1
- package/src/interactions.js +30 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +103 -0
- package/src/runner.js +81 -17
- package/src/workflows/loading.js +14 -0
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
})
|
|
32
32
|
.sort((a, b) => b.responseEnd - a.responseEnd);
|
|
33
33
|
|
|
34
|
+
const lastBlockingEnd = blockingResources.length
|
|
35
|
+
? Math.max(...blockingResources.map((r) => r.responseEnd))
|
|
36
|
+
: 0;
|
|
37
|
+
const totalSizeBytes = blockingResources.reduce((sum, r) => sum + r.size, 0);
|
|
38
|
+
const byType = blockingResources.reduce((acc, r) => {
|
|
39
|
+
acc[r.type] = (acc[r.type] || 0) + 1;
|
|
40
|
+
return acc;
|
|
41
|
+
}, {});
|
|
42
|
+
|
|
34
43
|
console.group("%c🚧 Render-Blocking Resources", "font-weight: bold; font-size: 14px;");
|
|
35
44
|
|
|
36
45
|
if (blockingResources.length === 0) {
|
|
@@ -45,14 +54,6 @@
|
|
|
45
54
|
console.log(" • Scripts use async or defer attributes");
|
|
46
55
|
console.log(" • Critical resources are optimized");
|
|
47
56
|
} else {
|
|
48
|
-
// Calculate metrics
|
|
49
|
-
const lastBlockingEnd = Math.max(...blockingResources.map((r) => r.responseEnd));
|
|
50
|
-
const totalSize = blockingResources.reduce((sum, r) => sum + r.size, 0);
|
|
51
|
-
const byType = blockingResources.reduce((acc, r) => {
|
|
52
|
-
acc[r.type] = (acc[r.type] || 0) + 1;
|
|
53
|
-
return acc;
|
|
54
|
-
}, {});
|
|
55
|
-
|
|
56
57
|
// Summary
|
|
57
58
|
console.log(
|
|
58
59
|
`%c⚠️ Found ${blockingResources.length} render-blocking resource(s)`,
|
|
@@ -63,8 +64,8 @@
|
|
|
63
64
|
console.log("%c📊 Impact Summary:", "font-weight: bold;");
|
|
64
65
|
console.log(` Rendering blocked until: ${lastBlockingEnd.toFixed(0)}ms`);
|
|
65
66
|
console.log(` Total blocking resources: ${blockingResources.length}`);
|
|
66
|
-
if (
|
|
67
|
-
const sizeKB = (
|
|
67
|
+
if (totalSizeBytes > 0) {
|
|
68
|
+
const sizeKB = (totalSizeBytes / 1024).toFixed(1);
|
|
68
69
|
console.log(` Total size: ${sizeKB} KB`);
|
|
69
70
|
}
|
|
70
71
|
console.log(` By type: ${Object.entries(byType).map(([k, v]) => `${k} (${v})`).join(", ")}`);
|
|
@@ -134,9 +135,6 @@
|
|
|
134
135
|
|
|
135
136
|
console.groupEnd();
|
|
136
137
|
|
|
137
|
-
const lastBlockingEnd = blockingResources.length ? Math.max(...blockingResources.map((r) => r.responseEnd)) : 0;
|
|
138
|
-
const totalSizeBytes = blockingResources.reduce((sum, r) => sum + r.size, 0);
|
|
139
|
-
const byType = blockingResources.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
|
|
140
138
|
return {
|
|
141
139
|
script: "Find-render-blocking-resources",
|
|
142
140
|
status: "ok",
|
|
@@ -3,33 +3,37 @@
|
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
5
|
// Auto-detect first-party: same root domain (handles subdomains)
|
|
6
|
+
// @shared getRootDomain
|
|
6
7
|
function getRootDomain(hostname) {
|
|
7
|
-
const
|
|
8
|
-
//
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
8
|
+
const host = hostname.replace(/\.$/, "");
|
|
9
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
10
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
11
|
+
const parts = host.split(".");
|
|
12
|
+
if (parts.length <= 2) return host;
|
|
13
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
14
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
15
|
+
const tld = parts[parts.length - 1];
|
|
16
|
+
const sld = parts[parts.length - 2];
|
|
17
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
18
|
+
return parts.slice(-2).join(".");
|
|
18
19
|
}
|
|
20
|
+
// @end-shared getRootDomain
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// @shared isFirstParty
|
|
22
23
|
function isFirstParty(hostname) {
|
|
23
|
-
return getRootDomain(hostname) ===
|
|
24
|
+
return getRootDomain(hostname) === getRootDomain(location.hostname);
|
|
24
25
|
}
|
|
26
|
+
// @end-shared isFirstParty
|
|
25
27
|
|
|
28
|
+
// @shared formatBytes
|
|
26
29
|
function formatBytes(bytes) {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
31
|
-
return (bytes / Math.pow(
|
|
30
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
31
|
+
if (bytes === 0) return "0 B";
|
|
32
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
33
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
34
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
32
35
|
}
|
|
36
|
+
// @end-shared formatBytes
|
|
33
37
|
|
|
34
38
|
// Gather script data
|
|
35
39
|
const scripts = performance
|
|
@@ -46,6 +50,8 @@
|
|
|
46
50
|
firstParty,
|
|
47
51
|
duration: r.duration,
|
|
48
52
|
transferSize: r.transferSize || 0,
|
|
53
|
+
// Cross-origin scripts without Timing-Allow-Origin report every size as 0
|
|
54
|
+
sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
|
|
49
55
|
startTime: r.startTime,
|
|
50
56
|
responseEnd: r.responseEnd,
|
|
51
57
|
renderBlocking: r.renderBlockingStatus === "blocking",
|
|
@@ -218,10 +224,19 @@
|
|
|
218
224
|
|
|
219
225
|
console.groupEnd();
|
|
220
226
|
|
|
227
|
+
// 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
|
|
228
|
+
const MAX_ITEMS = 50;
|
|
229
|
+
const rankedScripts = [...scripts].sort(
|
|
230
|
+
(a, b) =>
|
|
231
|
+
Number(b.renderBlocking) - Number(a.renderBlocking) ||
|
|
232
|
+
Number(a.firstParty) - Number(b.firstParty) ||
|
|
233
|
+
b.transferSize - a.transferSize
|
|
234
|
+
);
|
|
221
235
|
return {
|
|
222
236
|
script: "First-And-Third-Party-Script-Info",
|
|
223
237
|
status: "ok",
|
|
224
238
|
count: totalScripts,
|
|
239
|
+
corsLimitedAnalysis: scripts.some((s) => !s.sizeKnown),
|
|
225
240
|
details: {
|
|
226
241
|
firstPartyCount: firstParty.length,
|
|
227
242
|
thirdPartyCount: thirdParty.length,
|
|
@@ -230,16 +245,21 @@
|
|
|
230
245
|
thirdPartySizeBytes: thirdMetrics.totalSize,
|
|
231
246
|
thirdPartyBlockingCount: thirdMetrics.blocking,
|
|
232
247
|
thirdPartyHostCount: thirdMetrics.hosts.length,
|
|
248
|
+
sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
|
|
233
249
|
},
|
|
234
|
-
items:
|
|
250
|
+
items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
|
|
235
251
|
shortName: s.shortName,
|
|
236
252
|
host: s.host,
|
|
237
253
|
firstParty: s.firstParty,
|
|
238
254
|
transferBytes: s.transferSize,
|
|
255
|
+
sizeKnown: s.sizeKnown,
|
|
239
256
|
durationMs: Math.round(s.duration),
|
|
240
257
|
renderBlocking: s.renderBlocking,
|
|
241
258
|
})),
|
|
242
259
|
issues: [
|
|
260
|
+
...(scripts.some((s) => !s.sizeKnown)
|
|
261
|
+
? [{ 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` }]
|
|
262
|
+
: []),
|
|
243
263
|
...(thirdMetrics.blocking > 0
|
|
244
264
|
? [
|
|
245
265
|
{
|
|
@@ -3,23 +3,27 @@
|
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
5
|
// Auto-detect first-party by root domain
|
|
6
|
+
// @shared getRootDomain
|
|
6
7
|
function getRootDomain(hostname) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
const host = hostname.replace(/\.$/, "");
|
|
9
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
10
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
11
|
+
const parts = host.split(".");
|
|
12
|
+
if (parts.length <= 2) return host;
|
|
13
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
14
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
15
|
+
const tld = parts[parts.length - 1];
|
|
16
|
+
const sld = parts[parts.length - 2];
|
|
17
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
18
|
+
return parts.slice(-2).join(".");
|
|
16
19
|
}
|
|
20
|
+
// @end-shared getRootDomain
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
// @shared isFirstParty
|
|
20
23
|
function isFirstParty(hostname) {
|
|
21
|
-
return getRootDomain(hostname) ===
|
|
24
|
+
return getRootDomain(hostname) === getRootDomain(location.hostname);
|
|
22
25
|
}
|
|
26
|
+
// @end-shared isFirstParty
|
|
23
27
|
|
|
24
28
|
// Gather script timing data
|
|
25
29
|
const scripts = performance
|
|
@@ -272,10 +276,14 @@
|
|
|
272
276
|
|
|
273
277
|
console.groupEnd();
|
|
274
278
|
|
|
279
|
+
// The list keeps the 50 slowest scripts; count and details cover all of them
|
|
280
|
+
const MAX_ITEMS = 50;
|
|
281
|
+
const slowestScripts = [...scripts].sort((a, b) => b.total - a.total);
|
|
275
282
|
return {
|
|
276
283
|
script: "First-And-Third-Party-Script-Timings",
|
|
277
284
|
status: "ok",
|
|
278
285
|
count: scripts.length,
|
|
286
|
+
corsLimitedAnalysis: thirdStats.withoutTiming > 0,
|
|
279
287
|
details: {
|
|
280
288
|
firstPartyCount: firstParty.length,
|
|
281
289
|
thirdPartyCount: thirdParty.length,
|
|
@@ -284,7 +292,7 @@
|
|
|
284
292
|
firstPartyAvgTotalMs: Math.round(firstStats.stats.total?.avg || 0),
|
|
285
293
|
thirdPartyAvgTotalMs: Math.round(thirdStats.stats.total?.avg || 0),
|
|
286
294
|
},
|
|
287
|
-
items:
|
|
295
|
+
items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: Math.round(s.dns), tcpMs: Math.round(s.tcp), requestMs: Math.round(s.request), responseMs: Math.round(s.response), hasTiming: s.hasTiming })),
|
|
288
296
|
issues: [
|
|
289
297
|
...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
|
|
290
298
|
...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
|
|
@@ -12,10 +12,32 @@
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// @shared getRootDomain
|
|
16
|
+
function getRootDomain(hostname) {
|
|
17
|
+
const host = hostname.replace(/\.$/, "");
|
|
18
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
19
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
20
|
+
const parts = host.split(".");
|
|
21
|
+
if (parts.length <= 2) return host;
|
|
22
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
23
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
24
|
+
const tld = parts[parts.length - 1];
|
|
25
|
+
const sld = parts[parts.length - 2];
|
|
26
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
27
|
+
return parts.slice(-2).join(".");
|
|
28
|
+
}
|
|
29
|
+
// @end-shared getRootDomain
|
|
30
|
+
|
|
31
|
+
// @shared isFirstParty
|
|
32
|
+
function isFirstParty(hostname) {
|
|
33
|
+
return getRootDomain(hostname) === getRootDomain(location.hostname);
|
|
34
|
+
}
|
|
35
|
+
// @end-shared isFirstParty
|
|
36
|
+
|
|
37
|
+
// Check if URL is third-party (a different root domain)
|
|
16
38
|
function isThirdParty(url) {
|
|
17
39
|
try {
|
|
18
|
-
return new URL(url).hostname
|
|
40
|
+
return !isFirstParty(new URL(url).hostname);
|
|
19
41
|
} catch {
|
|
20
42
|
return false;
|
|
21
43
|
}
|
|
@@ -305,9 +327,18 @@
|
|
|
305
327
|
usedAboveFoldCount: usedFonts.length,
|
|
306
328
|
preloadedNotUsedCount: preloadedNotUsed.length,
|
|
307
329
|
usedNotPreloadedCount: usedNotPreloaded.length,
|
|
330
|
+
// The preload links themselves (at most 20)
|
|
331
|
+
preloadedFonts: preloadedFonts.slice(0, 20).map(f => ({
|
|
332
|
+
family: f.name.replace(/\.(woff2?|ttf|otf|eot)$/i, ""),
|
|
333
|
+
href: f.href,
|
|
334
|
+
fontType: f.type,
|
|
335
|
+
crossorigin: f.crossorigin || "",
|
|
336
|
+
thirdParty: f.thirdParty,
|
|
337
|
+
})),
|
|
308
338
|
},
|
|
309
|
-
|
|
310
|
-
|
|
339
|
+
// The Visualizer reads items (loaded fonts) and usedFonts
|
|
340
|
+
items: uniqueLoadedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
|
|
341
|
+
usedFonts: usedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
|
|
311
342
|
issues: [
|
|
312
343
|
...preloadedNotUsed.map(f => ({ severity: "warning", message: `Preloaded but not used above fold: ${f.name}` })),
|
|
313
344
|
...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
|
})();
|
|
@@ -494,6 +494,21 @@
|
|
|
494
494
|
missingPreconnects: missingHints.length,
|
|
495
495
|
redundantHints: redundantDomains.length,
|
|
496
496
|
},
|
|
497
|
+
// Origins that need a hint, and those with a redundant dns-prefetch (at most 50)
|
|
498
|
+
items: [
|
|
499
|
+
...missingHints.map(([domain, count]) => ({
|
|
500
|
+
domain,
|
|
501
|
+
requestCount: count,
|
|
502
|
+
action: count >= 5 ? "add-preconnect" : "add-dns-prefetch",
|
|
503
|
+
recommendedHint: count >= 5 ? "preconnect" : "dns-prefetch",
|
|
504
|
+
})),
|
|
505
|
+
...redundantDomains.map((domain) => ({
|
|
506
|
+
domain,
|
|
507
|
+
requestCount: domainRequestCounts[domain] || 0,
|
|
508
|
+
action: "remove-dns-prefetch",
|
|
509
|
+
recommendedHint: "preconnect",
|
|
510
|
+
})),
|
|
511
|
+
].slice(0, 50),
|
|
497
512
|
issues: [
|
|
498
513
|
...(totalIssues > 0 ? [{ severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []),
|
|
499
514
|
...(missingHints.length > 0 ? [{ severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []),
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
// @shared formatBytes
|
|
6
|
+
function formatBytes(bytes) {
|
|
7
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
8
|
+
if (bytes === 0) return "0 B";
|
|
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
|
// Resource hint definitions
|
|
14
16
|
const hintTypes = [
|
|
@@ -171,6 +173,8 @@
|
|
|
171
173
|
|
|
172
174
|
// Get third-party origins that could benefit from preconnect
|
|
173
175
|
const thirdPartyOrigins = new Map();
|
|
176
|
+
// Third-party resources without Timing-Allow-Origin report every size as 0
|
|
177
|
+
let sizeUnknownCount = 0;
|
|
174
178
|
loadedResources.forEach((r) => {
|
|
175
179
|
try {
|
|
176
180
|
const url = new URL(r.name);
|
|
@@ -181,6 +185,7 @@
|
|
|
181
185
|
const data = thirdPartyOrigins.get(url.origin);
|
|
182
186
|
data.count++;
|
|
183
187
|
data.size += r.transferSize || 0;
|
|
188
|
+
if (r.transferSize === 0 && r.encodedBodySize === 0 && r.decodedBodySize === 0 && r.duration > 0) sizeUnknownCount++;
|
|
184
189
|
data.types.add(r.initiatorType);
|
|
185
190
|
}
|
|
186
191
|
} catch {}
|
|
@@ -386,15 +391,31 @@
|
|
|
386
391
|
|
|
387
392
|
console.groupEnd();
|
|
388
393
|
|
|
394
|
+
// The list keeps 50 hints: those with an error or a warning first, then in document order; count and details cover all of them
|
|
395
|
+
const MAX_ITEMS = 50;
|
|
396
|
+
const hintRank = (h) => (issues.some((i) => i.hint === h && i.severity === "error") ? 0 : issues.some((i) => i.hint === h) ? 1 : 2);
|
|
397
|
+
const rankedHints = allHints
|
|
398
|
+
.map((h, index) => ({ h, index }))
|
|
399
|
+
.sort((a, b) => hintRank(a.h) - hintRank(b.h) || a.index - b.index)
|
|
400
|
+
.map(({ h }) => h);
|
|
389
401
|
return {
|
|
390
402
|
script: "Resource-Hints",
|
|
391
403
|
status: "ok",
|
|
392
404
|
count: allHints.length,
|
|
405
|
+
corsLimitedAnalysis: sizeUnknownCount > 0,
|
|
393
406
|
details: {
|
|
407
|
+
sizeUnknownCount,
|
|
394
408
|
byType: Object.fromEntries(hintTypes.map(t => [t.rel, allHints.filter(h => h.rel === t.rel).length])),
|
|
395
409
|
missingPreconnectsCount: missingPreconnects.length,
|
|
410
|
+
missingPreconnects: missingPreconnects.slice(0, 20).map((o) => ({
|
|
411
|
+
origin: o.origin,
|
|
412
|
+
requestCount: o.count,
|
|
413
|
+
sizeBytes: o.size,
|
|
414
|
+
resourceTypes: Array.from(o.types),
|
|
415
|
+
recommendedHint: "preconnect",
|
|
416
|
+
})),
|
|
396
417
|
},
|
|
397
|
-
items:
|
|
418
|
+
items: rankedHints.slice(0, MAX_ITEMS).map(h => ({ rel: h.rel, href: h.shortHref, as: h.as, crossorigin: h.crossorigin, fetchpriority: h.fetchpriority })),
|
|
398
419
|
issues: issues.map(i => ({ severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` })),
|
|
399
420
|
};
|
|
400
421
|
})();
|
|
@@ -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
|
// Framework detection patterns
|
|
14
16
|
const frameworks = [
|
|
@@ -168,8 +170,18 @@
|
|
|
168
170
|
console.log(` Total hydration data: ${formatBytes(totalHydrationSize)}`);
|
|
169
171
|
console.log(` Other inline scripts: ${formatBytes(otherSize)}`);
|
|
170
172
|
|
|
173
|
+
// Structured findings for the return value
|
|
174
|
+
const itemsData = [];
|
|
175
|
+
const issuesData = [];
|
|
176
|
+
|
|
171
177
|
// Detailed analysis for each framework
|
|
172
178
|
detected.forEach((fw) => {
|
|
179
|
+
if (fw.exceedsThreshold) {
|
|
180
|
+
issuesData.push({
|
|
181
|
+
severity: "warning",
|
|
182
|
+
message: `${fw.name} hydration data (${Math.round(fw.size / 1024)} KB) exceeds ${Math.round(fw.threshold / 1024)} KB threshold`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
173
185
|
console.log("");
|
|
174
186
|
console.group(
|
|
175
187
|
`%c${fw.exceedsThreshold ? "🔴" : "🟢"} ${fw.name} Analysis`,
|
|
@@ -256,6 +268,46 @@
|
|
|
256
268
|
);
|
|
257
269
|
}
|
|
258
270
|
|
|
271
|
+
// Key paths that look sensitive. Only keys are checked: a value that contains
|
|
272
|
+
// the word "token" is not a sign of leaked data.
|
|
273
|
+
const sensitiveKeys = [];
|
|
274
|
+
const findSensitiveKeys = (obj, path, depth) => {
|
|
275
|
+
if (typeof obj !== "object" || obj === null || depth > 4) return;
|
|
276
|
+
for (const key of Object.keys(obj)) {
|
|
277
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
278
|
+
if (sensitivePatterns.test(key)) sensitiveKeys.push(fullPath);
|
|
279
|
+
findSensitiveKeys(obj[key], fullPath, depth + 1);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
findSensitiveKeys(pageProps, "", 0);
|
|
283
|
+
|
|
284
|
+
propsKeys.forEach((key) => {
|
|
285
|
+
const flags = [];
|
|
286
|
+
if (largeArrays.includes(key)) flags.push("large-array");
|
|
287
|
+
if (checkDepth(pageProps[key])) flags.push("deeply-nested");
|
|
288
|
+
if (sensitiveKeys.some((k) => k === key || k.startsWith(`${key}.`))) flags.push("sensitive-key");
|
|
289
|
+
itemsData.push({
|
|
290
|
+
framework: "Next.js",
|
|
291
|
+
kind: "prop",
|
|
292
|
+
name: key,
|
|
293
|
+
sizeBytes: new Blob([JSON.stringify(pageProps[key])]).size,
|
|
294
|
+
flags,
|
|
295
|
+
detail: null,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
if (largeArrays.length > 0) {
|
|
299
|
+
issuesData.push({ severity: "warning", message: `pageProps has large arrays (${largeArrays.join(", ")}). Consider pagination.` });
|
|
300
|
+
}
|
|
301
|
+
if (checkDepth(pageProps)) {
|
|
302
|
+
issuesData.push({ severity: "warning", message: "pageProps contains deeply nested data (more than 5 levels)." });
|
|
303
|
+
}
|
|
304
|
+
if (sensitiveKeys.length > 0) {
|
|
305
|
+
issuesData.push({
|
|
306
|
+
severity: "warning",
|
|
307
|
+
message: `pageProps has keys that look sensitive (${sensitiveKeys.slice(0, 5).join(", ")}). Everything in pageProps is sent to every visitor.`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
259
311
|
if (
|
|
260
312
|
largeArrays.length === 0 &&
|
|
261
313
|
!checkDepth(pageProps) &&
|
|
@@ -293,6 +345,21 @@
|
|
|
293
345
|
};
|
|
294
346
|
});
|
|
295
347
|
console.table(islandTable);
|
|
348
|
+
|
|
349
|
+
fw.elements.forEach((island) => {
|
|
350
|
+
const props = island.getAttribute("props");
|
|
351
|
+
itemsData.push({
|
|
352
|
+
framework: "Astro",
|
|
353
|
+
kind: "island",
|
|
354
|
+
name: island.getAttribute("component-url")?.split("/").pop() || "Unknown",
|
|
355
|
+
sizeBytes: props ? new Blob([props]).size : 0,
|
|
356
|
+
flags: [],
|
|
357
|
+
detail:
|
|
358
|
+
island.getAttribute("client") ||
|
|
359
|
+
Array.from(island.attributes).find((a) => a.name.startsWith("client:"))?.name.replace("client:", "") ||
|
|
360
|
+
"unknown",
|
|
361
|
+
});
|
|
362
|
+
});
|
|
296
363
|
}
|
|
297
364
|
|
|
298
365
|
// Element reference
|
|
@@ -357,8 +424,10 @@
|
|
|
357
424
|
totalHydrationBytes: totalHydrationSize,
|
|
358
425
|
otherInlineBytes: otherSize,
|
|
359
426
|
hasExceedingThreshold: detected.some(d => d.exceedsThreshold),
|
|
427
|
+
frameworks: detected.map(fw => ({ name: fw.name, sizeBytes: fw.size, thresholdBytes: fw.threshold, exceedsThreshold: fw.exceedsThreshold })),
|
|
360
428
|
},
|
|
361
|
-
|
|
362
|
-
|
|
429
|
+
// Props and islands, the biggest first (at most 30)
|
|
430
|
+
items: itemsData.sort((a, b) => b.sizeBytes - a.sizeBytes).slice(0, 30),
|
|
431
|
+
issues: issuesData,
|
|
363
432
|
};
|
|
364
433
|
})();
|