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
@@ -18,6 +18,12 @@
18
18
  new PerformanceObserver((list) => {
19
19
  const [entry] = list.getEntries();
20
20
 
21
+ // loadEventEnd is 0 until the load event finishes: the phases would be meaningless
22
+ if (!entry.loadEventEnd) {
23
+ console.log("%c⏳ The page is still loading. Run this snippet again after the load event.", "color: #f59e0b;");
24
+ return;
25
+ }
26
+
21
27
  const formatMs = (ms) => ms.toFixed(2) + " ms";
22
28
  const formatBar = (ms, total) => {
23
29
  const pct = total > 0 ? (ms / total) * 100 : 0;
@@ -104,7 +110,7 @@
104
110
  // Key milestones
105
111
  console.log("");
106
112
  console.log("%cKey Milestones:", "font-weight: bold;");
107
- console.log(` TTFB (Time to First Byte): ${entry.responseStart.toFixed(0)}ms`);
113
+ console.log(` TTFB (Time to First Byte): ${Math.max(0, entry.responseStart - (entry.activationStart || 0)).toFixed(0)}ms`);
108
114
  console.log(` DOMContentLoaded: ${entry.domContentLoadedEventEnd.toFixed(0)}ms`);
109
115
  console.log(` Load Complete: ${entry.loadEventEnd.toFixed(0)}ms`);
110
116
 
@@ -142,6 +148,13 @@
142
148
  // Synchronous return for agent
143
149
  const [navSync] = performance.getEntriesByType("navigation");
144
150
  if (!navSync) return { script: "Event-Processing-Time", status: "error", error: "No navigation entry" };
151
+ if (!navSync.loadEventEnd) {
152
+ return {
153
+ script: "Event-Processing-Time",
154
+ status: "tracking",
155
+ message: "The page is still loading. Run this snippet again after the load event.",
156
+ };
157
+ }
145
158
  const totalTimeSync = navSync.loadEventEnd - navSync.startTime;
146
159
  const networkTimeSync = (navSync.responseEnd || 0) - navSync.startTime;
147
160
  const ratingSync = totalTimeSync <= 2500 ? "good" : totalTimeSync <= 4000 ? "needs-improvement" : "poor";
@@ -169,7 +182,7 @@
169
182
  details: {
170
183
  networkTimeMs: Math.round(networkTimeSync),
171
184
  processingTimeMs: Math.round(totalTimeSync - networkTimeSync),
172
- ttfbMs: Math.round(navSync.responseStart),
185
+ ttfbMs: Math.round(Math.max(0, navSync.responseStart - (navSync.activationStart || 0))),
173
186
  domContentLoadedMs: Math.round(navSync.domContentLoadedEventEnd),
174
187
  loadCompleteMs: Math.round(navSync.loadEventEnd),
175
188
  phases: Object.fromEntries(phasesSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
@@ -18,12 +18,14 @@
18
18
  const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
19
19
  if (!fcpEntry) return;
20
20
 
21
- const fcpTime = fcpEntry.startTime;
21
+ const navEntry = performance.getEntriesByType("navigation")[0];
22
+ // Prerendered pages: measure from activation, not from the start of the prerender
23
+ const activationStart = navEntry?.activationStart || 0;
24
+ const fcpTime = Math.max(0, fcpEntry.startTime - activationStart);
22
25
  const rating = valueToRating(fcpTime);
23
26
  const { icon, color } = RATING[rating];
24
27
 
25
- const navEntry = performance.getEntriesByType("navigation")[0];
26
- const ttfb = navEntry?.responseStart ?? 0;
28
+ const ttfb = Math.max(0, (navEntry?.responseStart ?? 0) - activationStart);
27
29
 
28
30
  // Render-blocking resources (Chrome 107+, requires renderBlockingStatus API)
29
31
  const resources = performance.getEntriesByType("resource");
@@ -32,7 +34,7 @@
32
34
  );
33
35
 
34
36
  const lastBlockingEnd = blockingResources.length
35
- ? Math.max(...blockingResources.map((r) => r.responseEnd))
37
+ ? Math.max(...blockingResources.map((r) => r.responseEnd)) - activationStart
36
38
  : ttfb;
37
39
 
38
40
  console.group(
@@ -87,8 +89,25 @@
87
89
  // Synchronous return for agent
88
90
  const fcpEntrySync = performance.getEntriesByName("first-contentful-paint")[0];
89
91
  if (!fcpEntrySync) return { script: "FCP", status: "error", error: "No FCP entry yet" };
90
- const fcpTimeSync = fcpEntrySync.startTime;
92
+ const navEntrySync = performance.getEntriesByType("navigation")[0];
93
+ const activationStartSync = navEntrySync?.activationStart || 0;
94
+ const fcpTimeSync = Math.max(0, fcpEntrySync.startTime - activationStartSync);
91
95
  const ratingSync = valueToRating(fcpTimeSync);
96
+
97
+ // Phases, measured from activation like the value: TTFB, then the wait for the last
98
+ // render-blocking resource, then the rest until the paint
99
+ const ttfbSync = Math.max(0, (navEntrySync?.responseStart ?? 0) - activationStartSync);
100
+ const blockingSync = performance
101
+ .getEntriesByType("resource")
102
+ .filter((r) => r.renderBlockingStatus === "blocking");
103
+ const lastBlockingEndSync = Math.max(
104
+ ttfbSync,
105
+ ...blockingSync.map((r) => r.responseEnd - activationStartSync)
106
+ );
107
+ const ttfbMs = Math.round(ttfbSync);
108
+ const renderBlockingLoadMs = Math.round(lastBlockingEndSync - ttfbSync);
109
+ const renderDelayMs = Math.max(0, Math.round(fcpTimeSync) - ttfbMs - renderBlockingLoadMs);
110
+
92
111
  return {
93
112
  script: "FCP",
94
113
  status: "ok",
@@ -97,5 +116,20 @@
97
116
  unit: "ms",
98
117
  rating: ratingSync,
99
118
  thresholds: { good: 1800, needsImprovement: 3000 },
119
+ details: {
120
+ ttfbMs,
121
+ renderBlockingLoadMs,
122
+ renderDelayMs,
123
+ blockingResourceCount: blockingSync.length,
124
+ },
125
+ // Render-blocking resources, the last to finish first (at most 50)
126
+ items: [...blockingSync]
127
+ .sort((a, b) => b.responseEnd - a.responseEnd)
128
+ .slice(0, 50)
129
+ .map((r) => ({
130
+ url: r.name.split("/").pop().split("?")[0] || r.name,
131
+ type: r.initiatorType === "link" ? "CSS" : "JS",
132
+ durationMs: Math.round(r.duration),
133
+ })),
100
134
  };
101
135
  })();
@@ -246,7 +246,7 @@
246
246
  selector: image.selector,
247
247
  lazyType: image.lazyType,
248
248
  dimensions: image.dimensions,
249
- position: image.position,
249
+ position: `${image.position.top}px, ${image.position.left}px`,
250
250
  distanceFromEdge: image.distanceFromEdge,
251
251
  src: image.src,
252
252
  srcset: image.srcset,
@@ -78,13 +78,15 @@
78
78
  return 0;
79
79
  }
80
80
 
81
+ // @shared formatBytes
81
82
  function formatBytes(bytes) {
82
- if (bytes === 0) return "0 Bytes";
83
- const k = 1024;
84
- const sizes = ["Bytes", "KB", "MB", "GB"];
85
- const i = Math.floor(Math.log(bytes) / Math.log(k));
86
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
83
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
84
+ if (bytes === 0) return "0 B";
85
+ const units = ["B", "KB", "MB", "GB"];
86
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
87
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
87
88
  }
89
+ // @end-shared formatBytes
88
90
 
89
91
  // Find LCP candidate (largest visible image) to exclude from recommendations
90
92
  const allViewportImages = Array.from(document.querySelectorAll("img")).filter(
@@ -2,34 +2,55 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (() => {
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
+
5
9
  // Auto-detect first-party: same root domain (handles subdomains)
10
+ // @shared getRootDomain
6
11
  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;
12
+ const host = hostname.replace(/\.$/, "");
13
+ // An IP address has no registrable domain, so each address is its own root
14
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
15
+ const parts = host.split(".");
16
+ if (parts.length <= 2) return host;
17
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
18
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
19
+ const tld = parts[parts.length - 1];
20
+ const sld = parts[parts.length - 2];
21
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
22
+ return parts.slice(-2).join(".");
18
23
  }
24
+ // @end-shared getRootDomain
19
25
 
20
- const currentRootDomain = getRootDomain(location.hostname);
21
-
26
+ // @shared isFirstParty
22
27
  function isFirstParty(hostname) {
23
- return getRootDomain(hostname) === currentRootDomain;
28
+ const root = getRootDomain(hostname);
29
+ if (root === getRootDomain(location.hostname)) return true;
30
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
31
+ }
32
+ // @end-shared isFirstParty
33
+
34
+ // @shared logOwnDomainsHint
35
+ function logOwnDomainsHint(thirdPartyCount) {
36
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
37
+ console.log(
38
+ '%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.',
39
+ "color: #3b82f6;"
40
+ );
41
+ }
24
42
  }
43
+ // @end-shared logOwnDomainsHint
25
44
 
45
+ // @shared formatBytes
26
46
  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];
47
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
48
+ if (bytes === 0) return "0 B";
49
+ const units = ["B", "KB", "MB", "GB"];
50
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
51
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
32
52
  }
53
+ // @end-shared formatBytes
33
54
 
34
55
  // Gather script data
35
56
  const scripts = performance
@@ -46,6 +67,8 @@
46
67
  firstParty,
47
68
  duration: r.duration,
48
69
  transferSize: r.transferSize || 0,
70
+ // Cross-origin scripts without Timing-Allow-Origin report every size as 0
71
+ sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
49
72
  startTime: r.startTime,
50
73
  responseEnd: r.responseEnd,
51
74
  renderBlocking: r.renderBlockingStatus === "blocking",
@@ -216,12 +239,22 @@
216
239
  console.groupEnd();
217
240
  }
218
241
 
242
+ logOwnDomainsHint(thirdParty.length);
219
243
  console.groupEnd();
220
244
 
245
+ // 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
246
+ const MAX_ITEMS = 50;
247
+ const rankedScripts = [...scripts].sort(
248
+ (a, b) =>
249
+ Number(b.renderBlocking) - Number(a.renderBlocking) ||
250
+ Number(a.firstParty) - Number(b.firstParty) ||
251
+ b.transferSize - a.transferSize
252
+ );
221
253
  return {
222
254
  script: "First-And-Third-Party-Script-Info",
223
255
  status: "ok",
224
256
  count: totalScripts,
257
+ corsLimitedAnalysis: scripts.some((s) => !s.sizeKnown),
225
258
  details: {
226
259
  firstPartyCount: firstParty.length,
227
260
  thirdPartyCount: thirdParty.length,
@@ -230,16 +263,21 @@
230
263
  thirdPartySizeBytes: thirdMetrics.totalSize,
231
264
  thirdPartyBlockingCount: thirdMetrics.blocking,
232
265
  thirdPartyHostCount: thirdMetrics.hosts.length,
266
+ sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
233
267
  },
234
- items: scripts.map((s) => ({
268
+ items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
235
269
  shortName: s.shortName,
236
270
  host: s.host,
237
271
  firstParty: s.firstParty,
238
272
  transferBytes: s.transferSize,
273
+ sizeKnown: s.sizeKnown,
239
274
  durationMs: Math.round(s.duration),
240
275
  renderBlocking: s.renderBlocking,
241
276
  })),
242
277
  issues: [
278
+ ...(scripts.some((s) => !s.sizeKnown)
279
+ ? [{ 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` }]
280
+ : []),
243
281
  ...(thirdMetrics.blocking > 0
244
282
  ? [
245
283
  {
@@ -2,24 +2,45 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (() => {
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
+
5
9
  // Auto-detect first-party by root domain
10
+ // @shared getRootDomain
6
11
  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;
12
+ const host = hostname.replace(/\.$/, "");
13
+ // An IP address has no registrable domain, so each address is its own root
14
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
15
+ const parts = host.split(".");
16
+ if (parts.length <= 2) return host;
17
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
18
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
19
+ const tld = parts[parts.length - 1];
20
+ const sld = parts[parts.length - 2];
21
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
22
+ return parts.slice(-2).join(".");
16
23
  }
24
+ // @end-shared getRootDomain
17
25
 
18
- const currentRootDomain = getRootDomain(location.hostname);
19
-
26
+ // @shared isFirstParty
20
27
  function isFirstParty(hostname) {
21
- return getRootDomain(hostname) === currentRootDomain;
28
+ const root = getRootDomain(hostname);
29
+ if (root === getRootDomain(location.hostname)) return true;
30
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
22
31
  }
32
+ // @end-shared isFirstParty
33
+
34
+ // @shared logOwnDomainsHint
35
+ function logOwnDomainsHint(thirdPartyCount) {
36
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
37
+ console.log(
38
+ '%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.',
39
+ "color: #3b82f6;"
40
+ );
41
+ }
42
+ }
43
+ // @end-shared logOwnDomainsHint
23
44
 
24
45
  // Gather script timing data
25
46
  const scripts = performance
@@ -47,6 +68,12 @@
47
68
  timings.tcp = r.secureConnectionStart - r.connectStart;
48
69
  }
49
70
 
71
+ // Without Timing-Allow-Origin the browser reports the phases as 0, so response would be
72
+ // the whole duration since the epoch. Only the total is real, the phases are unknown.
73
+ if (!hasTiming) {
74
+ for (const phase of ["dns", "tcp", "tls", "request", "response"]) timings[phase] = null;
75
+ }
76
+
50
77
  return {
51
78
  name: r.name,
52
79
  shortName: url.pathname.split("/").pop() || url.hostname,
@@ -92,6 +119,7 @@
92
119
  const thirdStats = calcStats(thirdParty);
93
120
 
94
121
  // Format helpers
122
+ const roundOrNull = (ms) => (ms === null ? null : Math.round(ms));
95
123
  const formatMs = (ms) => (ms > 0 ? ms.toFixed(1) + "ms" : "-");
96
124
  const formatBar = (value, max) => {
97
125
  if (value <= 0 || max <= 0) return "";
@@ -270,12 +298,17 @@
270
298
  console.groupEnd();
271
299
  }
272
300
 
301
+ logOwnDomainsHint(thirdParty.length);
273
302
  console.groupEnd();
274
303
 
304
+ // The list keeps the 50 slowest scripts; count and details cover all of them
305
+ const MAX_ITEMS = 50;
306
+ const slowestScripts = [...scripts].sort((a, b) => b.total - a.total);
275
307
  return {
276
308
  script: "First-And-Third-Party-Script-Timings",
277
309
  status: "ok",
278
310
  count: scripts.length,
311
+ corsLimitedAnalysis: thirdStats.withoutTiming > 0,
279
312
  details: {
280
313
  firstPartyCount: firstParty.length,
281
314
  thirdPartyCount: thirdParty.length,
@@ -284,7 +317,7 @@
284
317
  firstPartyAvgTotalMs: Math.round(firstStats.stats.total?.avg || 0),
285
318
  thirdPartyAvgTotalMs: Math.round(thirdStats.stats.total?.avg || 0),
286
319
  },
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 })),
320
+ items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: roundOrNull(s.dns), tcpMs: roundOrNull(s.tcp), requestMs: roundOrNull(s.request), responseMs: roundOrNull(s.response), hasTiming: s.hasTiming })),
288
321
  issues: [
289
322
  ...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
290
323
  ...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
@@ -2,6 +2,10 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (() => {
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
+
5
9
  // Helper to extract font filename from URL
6
10
  function getFontName(url) {
7
11
  try {
@@ -12,10 +16,45 @@
12
16
  }
13
17
  }
14
18
 
15
- // Check if URL is third-party
19
+ // @shared getRootDomain
20
+ function getRootDomain(hostname) {
21
+ const host = hostname.replace(/\.$/, "");
22
+ // An IP address has no registrable domain, so each address is its own root
23
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
24
+ const parts = host.split(".");
25
+ if (parts.length <= 2) return host;
26
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
27
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
28
+ const tld = parts[parts.length - 1];
29
+ const sld = parts[parts.length - 2];
30
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
31
+ return parts.slice(-2).join(".");
32
+ }
33
+ // @end-shared getRootDomain
34
+
35
+ // @shared isFirstParty
36
+ function isFirstParty(hostname) {
37
+ const root = getRootDomain(hostname);
38
+ if (root === getRootDomain(location.hostname)) return true;
39
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
40
+ }
41
+ // @end-shared isFirstParty
42
+
43
+ // @shared logOwnDomainsHint
44
+ function logOwnDomainsHint(thirdPartyCount) {
45
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
46
+ console.log(
47
+ '%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.',
48
+ "color: #3b82f6;"
49
+ );
50
+ }
51
+ }
52
+ // @end-shared logOwnDomainsHint
53
+
54
+ // Check if URL is third-party (a different root domain)
16
55
  function isThirdParty(url) {
17
56
  try {
18
- return new URL(url).hostname !== location.hostname;
57
+ return !isFirstParty(new URL(url).hostname);
19
58
  } catch {
20
59
  return false;
21
60
  }
@@ -291,6 +330,7 @@
291
330
  console.log("4. Self-host fonts when possible for better control");
292
331
  console.log("5. Subset fonts to include only needed characters");
293
332
  console.log("6. Use WOFF2 format for best compression");
333
+ logOwnDomainsHint(preloadedFonts.filter((f) => f.thirdParty).length);
294
334
  console.groupEnd();
295
335
 
296
336
  console.groupEnd();
@@ -305,9 +345,18 @@
305
345
  usedAboveFoldCount: usedFonts.length,
306
346
  preloadedNotUsedCount: preloadedNotUsed.length,
307
347
  usedNotPreloadedCount: usedNotPreloaded.length,
348
+ // The preload links themselves (at most 20)
349
+ preloadedFonts: preloadedFonts.slice(0, 20).map(f => ({
350
+ family: f.name.replace(/\.(woff2?|ttf|otf|eot)$/i, ""),
351
+ href: f.href,
352
+ fontType: f.type,
353
+ crossorigin: f.crossorigin || "",
354
+ thirdParty: f.thirdParty,
355
+ })),
308
356
  },
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 })),
357
+ // The Visualizer reads items (loaded fonts) and usedFonts
358
+ items: uniqueLoadedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
359
+ usedFonts: usedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
311
360
  issues: [
312
361
  ...preloadedNotUsed.map(f => ({ severity: "warning", message: `Preloaded but not used above fold: ${f.name}` })),
313
362
  ...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
  })();