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
|
@@ -54,13 +54,15 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// Helper to format bytes
|
|
57
|
+
// @shared formatBytes
|
|
57
58
|
function formatBytes(bytes) {
|
|
58
|
-
if (bytes ===
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
62
|
-
return
|
|
59
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
60
|
+
if (bytes === 0) return "0 B";
|
|
61
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
62
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
63
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
63
64
|
}
|
|
65
|
+
// @end-shared formatBytes
|
|
64
66
|
|
|
65
67
|
// Parse CSS text to find @media rules
|
|
66
68
|
function parseMediaQueriesFromCSS(cssText, source, isInline) {
|
|
@@ -254,8 +256,60 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
|
254
256
|
// CSS Performance Impact Analyzer
|
|
255
257
|
// Estimates the real-world performance cost of unnecessary CSS on mobile devices
|
|
256
258
|
|
|
259
|
+
// Device profiles based on real Chrome UX Report data. A function, not a top-level const, so
|
|
260
|
+
// the snippet can be run again in the same console.
|
|
261
|
+
function getDeviceProfiles() {
|
|
262
|
+
return {
|
|
263
|
+
"High-end (Pixel 7, iPhone 14)": {
|
|
264
|
+
cssParsingSpeed: 1.5, // MB/s
|
|
265
|
+
cssSelectorMatchingMultiplier: 1.0,
|
|
266
|
+
networkSpeed: 10, // Mbps (4G LTE)
|
|
267
|
+
description: "Top 25% devices",
|
|
268
|
+
},
|
|
269
|
+
"Mid-range (Moto G Power, iPhone SE)": {
|
|
270
|
+
cssParsingSpeed: 0.8, // MB/s
|
|
271
|
+
cssSelectorMatchingMultiplier: 1.8,
|
|
272
|
+
networkSpeed: 5, // Mbps (4G)
|
|
273
|
+
description: "Median mobile device",
|
|
274
|
+
},
|
|
275
|
+
"Low-end (Moto E, older devices)": {
|
|
276
|
+
cssParsingSpeed: 0.3, // MB/s
|
|
277
|
+
cssSelectorMatchingMultiplier: 3.5,
|
|
278
|
+
networkSpeed: 2, // Mbps (3G/slow 4G)
|
|
279
|
+
description: "Bottom 25% devices",
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Estimated cost of the desktop-only CSS on each device profile. No console output.
|
|
285
|
+
function estimateCSSImpact(summary) {
|
|
286
|
+
const deviceProfiles = getDeviceProfiles();
|
|
287
|
+
const unnecessaryBytes = summary.total.bytes;
|
|
288
|
+
const totalClasses = summary.total.classes;
|
|
289
|
+
const totalProperties = summary.total.properties;
|
|
290
|
+
const deviceImpact = Object.entries(deviceProfiles).reduce((acc, [name, profile]) => {
|
|
291
|
+
const download = (unnecessaryBytes * 8) / (profile.networkSpeed * 1000);
|
|
292
|
+
const parsing = (unnecessaryBytes / (1024 * 1024) / profile.cssParsingSpeed) * 1000;
|
|
293
|
+
const cssom = totalProperties * 0.01 * profile.cssSelectorMatchingMultiplier;
|
|
294
|
+
const totalBlocking = download + parsing + cssom;
|
|
295
|
+
const runtime =
|
|
296
|
+
(totalClasses * 0.005 + totalProperties * 0.002) * profile.cssSelectorMatchingMultiplier;
|
|
297
|
+
|
|
298
|
+
acc[name] = {
|
|
299
|
+
renderBlockingTimeMs: totalBlocking,
|
|
300
|
+
runtimeOverheadMs: runtime,
|
|
301
|
+
fcpImpactMs: totalBlocking * 0.6,
|
|
302
|
+
lcpImpactMs: totalBlocking * 0.4,
|
|
303
|
+
inpOverheadMs: runtime,
|
|
304
|
+
};
|
|
305
|
+
return acc;
|
|
306
|
+
}, {});
|
|
307
|
+
return { unnecessaryBytes, totalClasses, totalProperties, deviceImpact };
|
|
308
|
+
}
|
|
309
|
+
|
|
257
310
|
async function analyzeCSSPerformanceImpact(minWidth = 768) {
|
|
258
311
|
console.log("🔍 Analyzing CSS performance impact...\n");
|
|
312
|
+
const deviceProfiles = getDeviceProfiles();
|
|
259
313
|
|
|
260
314
|
// First, run the media queries analysis
|
|
261
315
|
const mediaQueryResults = await analyzeCSSMediaQueries(minWidth);
|
|
@@ -286,28 +340,6 @@ async function analyzeCSSPerformanceImpact(minWidth = 768) {
|
|
|
286
340
|
return null;
|
|
287
341
|
}
|
|
288
342
|
|
|
289
|
-
// Device profiles based on real Chrome UX Report data
|
|
290
|
-
const deviceProfiles = {
|
|
291
|
-
"High-end (Pixel 7, iPhone 14)": {
|
|
292
|
-
cssParsingSpeed: 1.5, // MB/s
|
|
293
|
-
cssSelectorMatchingMultiplier: 1.0,
|
|
294
|
-
networkSpeed: 10, // Mbps (4G LTE)
|
|
295
|
-
description: "Top 25% devices",
|
|
296
|
-
},
|
|
297
|
-
"Mid-range (Moto G Power, iPhone SE)": {
|
|
298
|
-
cssParsingSpeed: 0.8, // MB/s
|
|
299
|
-
cssSelectorMatchingMultiplier: 1.8,
|
|
300
|
-
networkSpeed: 5, // Mbps (4G)
|
|
301
|
-
description: "Median mobile device",
|
|
302
|
-
},
|
|
303
|
-
"Low-end (Moto E, older devices)": {
|
|
304
|
-
cssParsingSpeed: 0.3, // MB/s
|
|
305
|
-
cssSelectorMatchingMultiplier: 3.5,
|
|
306
|
-
networkSpeed: 2, // Mbps (3G/slow 4G)
|
|
307
|
-
description: "Bottom 25% devices",
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
|
|
311
343
|
const totalClasses = mediaQueryResults.summary.total.classes;
|
|
312
344
|
const totalProperties = mediaQueryResults.summary.total.properties;
|
|
313
345
|
|
|
@@ -464,23 +496,7 @@ async function analyzeCSSPerformanceImpact(minWidth = 768) {
|
|
|
464
496
|
totalClasses,
|
|
465
497
|
totalProperties,
|
|
466
498
|
corsBlockedCount: mediaQueryResults.summary.corsBlocked,
|
|
467
|
-
deviceImpact:
|
|
468
|
-
const download = (unnecessaryBytes * 8) / (profile.networkSpeed * 1000);
|
|
469
|
-
const parsing = (unnecessaryBytes / (1024 * 1024) / profile.cssParsingSpeed) * 1000;
|
|
470
|
-
const cssom = totalProperties * 0.01 * profile.cssSelectorMatchingMultiplier;
|
|
471
|
-
const totalBlocking = download + parsing + cssom;
|
|
472
|
-
const runtime =
|
|
473
|
-
(totalClasses * 0.005 + totalProperties * 0.002) * profile.cssSelectorMatchingMultiplier;
|
|
474
|
-
|
|
475
|
-
acc[name] = {
|
|
476
|
-
renderBlockingTimeMs: totalBlocking,
|
|
477
|
-
runtimeOverheadMs: runtime,
|
|
478
|
-
fcpImpactMs: totalBlocking * 0.6,
|
|
479
|
-
lcpImpactMs: totalBlocking * 0.4,
|
|
480
|
-
inpOverheadMs: runtime,
|
|
481
|
-
};
|
|
482
|
-
return acc;
|
|
483
|
-
}, {}),
|
|
499
|
+
deviceImpact: estimateCSSImpact(mediaQueryResults.summary).deviceImpact,
|
|
484
500
|
estimatedConversionLift: ((midRangeTotalBlocking * 0.6) / 100).toFixed(2) + "%",
|
|
485
501
|
};
|
|
486
502
|
}
|
|
@@ -502,6 +518,22 @@ window.analyzeCSSPerformanceImpact = analyzeCSSPerformanceImpact;
|
|
|
502
518
|
inline: result.summary.inline,
|
|
503
519
|
files: result.summary.files,
|
|
504
520
|
corsBlockedCount: result.summary.corsBlocked,
|
|
521
|
+
// Estimated cost of the desktop-only CSS, computed silently (the console report stays
|
|
522
|
+
// opt-in through analyzeCSSPerformanceImpact()). null when there is none.
|
|
523
|
+
performanceImpact: (() => {
|
|
524
|
+
const impact = estimateCSSImpact(result.summary);
|
|
525
|
+
if (impact.unnecessaryBytes === 0) return null;
|
|
526
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
527
|
+
return {
|
|
528
|
+
...impact,
|
|
529
|
+
deviceImpact: Object.fromEntries(
|
|
530
|
+
Object.entries(impact.deviceImpact).map(([device, d]) => [
|
|
531
|
+
device,
|
|
532
|
+
Object.fromEntries(Object.entries(d).map(([k, v]) => [k, round1(v)])),
|
|
533
|
+
])
|
|
534
|
+
),
|
|
535
|
+
};
|
|
536
|
+
})(),
|
|
505
537
|
},
|
|
506
538
|
items: [...result.details.inline, ...result.details.files],
|
|
507
539
|
};
|
|
@@ -3,36 +3,56 @@
|
|
|
3
3
|
// https://webperf-snippets.nucliweb.net
|
|
4
4
|
|
|
5
5
|
(async () => {
|
|
6
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
7
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
8
|
+
const OWN_DOMAINS = [];
|
|
9
|
+
|
|
6
10
|
// --- Helpers ---
|
|
7
11
|
|
|
12
|
+
// @shared getRootDomain
|
|
8
13
|
function getRootDomain(hostname) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
const host = hostname.replace(/\.$/, "");
|
|
15
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
16
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
17
|
+
const parts = host.split(".");
|
|
18
|
+
if (parts.length <= 2) return host;
|
|
19
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
20
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
21
|
+
const tld = parts[parts.length - 1];
|
|
22
|
+
const sld = parts[parts.length - 2];
|
|
23
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
24
|
+
return parts.slice(-2).join(".");
|
|
25
|
+
}
|
|
26
|
+
// @end-shared getRootDomain
|
|
27
|
+
|
|
28
|
+
// @shared isFirstParty
|
|
25
29
|
function isFirstParty(hostname) {
|
|
26
|
-
|
|
30
|
+
const root = getRootDomain(hostname);
|
|
31
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
32
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
33
|
+
}
|
|
34
|
+
// @end-shared isFirstParty
|
|
35
|
+
|
|
36
|
+
// @shared logOwnDomainsHint
|
|
37
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
38
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
39
|
+
console.log(
|
|
40
|
+
'%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.',
|
|
41
|
+
"color: #3b82f6;"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
27
44
|
}
|
|
45
|
+
// @end-shared logOwnDomainsHint
|
|
28
46
|
|
|
47
|
+
// @shared formatBytes
|
|
29
48
|
function formatBytes(bytes) {
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const i = Math.floor(Math.log(bytes) / Math.log(
|
|
34
|
-
return (bytes / Math.pow(
|
|
49
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
50
|
+
if (bytes === 0) return "0 B";
|
|
51
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
52
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
53
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
35
54
|
}
|
|
55
|
+
// @end-shared formatBytes
|
|
36
56
|
|
|
37
57
|
function getResourceType(entry) {
|
|
38
58
|
// Use file extension as primary signal — initiatorType reflects the loader
|
|
@@ -374,13 +394,17 @@
|
|
|
374
394
|
|
|
375
395
|
// --- Phase 6: Value-add metrics ---
|
|
376
396
|
|
|
377
|
-
|
|
397
|
+
// Only resources whose cache headers were read have a cache policy to judge. One whose headers
|
|
398
|
+
// could not be read, or that was only inferred as cached, says nothing about the policy: counting it
|
|
399
|
+
// as "not effective" made the figure measure how much was analyzable instead of the cache.
|
|
400
|
+
const analyzableEntries = entries.filter((e) => e.headers !== null);
|
|
401
|
+
const effectiveEntries = analyzableEntries.filter((e) =>
|
|
378
402
|
EFFECTIVE_CACHE_STRATEGIES.has(e.cacheStrategy)
|
|
379
403
|
);
|
|
380
404
|
const cacheEfficiencyPercent =
|
|
381
|
-
|
|
382
|
-
? Math.round((effectiveEntries.length /
|
|
383
|
-
:
|
|
405
|
+
analyzableEntries.length > 0
|
|
406
|
+
? Math.round((effectiveEntries.length / analyzableEntries.length) * 100)
|
|
407
|
+
: null;
|
|
384
408
|
|
|
385
409
|
const bandwidthSavedBytes = entries
|
|
386
410
|
.filter((e) => e.isCached)
|
|
@@ -422,8 +446,39 @@
|
|
|
422
446
|
const costlyDuplicates = uniqueDuplicates.filter((d) => d.costly);
|
|
423
447
|
const cachedDuplicates = uniqueDuplicates.filter((d) => !d.costly);
|
|
424
448
|
|
|
449
|
+
// These two findings need no cache headers, so they also cover resources on other origins. They
|
|
450
|
+
// are anti-patterns like the header-based ones: they reach `items` and `issues`.
|
|
451
|
+
for (const entry of uncompressedResources) {
|
|
452
|
+
entry.antiPatterns.push({
|
|
453
|
+
id: "uncompressed",
|
|
454
|
+
severity: "warning",
|
|
455
|
+
message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}) — enable Brotli or gzip`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
for (const duplicate of costlyDuplicates) {
|
|
459
|
+
// One finding per URL, on the first request, not one per request
|
|
460
|
+
entries.find((e) => e.url === duplicate.url).antiPatterns.push({
|
|
461
|
+
id: "duplicate-uncached",
|
|
462
|
+
severity: "warning",
|
|
463
|
+
message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network — load it once or make it cacheable`,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
425
467
|
// --- Phase 7: Console output ---
|
|
426
468
|
|
|
469
|
+
if (entries.length === 0) {
|
|
470
|
+
console.group("%c🔍 Cache Strategy Analysis", "font-weight: bold; font-size: 14px;");
|
|
471
|
+
console.warn("⚠️ No resources found in the Performance API buffer.");
|
|
472
|
+
console.log(" This usually means the site called performance.clearResourceTimings() to manage memory.");
|
|
473
|
+
console.log(" Try: reload the page and run this snippet immediately after load.");
|
|
474
|
+
console.groupEnd();
|
|
475
|
+
return {
|
|
476
|
+
script: "Cache-Strategy-Analysis",
|
|
477
|
+
status: "error",
|
|
478
|
+
error: "No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again.",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
427
482
|
const allAntiPatterns = entries.flatMap((e) =>
|
|
428
483
|
e.antiPatterns.map((ap) => ({ ...ap, resource: e.shortName, url: e.url }))
|
|
429
484
|
);
|
|
@@ -443,6 +498,7 @@
|
|
|
443
498
|
);
|
|
444
499
|
|
|
445
500
|
const headersAnalyzed = entries.filter((e) => e.headers !== null).length;
|
|
501
|
+
const headerCoveragePercent = Math.round((headersAnalyzed / entries.length) * 100);
|
|
446
502
|
const corsRestricted = entries.filter((e) => e.isCorsRestricted).length;
|
|
447
503
|
|
|
448
504
|
// Resources excluded from header analysis — cross-origin or non-static types.
|
|
@@ -458,6 +514,26 @@
|
|
|
458
514
|
!FETCHABLE_TYPES.has(e.type))
|
|
459
515
|
);
|
|
460
516
|
|
|
517
|
+
// Header analysis only reaches same-origin static assets. When most of the page is elsewhere (a CDN
|
|
518
|
+
// or another origin for the assets), say so and name the hosts, so an empty list is not read as "clean".
|
|
519
|
+
let coverageIssue = null;
|
|
520
|
+
if (headerCoveragePercent < 50) {
|
|
521
|
+
const hostCounts = new Map();
|
|
522
|
+
for (const e of entries.filter((e) => e.headers === null)) {
|
|
523
|
+
hostCounts.set(e.host, (hostCounts.get(e.host) || 0) + 1);
|
|
524
|
+
}
|
|
525
|
+
const topHosts = [...hostCounts.entries()]
|
|
526
|
+
.sort((a, b) => b[1] - a[1])
|
|
527
|
+
.slice(0, 3)
|
|
528
|
+
.map(([host]) => host)
|
|
529
|
+
.join(", ");
|
|
530
|
+
coverageIssue = {
|
|
531
|
+
severity: "info",
|
|
532
|
+
id: "header-coverage",
|
|
533
|
+
message: `Cache headers were read for ${headersAnalyzed} of ${entries.length} resources (${headerCoveragePercent}%): the rest are on other origins or are not static assets, so their Cache-Control cannot be read from the page. Most of them are on ${topHosts}; check those hosts in the Network panel or with curl -I`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
461
537
|
console.group(
|
|
462
538
|
"%c🔍 Cache Strategy Analysis",
|
|
463
539
|
"font-weight: bold; font-size: 14px;"
|
|
@@ -469,13 +545,18 @@
|
|
|
469
545
|
console.log(
|
|
470
546
|
` Headers analyzed: ${headersAnalyzed} | CORS-restricted: ${corsRestricted} | Excluded (non-static/cross-origin): ${excludedFromHeaders.length}`
|
|
471
547
|
);
|
|
548
|
+
if (coverageIssue) console.log(` ℹ️ ${coverageIssue.message}`);
|
|
472
549
|
if (excludedFromHeaders.length > 0) {
|
|
473
550
|
const excludedTypes = [...new Set(excludedFromHeaders.map((e) => e.type))].join(", ");
|
|
474
551
|
console.log(
|
|
475
552
|
` ℹ️ Excluded resources (${excludedTypes}) are analyzed with Tier 1 data only (Performance API) — header inspection requires same-origin static assets due to CORS restrictions.`
|
|
476
553
|
);
|
|
477
554
|
}
|
|
478
|
-
console.log(
|
|
555
|
+
console.log(
|
|
556
|
+
cacheEfficiencyPercent === null
|
|
557
|
+
? ` Cache efficiency: n/a (no cache headers could be read)`
|
|
558
|
+
: ` Cache efficiency: ${cacheEfficiencyPercent}% (of the ${analyzableEntries.length} resources whose cache headers could be read)`
|
|
559
|
+
);
|
|
479
560
|
console.log(` Bandwidth saved by cache: ${formatBytes(bandwidthSavedBytes)}`);
|
|
480
561
|
if (cdnHitRate !== null) {
|
|
481
562
|
console.log(
|
|
@@ -580,6 +661,8 @@
|
|
|
580
661
|
{ Pattern: "expires-without-cc", Severity: "🟡 warning", Meaning: "Uses Expires header without Cache-Control — outdated pattern, less reliable" },
|
|
581
662
|
{ Pattern: "maxage-zero-static", Severity: "🟡 warning", Meaning: "Static asset with max-age=0 — browser revalidates on every request" },
|
|
582
663
|
{ Pattern: "short-cache-static", Severity: "🟡 warning", Meaning: "Static asset with max-age < 3600 — consider a longer cache duration" },
|
|
664
|
+
{ Pattern: "uncompressed", Severity: "🟡 warning", Meaning: "Text resource sent without Brotli or gzip — works for any origin, no headers needed" },
|
|
665
|
+
{ Pattern: "duplicate-uncached", Severity: "🟡 warning", Meaning: "Same URL loaded more than once with at least one request to the network" },
|
|
583
666
|
{ Pattern: "versioned-not-immutable", Severity: "🔵 info", Meaning: "Hashed URL without immutable directive — adding it avoids conditional GET requests" },
|
|
584
667
|
]);
|
|
585
668
|
console.groupEnd();
|
|
@@ -703,9 +786,9 @@
|
|
|
703
786
|
"🟡 Replace Expires headers with Cache-Control: max-age — Expires is an outdated mechanism and less reliable."
|
|
704
787
|
);
|
|
705
788
|
}
|
|
706
|
-
if (cacheEfficiencyPercent < 50) {
|
|
789
|
+
if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
|
|
707
790
|
recommendations.push(
|
|
708
|
-
`🔴 Cache efficiency is low (${cacheEfficiencyPercent}
|
|
791
|
+
`🔴 Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
|
|
709
792
|
);
|
|
710
793
|
}
|
|
711
794
|
if (cdnHitRate !== null && cdnHitRate < 70) {
|
|
@@ -747,28 +830,41 @@
|
|
|
747
830
|
console.groupEnd();
|
|
748
831
|
}
|
|
749
832
|
|
|
833
|
+
logOwnDomainsHint(entries.filter((e) => !e.firstParty).length);
|
|
750
834
|
console.groupEnd(); // Cache Strategy Analysis
|
|
751
835
|
|
|
752
836
|
// --- Return structured object ---
|
|
753
837
|
|
|
754
838
|
const cacheEfficiencyRating =
|
|
755
|
-
cacheEfficiencyPercent
|
|
839
|
+
cacheEfficiencyPercent === null
|
|
840
|
+
? null
|
|
841
|
+
: cacheEfficiencyPercent >= 80
|
|
756
842
|
? "good"
|
|
757
843
|
: cacheEfficiencyPercent >= 50
|
|
758
844
|
? "needs-improvement"
|
|
759
845
|
: "poor";
|
|
760
846
|
|
|
847
|
+
const MAX_ITEMS = 50;
|
|
848
|
+
const actionableEntries = entries.filter(
|
|
849
|
+
(e) =>
|
|
850
|
+
e.antiPatterns.length > 0 ||
|
|
851
|
+
["none", "no-store", "no-cache", "short"].includes(e.cacheStrategy)
|
|
852
|
+
);
|
|
761
853
|
return {
|
|
762
854
|
script: "Cache-Strategy-Analysis",
|
|
763
855
|
status: "ok",
|
|
764
856
|
count: entries.length,
|
|
857
|
+
corsLimitedAnalysis: corsRestricted > 0,
|
|
765
858
|
details: {
|
|
766
859
|
totalResources: entries.length,
|
|
860
|
+
actionableCount: actionableEntries.length,
|
|
767
861
|
headersAnalyzed,
|
|
768
862
|
corsRestricted,
|
|
769
863
|
excludedFromHeaders: excludedFromHeaders.length,
|
|
864
|
+
headerCoveragePercent,
|
|
770
865
|
cacheEfficiencyPercent,
|
|
771
866
|
cacheEfficiencyRating,
|
|
867
|
+
cacheEfficiencyResources: analyzableEntries.length,
|
|
772
868
|
bandwidthSavedBytes,
|
|
773
869
|
strategyDistribution: strategyGroups,
|
|
774
870
|
cdnDetected: cdnEntries.length > 0,
|
|
@@ -780,13 +876,12 @@
|
|
|
780
876
|
uncompressedCount: uncompressedResources.length,
|
|
781
877
|
protocolDistribution,
|
|
782
878
|
},
|
|
783
|
-
// Only resources with actionable
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
)
|
|
879
|
+
// Only resources with actionable findings (cache anti-patterns, a weak cache strategy, a costly
|
|
880
|
+
// duplicate or uncompressed text), at most 50: the ones with the most anti-patterns first, then the
|
|
881
|
+
// largest. Clean resources are left out to reduce noise.
|
|
882
|
+
items: actionableEntries
|
|
883
|
+
.sort((a, b) => b.antiPatterns.length - a.antiPatterns.length || b.decodedBodySize - a.decodedBodySize)
|
|
884
|
+
.slice(0, MAX_ITEMS)
|
|
790
885
|
.map((e) => ({
|
|
791
886
|
shortName: e.shortName,
|
|
792
887
|
host: e.host,
|
|
@@ -797,12 +892,15 @@
|
|
|
797
892
|
isCached: e.isCached,
|
|
798
893
|
antiPatterns: e.antiPatterns.map((p) => p.id),
|
|
799
894
|
})),
|
|
800
|
-
issues:
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
895
|
+
issues: [
|
|
896
|
+
...allAntiPatterns.map((p) => ({
|
|
897
|
+
severity: p.severity,
|
|
898
|
+
id: p.id,
|
|
899
|
+
resource: p.resource,
|
|
900
|
+
message: p.message,
|
|
901
|
+
})),
|
|
902
|
+
...(coverageIssue ? [coverageIssue] : []),
|
|
903
|
+
],
|
|
806
904
|
recommendations,
|
|
807
905
|
};
|
|
808
906
|
})();
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Compression Audit
|
|
2
|
+
// Finds text-based resources served without HTTP compression and estimates the savings
|
|
3
|
+
// https://webperf-snippets.nucliweb.net
|
|
4
|
+
|
|
5
|
+
(() => {
|
|
6
|
+
const MIN_BYTES = 1024; // Below this, compression saves too little to matter
|
|
7
|
+
const MAX_ITEMS = 50;
|
|
8
|
+
|
|
9
|
+
// Typical size reduction gzip/brotli achieve per resource type (estimate, not a measurement)
|
|
10
|
+
const SAVINGS_RATIO = { js: 0.7, css: 0.7, html: 0.7, json: 0.7, svg: 0.7, xml: 0.7, font: 0.5 };
|
|
11
|
+
|
|
12
|
+
const EXTENSION_TYPES = {
|
|
13
|
+
js: "js", mjs: "js", cjs: "js",
|
|
14
|
+
css: "css",
|
|
15
|
+
html: "html", htm: "html",
|
|
16
|
+
json: "json", webmanifest: "json",
|
|
17
|
+
svg: "svg",
|
|
18
|
+
xml: "xml", rss: "xml", atom: "xml",
|
|
19
|
+
ttf: "font", otf: "font", eot: "font",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// @shared formatBytes
|
|
23
|
+
function formatBytes(bytes) {
|
|
24
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
25
|
+
if (bytes === 0) return "0 B";
|
|
26
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
27
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
28
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
29
|
+
}
|
|
30
|
+
// @end-shared formatBytes
|
|
31
|
+
|
|
32
|
+
const parseUrl = (name) => {
|
|
33
|
+
try {
|
|
34
|
+
return new URL(name);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Text type of a resource, or null when it is not a compressible text resource
|
|
41
|
+
const textType = (entry, url) => {
|
|
42
|
+
if (entry.entryType === "navigation") return "html";
|
|
43
|
+
const ext = url.pathname.split(".").pop().toLowerCase();
|
|
44
|
+
if (EXTENSION_TYPES[ext]) return EXTENSION_TYPES[ext];
|
|
45
|
+
if (entry.initiatorType === "script") return "js";
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const entries = [...performance.getEntriesByType("navigation"), ...performance.getEntriesByType("resource")];
|
|
50
|
+
const contentEncodingSupported = entries.some((e) => "contentEncoding" in e);
|
|
51
|
+
|
|
52
|
+
const candidates = [];
|
|
53
|
+
entries.forEach((entry) => {
|
|
54
|
+
const url = parseUrl(entry.name);
|
|
55
|
+
if (!url || !/^https?:$/.test(url.protocol)) return;
|
|
56
|
+
const type = textType(entry, url);
|
|
57
|
+
if (!type) return;
|
|
58
|
+
candidates.push({ entry, url, type });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const compressed = [];
|
|
62
|
+
const uncompressed = [];
|
|
63
|
+
let sizeUnknownCount = 0;
|
|
64
|
+
let skippedSmallCount = 0;
|
|
65
|
+
const byEncoding = {};
|
|
66
|
+
|
|
67
|
+
candidates.forEach(({ entry, url, type }) => {
|
|
68
|
+
const decoded = entry.decodedBodySize || 0;
|
|
69
|
+
const encoded = entry.encodedBodySize || 0;
|
|
70
|
+
|
|
71
|
+
// Cross-origin resources without Timing-Allow-Origin report every size as zero
|
|
72
|
+
if (decoded === 0 && url.origin !== location.origin) {
|
|
73
|
+
sizeUnknownCount++;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (decoded < MIN_BYTES) {
|
|
77
|
+
skippedSmallCount++;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const encoding = (entry.contentEncoding || "").toLowerCase();
|
|
82
|
+
// Without contentEncoding (older browsers), a body smaller than its decoded form was compressed
|
|
83
|
+
const isCompressed = encoding ? encoding !== "identity" : encoded < decoded;
|
|
84
|
+
|
|
85
|
+
if (isCompressed) {
|
|
86
|
+
const label = encoding || "unspecified";
|
|
87
|
+
byEncoding[label] = (byEncoding[label] || 0) + 1;
|
|
88
|
+
compressed.push(entry);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const savings = Math.round(decoded * SAVINGS_RATIO[type]);
|
|
93
|
+
uncompressed.push({
|
|
94
|
+
url: entry.name,
|
|
95
|
+
shortName: url.pathname.split("/").filter(Boolean).pop() || url.hostname,
|
|
96
|
+
type,
|
|
97
|
+
encoding: "none",
|
|
98
|
+
encodedBytes: encoded,
|
|
99
|
+
decodedBytes: decoded,
|
|
100
|
+
estimatedSavingsBytes: savings,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
byEncoding.none = uncompressed.length;
|
|
105
|
+
uncompressed.sort((a, b) => b.estimatedSavingsBytes - a.estimatedSavingsBytes);
|
|
106
|
+
|
|
107
|
+
const totalUncompressedBytes = uncompressed.reduce((n, i) => n + i.decodedBytes, 0);
|
|
108
|
+
const estimatedSavingsBytes = uncompressed.reduce((n, i) => n + i.estimatedSavingsBytes, 0);
|
|
109
|
+
const corsLimitedAnalysis = sizeUnknownCount > 0;
|
|
110
|
+
|
|
111
|
+
const issues = [];
|
|
112
|
+
if (uncompressed.length > 0) {
|
|
113
|
+
issues.push({
|
|
114
|
+
severity: "warning",
|
|
115
|
+
message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (corsLimitedAnalysis) {
|
|
119
|
+
issues.push({
|
|
120
|
+
severity: "info",
|
|
121
|
+
message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!contentEncodingSupported && candidates.length > 0) {
|
|
125
|
+
issues.push({
|
|
126
|
+
severity: "info",
|
|
127
|
+
message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.group("%c🗜️ Compression Audit", "font-weight: bold; font-size: 14px;");
|
|
132
|
+
console.log(
|
|
133
|
+
`Text resources: ${candidates.length} | compressed: ${compressed.length} | uncompressed: ${uncompressed.length} | size unknown: ${sizeUnknownCount}`
|
|
134
|
+
);
|
|
135
|
+
if (uncompressed.length > 0) {
|
|
136
|
+
console.log(`Estimated savings: ~${formatBytes(estimatedSavingsBytes)} of ${formatBytes(totalUncompressedBytes)}`);
|
|
137
|
+
console.table(
|
|
138
|
+
uncompressed.slice(0, MAX_ITEMS).map((i) => ({
|
|
139
|
+
Resource: i.shortName,
|
|
140
|
+
Type: i.type,
|
|
141
|
+
Size: formatBytes(i.decodedBytes),
|
|
142
|
+
"Est. savings": formatBytes(i.estimatedSavingsBytes),
|
|
143
|
+
}))
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
console.log("%c✅ No uncompressed text resources found", "color: #22c55e;");
|
|
147
|
+
}
|
|
148
|
+
if (corsLimitedAnalysis) {
|
|
149
|
+
console.log("%cℹ️ Some cross-origin resources could not be analyzed (no Timing-Allow-Origin)", "color: #3b82f6;");
|
|
150
|
+
}
|
|
151
|
+
console.groupEnd();
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
script: "Compression-Audit",
|
|
155
|
+
status: "ok",
|
|
156
|
+
count: uncompressed.length,
|
|
157
|
+
corsLimitedAnalysis,
|
|
158
|
+
details: {
|
|
159
|
+
totalTextResources: candidates.length,
|
|
160
|
+
compressedCount: compressed.length,
|
|
161
|
+
uncompressedCount: uncompressed.length,
|
|
162
|
+
sizeUnknownCount,
|
|
163
|
+
skippedSmallCount,
|
|
164
|
+
totalUncompressedBytes,
|
|
165
|
+
estimatedSavingsBytes,
|
|
166
|
+
byEncoding,
|
|
167
|
+
contentEncodingSupported,
|
|
168
|
+
},
|
|
169
|
+
items: uncompressed.slice(0, MAX_ITEMS),
|
|
170
|
+
issues,
|
|
171
|
+
};
|
|
172
|
+
})();
|
|
@@ -345,8 +345,13 @@ window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportuni
|
|
|
345
345
|
...cvResults.autoElements.map(el => ({ ...el, type: "auto" })),
|
|
346
346
|
...cvResults.hiddenElements.map(el => ({ ...el, type: "hidden" })),
|
|
347
347
|
],
|
|
348
|
-
issues:
|
|
349
|
-
.
|
|
350
|
-
|
|
348
|
+
issues: [
|
|
349
|
+
...cvResults.autoElements
|
|
350
|
+
.filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
|
|
351
|
+
.map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
|
|
352
|
+
...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
|
|
353
|
+
? [{ severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
|
|
354
|
+
: []),
|
|
355
|
+
],
|
|
351
356
|
};
|
|
352
357
|
})();
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// https://webperf-snippets.nucliweb.net
|
|
4
4
|
|
|
5
5
|
(() => {
|
|
6
|
-
|
|
6
|
+
// @shared formatBytes
|
|
7
|
+
function formatBytes(bytes) {
|
|
8
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
7
9
|
if (bytes === 0) return "0 B";
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
|
14
16
|
const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
|
|
15
17
|
|