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.
Files changed (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. 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 === 0) return "0 Bytes";
59
- const k = 1024;
60
- const sizes = ["Bytes", "KB", "MB"];
61
- const i = Math.floor(Math.log(bytes) / Math.log(k));
62
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
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: Object.entries(deviceProfiles).reduce((acc, [name, profile]) => {
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 parts = hostname.split(".");
10
- if (parts.length > 2) {
11
- const sld = parts[parts.length - 2];
12
- if (
13
- sld.length <= 3 &&
14
- ["co", "com", "org", "net", "gov", "edu"].includes(sld)
15
- ) {
16
- return parts.slice(-3).join(".");
17
- }
18
- return parts.slice(-2).join(".");
19
- }
20
- return hostname;
21
- }
22
-
23
- const currentRootDomain = getRootDomain(location.hostname);
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
- return getRootDomain(hostname) === currentRootDomain;
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 (!bytes || bytes === 0) return "N/A";
31
- const k = 1024;
32
- const sizes = ["B", "KB", "MB"];
33
- const i = Math.floor(Math.log(bytes) / Math.log(k));
34
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
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
- const effectiveEntries = entries.filter((e) =>
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
- entries.length > 0
382
- ? Math.round((effectiveEntries.length / entries.length) * 100)
383
- : 0;
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(` Cache efficiency: ${cacheEfficiencyPercent}%`);
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}%). Review caching strategy for all static assets.`
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 >= 80
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 issues — omits clean resources to reduce noise
784
- items: entries
785
- .filter(
786
- (e) =>
787
- e.antiPatterns.length > 0 ||
788
- ["none", "no-store", "no-cache", "short"].includes(e.cacheStrategy)
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: allAntiPatterns.map((p) => ({
801
- severity: p.severity,
802
- id: p.id,
803
- resource: p.resource,
804
- message: p.message,
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: cvResults.autoElements
349
- .filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
350
- .map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
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
- const formatBytes = (bytes) => {
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 k = 1024;
9
- const sizes = ["B", "KB", "MB"];
10
- const i = Math.floor(Math.log(bytes) / Math.log(k));
11
- return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
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