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.
Files changed (69) hide show
  1. package/README.md +185 -20
  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 +52 -30
  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/Find-render-blocking-resources.js +11 -13
  33. package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
  34. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  35. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
  36. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  37. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  38. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  39. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  40. package/snippets/Loading/Resource-Hints-Validation.js +15 -0
  41. package/snippets/Loading/Resource-Hints.js +29 -8
  42. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  43. package/snippets/Loading/Script-Loading.js +55 -25
  44. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  45. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  46. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  47. package/snippets/Loading/TTFB-Resources.js +59 -18
  48. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  49. package/snippets/Loading/TTFB.js +3 -2
  50. package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  52. package/snippets/Loading/WORKFLOWS.md +47 -1
  53. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  54. package/snippets/Media/Image-Element-Audit.js +22 -3
  55. package/snippets/Media/Oversized-Images.js +217 -0
  56. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  57. package/snippets/Media/WORKFLOWS.md +22 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  59. package/snippets/SCHEMA.md +1176 -0
  60. package/snippets/_shared/helpers.js +44 -0
  61. package/src/bin.js +86 -57
  62. package/src/crux.js +84 -0
  63. package/src/decision-tree.js +11 -1
  64. package/src/interactions.js +30 -0
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +20 -1
  67. package/src/reporters/markdown.js +103 -0
  68. package/src/runner.js +81 -17
  69. 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 (totalSize > 0) {
67
- const sizeKB = (totalSize / 1024).toFixed(1);
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 parts = hostname.split(".");
8
- // Handle cases like co.uk, com.au, etc.
9
- if (parts.length > 2) {
10
- // Simple heuristic: if second-to-last part is short (co, com, org, net), take 3 parts
11
- const sld = parts[parts.length - 2];
12
- if (sld.length <= 3 && ["co", "com", "org", "net", "gov", "edu"].includes(sld)) {
13
- return parts.slice(-3).join(".");
14
- }
15
- return parts.slice(-2).join(".");
16
- }
17
- return hostname;
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
- const currentRootDomain = getRootDomain(location.hostname);
21
-
22
+ // @shared isFirstParty
22
23
  function isFirstParty(hostname) {
23
- return getRootDomain(hostname) === currentRootDomain;
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 (!bytes || bytes === 0) return "N/A";
28
- const k = 1024;
29
- const sizes = ["B", "KB", "MB"];
30
- const i = Math.floor(Math.log(bytes) / Math.log(k));
31
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
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: scripts.map((s) => ({
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 parts = hostname.split(".");
8
- if (parts.length > 2) {
9
- const sld = parts[parts.length - 2];
10
- if (sld.length <= 3 && ["co", "com", "org", "net", "gov", "edu"].includes(sld)) {
11
- return parts.slice(-3).join(".");
12
- }
13
- return parts.slice(-2).join(".");
14
- }
15
- return hostname;
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
- const currentRootDomain = getRootDomain(location.hostname);
19
-
22
+ // @shared isFirstParty
20
23
  function isFirstParty(hostname) {
21
- return getRootDomain(hostname) === currentRootDomain;
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: scripts.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 })),
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
- // Check if URL is third-party
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 !== location.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
- items: uniqueLoadedFonts.map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
310
- usedFonts: usedFonts.map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
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
- const formatBytes = (bytes) => {
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 k = 1024;
8
- const sizes = ["B", "KB", "MB"];
9
- const i = Math.floor(Math.log(bytes) / Math.log(k));
10
- return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
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
- const formatBytes = (bytes) => {
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 k = 1024;
8
- const sizes = ["B", "KB", "MB"];
9
- const i = Math.floor(Math.log(bytes) / Math.log(k));
10
- return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
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: executableScripts.map(s => ({ index: s.index, sizeBytes: s.size, category: s.category, inHead: s.inHead, isParserBlocking: s.isParserBlocking, isAnalytics: s.isAnalytics, isConfig: s.isConfig })),
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
- const formatBytes = (bytes) => {
7
- if (!bytes || bytes === 0) return "-";
8
- const k = 1024;
9
- const sizes = ["B", "KB", "MB"];
10
- const i = Math.floor(Math.log(Math.max(bytes, 1)) / Math.log(k));
11
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
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 — consider code splitting` }] : []),
333
- ...(poorCompression ? [{ severity: "warning", message: `Low compression ratio (${compressionRatio.toFixed(1)}x) — enable Brotli/gzip` }] : []),
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 => ({ severity: i.severity, message: i.message })),
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
- const formatBytes = (bytes) => {
6
- if (!bytes || bytes === 0) return "-";
7
- const k = 1024;
8
- const sizes = ["B", "KB", "MB"];
9
- const i = Math.floor(Math.log(bytes) / Math.log(k));
10
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
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: allHints.map(h => ({ rel: h.rel, href: h.shortHref, as: h.as, crossorigin: h.crossorigin, fetchpriority: h.fetchpriority })),
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
- const formatBytes = (bytes) => {
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 k = 1024;
8
- const sizes = ["B", "KB", "MB"];
9
- const i = Math.floor(Math.log(bytes) / Math.log(k));
10
- return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
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
- items: detected.map(fw => ({ name: fw.name, sizeBytes: fw.size, thresholdBytes: fw.threshold, exceedsThreshold: fw.exceedsThreshold })),
362
- issues: detected.filter(fw => fw.exceedsThreshold).map(fw => ({ severity: "warning", message: `${fw.name} hydration data (${Math.round(fw.size / 1024)} KB) exceeds ${Math.round(fw.threshold / 1024)} KB threshold` })),
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
  })();