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
@@ -461,15 +461,32 @@
461
461
  } catch {}
462
462
  });
463
463
 
464
- const totalIssues = preloadLinks.filter(l => !usedResourceUrls.has(normalizeUrl(l.href))).length +
465
- preconnectLinks.filter(l => {
466
- const domain = getDomain(l.href);
467
- return domain && !usedDomains.has(domain) && !lazyLoadedDomainsForSummary.has(domain);
468
- }).length +
469
- dnsPrefetchLinks.filter(l => {
470
- const domain = getDomain(l.href);
471
- return domain && !usedDomains.has(domain) && !lazyLoadedDomainsForSummary.has(domain);
472
- }).length;
464
+ // A preload with a media query that does not match this viewport is conditional, not unused
465
+ const isConditionalPreload = (l) => {
466
+ const media = l.getAttribute("media");
467
+ if (!media) return false;
468
+ try {
469
+ return !window.matchMedia(media).matches;
470
+ } catch {
471
+ return true;
472
+ }
473
+ };
474
+ const isUnusedConnection = (l) => {
475
+ const domain = getDomain(l.href);
476
+ return domain && !usedDomains.has(domain) && !lazyLoadedDomainsForSummary.has(domain);
477
+ };
478
+ const unusedHintItems = [
479
+ ...preloadLinks
480
+ .filter((l) => !usedResourceUrls.has(normalizeUrl(l.href)) && !isConditionalPreload(l))
481
+ .map((l) => ({ domain: getDomain(l.href), href: l.href, requestCount: 0, action: "remove-unused-preload", recommendedHint: null })),
482
+ ...preconnectLinks
483
+ .filter(isUnusedConnection)
484
+ .map((l) => ({ domain: getDomain(l.href), href: l.href, requestCount: 0, action: "remove-unused-preconnect", recommendedHint: null })),
485
+ ...dnsPrefetchLinks
486
+ .filter(isUnusedConnection)
487
+ .map((l) => ({ domain: getDomain(l.href), href: l.href, requestCount: 0, action: "remove-unused-dns-prefetch", recommendedHint: null })),
488
+ ];
489
+ const totalIssues = unusedHintItems.length;
473
490
 
474
491
  console.log("");
475
492
  if (totalIssues === 0 && missingHints.length === 0) {
@@ -494,6 +511,24 @@
494
511
  missingPreconnects: missingHints.length,
495
512
  redundantHints: redundantDomains.length,
496
513
  },
514
+ // The hints to remove (unused), the origins that need a hint and those with a redundant dns-prefetch (at most 50)
515
+ items: [
516
+ ...unusedHintItems,
517
+ ...missingHints.map(([domain, count]) => ({
518
+ domain,
519
+ href: null,
520
+ requestCount: count,
521
+ action: count >= 5 ? "add-preconnect" : "add-dns-prefetch",
522
+ recommendedHint: count >= 5 ? "preconnect" : "dns-prefetch",
523
+ })),
524
+ ...redundantDomains.map((domain) => ({
525
+ domain,
526
+ href: null,
527
+ requestCount: domainRequestCounts[domain] || 0,
528
+ action: "remove-dns-prefetch",
529
+ recommendedHint: "preconnect",
530
+ })),
531
+ ].slice(0, 50),
497
532
  issues: [
498
533
  ...(totalIssues > 0 ? [{ severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []),
499
534
  ...(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
  })();
@@ -2,33 +2,61 @@
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
+ // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
6
+ // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
7
+ const OWN_DOMAINS = [];
8
+
9
+ // @shared formatBytes
10
+ function formatBytes(bytes) {
11
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
12
+ if (bytes === 0) return "0 B";
13
+ const units = ["B", "KB", "MB", "GB"];
14
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
15
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
16
+ }
17
+ // @end-shared formatBytes
12
18
 
13
19
  const formatMs = (ms) => (ms > 0 ? ms.toFixed(0) + "ms" : "-");
14
20
 
15
- // Get current page's root domain for first/third party detection
16
- const getRootDomain = (hostname) => {
17
- const parts = hostname.split(".");
18
- if (parts.length > 2) {
19
- const sld = parts[parts.length - 2];
20
- if (["co", "com", "org", "net", "gov", "edu"].includes(sld) && sld.length <= 3) {
21
- return parts.slice(-3).join(".");
22
- }
23
- return parts.slice(-2).join(".");
21
+ // @shared getRootDomain
22
+ function getRootDomain(hostname) {
23
+ const host = hostname.replace(/\.$/, "");
24
+ // An IP address has no registrable domain, so each address is its own root
25
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
26
+ const parts = host.split(".");
27
+ if (parts.length <= 2) return host;
28
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
29
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
30
+ const tld = parts[parts.length - 1];
31
+ const sld = parts[parts.length - 2];
32
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
33
+ return parts.slice(-2).join(".");
34
+ }
35
+ // @end-shared getRootDomain
36
+
37
+ // @shared isFirstParty
38
+ function isFirstParty(hostname) {
39
+ const root = getRootDomain(hostname);
40
+ if (root === getRootDomain(location.hostname)) return true;
41
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
42
+ }
43
+ // @end-shared isFirstParty
44
+
45
+ // @shared logOwnDomainsHint
46
+ function logOwnDomainsHint(thirdPartyCount) {
47
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
48
+ console.log(
49
+ '%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.',
50
+ "color: #3b82f6;"
51
+ );
24
52
  }
25
- return hostname;
26
- };
53
+ }
54
+ // @end-shared logOwnDomainsHint
27
55
 
28
- const currentDomain = getRootDomain(location.hostname);
29
- const isFirstParty = (url) => {
56
+ // A script whose URL cannot be parsed counts as first party
57
+ const isFirstPartyUrl = (url) => {
30
58
  try {
31
- return getRootDomain(new URL(url).hostname) === currentDomain;
59
+ return isFirstParty(new URL(url).hostname);
32
60
  } catch {
33
61
  return true;
34
62
  }
@@ -40,6 +68,8 @@
40
68
  if (r.initiatorType === "script") {
41
69
  resourceTiming.set(r.name, {
42
70
  size: r.transferSize || 0,
71
+ // Cross-origin scripts without Timing-Allow-Origin report every size as 0
72
+ sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
43
73
  duration: r.duration,
44
74
  startTime: r.startTime,
45
75
  });
@@ -74,7 +104,7 @@
74
104
  }
75
105
 
76
106
  const isBlocking = strategy === "blocking";
77
- const firstParty = isFirstParty(src);
107
+ const firstParty = isFirstPartyUrl(src);
78
108
 
79
109
  return {
80
110
  src,
@@ -87,6 +117,7 @@
87
117
  isAsync,
88
118
  isDefer,
89
119
  size: timing.size || 0,
120
+ sizeKnown: !!timing.sizeKnown,
90
121
  duration: timing.duration || 0,
91
122
  startTime: timing.startTime || 0,
92
123
  element: script,
@@ -293,19 +324,30 @@
293
324
  console.log("");
294
325
  console.log("%cFor critical inline scripts:", "font-weight: bold;");
295
326
  console.log('%c<script>/* Only essential initialization */</script>', "font-family: monospace; color: #22c55e;");
327
+ logOwnDomainsHint(thirdPartyScripts.length);
296
328
  console.groupEnd();
297
329
 
298
330
  console.groupEnd();
299
331
 
300
332
  const agentRating = blockingInHead.length === 0 ? "good" :
301
333
  (blockingInHead.length <= 2 && thirdPartyBlocking.length === 0) ? "needs-improvement" : "poor";
334
+ // 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
335
+ const MAX_ITEMS = 50;
336
+ const rankedScripts = [...scripts].sort(
337
+ (a, b) =>
338
+ Number(b.isBlocking) - Number(a.isBlocking) ||
339
+ Number(a.firstParty) - Number(b.firstParty) ||
340
+ b.size - a.size
341
+ );
302
342
  return {
303
343
  script: "Script-Loading",
304
344
  status: "ok",
305
345
  count: scripts.length,
346
+ corsLimitedAnalysis: scripts.some((s) => !s.sizeKnown),
306
347
  rating: agentRating,
307
348
  details: {
308
349
  totalSizeBytes: totalSize,
350
+ sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
309
351
  byStrategy: {
310
352
  blocking: blocking.length,
311
353
  async: asyncScripts.length,
@@ -318,15 +360,21 @@
318
360
  },
319
361
  thirdPartyBlockingCount: thirdPartyBlocking.length,
320
362
  },
321
- items: scripts.map((s) => ({
363
+ items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
322
364
  url: s.src,
323
365
  shortName: s.shortSrc,
324
366
  strategy: s.strategy,
325
367
  location: s.inHead ? "head" : "body",
326
368
  party: s.firstParty ? "first" : "third",
327
369
  sizeBytes: s.size,
370
+ sizeKnown: s.sizeKnown,
328
371
  durationMs: Math.round(s.duration),
329
372
  })),
330
- issues: issues.map((i) => ({ severity: i.severity, message: i.message })),
373
+ issues: [
374
+ ...issues.map((i) => ({ severity: i.severity, message: i.message })),
375
+ ...(scripts.some((s) => !s.sizeKnown)
376
+ ? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); total size is a lower bound` }]
377
+ : []),
378
+ ],
331
379
  };
332
380
  })();
@@ -0,0 +1,126 @@
1
+ // Server-Timing & Early Hints Viewer
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const SCRIPT = "Server-Timing-Early-Hints";
6
+ const MAX_ITEMS = 50;
7
+
8
+ const [nav] = performance.getEntriesByType("navigation");
9
+ if (!nav) {
10
+ return { script: SCRIPT, status: "error", error: "No navigation entry available" };
11
+ }
12
+ if (!Array.isArray(nav.serverTiming)) {
13
+ return {
14
+ script: SCRIPT,
15
+ status: "unsupported",
16
+ error: "Server-Timing entries are not supported in this browser",
17
+ };
18
+ }
19
+
20
+ const round = (n) => Math.round(n * 100) / 100;
21
+ // Times are relative to the time origin; a prerendered page starts at activationStart
22
+ const activationStart = Math.max(0, nav.activationStart ?? 0);
23
+ const rel = (t) => (t > 0 ? round(Math.max(0, t - activationStart)) : 0);
24
+
25
+ const toItem = (entry, source, url) => ({
26
+ source,
27
+ url,
28
+ name: entry.name,
29
+ durationMs: round(entry.duration || 0),
30
+ description: entry.description || "",
31
+ });
32
+
33
+ // Server-Timing entries of the document
34
+ const all = nav.serverTiming.map((m) => toItem(m, "navigation", location.href));
35
+
36
+ // Server-Timing entries of the resources. A cross-origin response without
37
+ // Timing-Allow-Origin exposes no Server-Timing and reports zero for every size, so it is
38
+ // counted as unreadable instead of being treated as a resource with no metrics.
39
+ let resourcesWithServerTiming = 0;
40
+ let corsRestrictedCount = 0;
41
+ for (const r of performance.getEntriesByType("resource")) {
42
+ if (r.serverTiming.length > 0) {
43
+ resourcesWithServerTiming++;
44
+ for (const m of r.serverTiming) all.push(toItem(m, "resource", r.name));
45
+ continue;
46
+ }
47
+ let crossOrigin = false;
48
+ try {
49
+ crossOrigin = new URL(r.name).origin !== location.origin;
50
+ } catch {
51
+ // Not a valid URL; treat as same-origin
52
+ }
53
+ if (crossOrigin && r.transferSize === 0 && r.encodedBodySize === 0 && r.decodedBodySize === 0) {
54
+ corsRestrictedCount++;
55
+ }
56
+ }
57
+
58
+ all.sort((a, b) => b.durationMs - a.durationMs);
59
+ const items = all.slice(0, MAX_ITEMS);
60
+
61
+ // 103 Early Hints. firstInterimResponseStart is 0 when the server sent no interim
62
+ // response, or when the browser did not expose it (Early Hints needs HTTP/2 or later).
63
+ const firstInterim = rel(nav.firstInterimResponseStart ?? 0);
64
+ const finalHeaders = rel(nav.finalResponseHeadersStart ?? nav.responseStart);
65
+ const earlyHintsReceived = firstInterim > 0;
66
+ const navigation = {
67
+ responseStartMs: rel(nav.responseStart),
68
+ firstInterimResponseStartMs: firstInterim,
69
+ finalResponseHeadersStartMs: finalHeaders,
70
+ earlyHintsReceived,
71
+ earlyHintsLeadMs: earlyHintsReceived ? round(Math.max(0, finalHeaders - firstInterim)) : 0,
72
+ serverTimingCount: nav.serverTiming.length,
73
+ };
74
+
75
+ const issues = [];
76
+ if (nav.serverTiming.length === 0) {
77
+ issues.push({
78
+ severity: "info",
79
+ message:
80
+ "The document response sends no Server-Timing header. Add one, for example Server-Timing: db;dur=53, to expose backend timings in DevTools and in the Performance API.",
81
+ });
82
+ }
83
+ if (corsRestrictedCount > 0) {
84
+ issues.push({
85
+ severity: "info",
86
+ message: `${corsRestrictedCount} cross-origin resource(s) hide their timing because they lack Timing-Allow-Origin, so their Server-Timing cannot be read. Add Timing-Allow-Origin to expose it.`,
87
+ });
88
+ }
89
+ if (!earlyHintsReceived) {
90
+ issues.push({
91
+ severity: "info",
92
+ message:
93
+ "No 103 Early Hints response was observed for the document. If the server can send Link preload or preconnect headers before the final response, Early Hints lets the browser start those fetches earlier.",
94
+ });
95
+ }
96
+
97
+ // Human output
98
+ console.group("%cServer-Timing & Early Hints", "font-weight: bold; font-size: 14px;");
99
+ console.log(
100
+ `Document: ${nav.serverTiming.length} Server-Timing metric(s); resources with Server-Timing: ${resourcesWithServerTiming}`
101
+ );
102
+ console.log(
103
+ earlyHintsReceived
104
+ ? `103 Early Hints at ${firstInterim} ms, final headers at ${finalHeaders} ms (${navigation.earlyHintsLeadMs} ms lead)`
105
+ : `No 103 Early Hints observed; final headers at ${finalHeaders} ms`
106
+ );
107
+ if (corsRestrictedCount > 0) {
108
+ console.log(`${corsRestrictedCount} cross-origin resource(s) unreadable (no Timing-Allow-Origin)`);
109
+ }
110
+ if (items.length > 0) console.table(items);
111
+ console.groupEnd();
112
+
113
+ return {
114
+ script: SCRIPT,
115
+ status: "ok",
116
+ count: all.length,
117
+ corsLimitedAnalysis: corsRestrictedCount > 0,
118
+ details: {
119
+ navigation,
120
+ resourcesWithServerTiming,
121
+ corsRestrictedCount,
122
+ },
123
+ items,
124
+ issues,
125
+ };
126
+ })();