webperf-snippets 0.2.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 (65) hide show
  1. package/README.md +120 -5
  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/First-And-Third-Party-Script-Info.js +40 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -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 +15 -0
  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 +55 -25
  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 +59 -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 +216 -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 +22 -3
  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 +1176 -0
  59. package/snippets/_shared/helpers.js +44 -0
  60. package/src/bin.js +55 -3
  61. package/src/crux.js +84 -0
  62. package/src/report-to.js +46 -0
  63. package/src/reporters/human.js +20 -1
  64. package/src/reporters/markdown.js +22 -1
  65. package/src/runner.js +4 -2
@@ -0,0 +1,134 @@
1
+ // Prerender & Speculation Rules Inspector
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const MAX_ITEMS = 50;
6
+ // Chrome limits eager prerenders per page; more than this is wasted or dropped
7
+ const EAGER_PRERENDER_LIMIT = 10;
8
+
9
+ // Activation of a prerendered page is only observable while the snippet listens
10
+ const state = { activated: !document.prerendering, activatedAt: null };
11
+ if (document.prerendering) {
12
+ document.addEventListener(
13
+ 'prerenderingchange',
14
+ () => {
15
+ state.activated = true;
16
+ state.activatedAt = Math.round(performance.now());
17
+ console.log('%cPrerendered page activated', 'color: #22c55e; font-weight: bold;');
18
+ },
19
+ { once: true }
20
+ );
21
+ }
22
+
23
+ const inspect = () => {
24
+ const issues = [];
25
+ const items = [];
26
+ const actions = {};
27
+ const supported = typeof window.HTMLScriptElement.supports === 'function'
28
+ && window.HTMLScriptElement.supports('speculationrules');
29
+
30
+ const [nav] = performance.getEntriesByType('navigation');
31
+ const activationStart = Math.round(nav?.activationStart ?? 0);
32
+ const deliveryType = nav?.deliveryType ?? '';
33
+
34
+ const scripts = [...document.querySelectorAll('script[type="speculationrules"]')];
35
+
36
+ scripts.forEach((script, index) => {
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(script.textContent);
40
+ } catch (e) {
41
+ issues.push({
42
+ severity: 'error',
43
+ message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.`,
44
+ });
45
+ return;
46
+ }
47
+
48
+ for (const [action, rules] of Object.entries(parsed ?? {})) {
49
+ if (!Array.isArray(rules)) continue;
50
+ actions[action] = (actions[action] ?? 0) + rules.length;
51
+
52
+ for (const rule of rules) {
53
+ const source = rule.source ?? (rule.where ? 'document' : 'list');
54
+ const urls = Array.isArray(rule.urls) ? rule.urls : [];
55
+ const eagerness = rule.eagerness ?? (source === 'document' ? 'conservative' : 'immediate');
56
+ items.push({
57
+ action,
58
+ source,
59
+ eagerness,
60
+ urls: urls.slice(0, 20),
61
+ urlCount: urls.length,
62
+ where: rule.where ?? null,
63
+ requires: rule.requires ?? [],
64
+ referrerPolicy: rule.referrer_policy ?? null,
65
+ scriptIndex: index,
66
+ });
67
+
68
+ const isPrerender = action === 'prerender' || action === 'prerender_until_script';
69
+ if (isPrerender && source === 'list' && (eagerness === 'immediate' || eagerness === 'eager') && urls.length > EAGER_PRERENDER_LIMIT) {
70
+ issues.push({
71
+ severity: 'warning',
72
+ message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`,
73
+ });
74
+ }
75
+ if (isPrerender && source === 'document' && (eagerness === 'immediate' || eagerness === 'eager')) {
76
+ issues.push({
77
+ severity: 'warning',
78
+ message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ });
84
+
85
+ if (scripts.length === 0) {
86
+ issues.push({ severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' });
87
+ } else if (items.length === 0 && !issues.some((i) => i.severity === 'error')) {
88
+ issues.push({ severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' });
89
+ }
90
+ if (!supported && scripts.length > 0) {
91
+ issues.push({ severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' });
92
+ }
93
+ if (activationStart > 0) {
94
+ issues.push({ severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` });
95
+ }
96
+
97
+ return {
98
+ script: 'Speculation-Rules-Inspector',
99
+ status: 'ok',
100
+ count: items.length,
101
+ details: {
102
+ supported,
103
+ prerendering: document.prerendering === true,
104
+ activated: state.activated,
105
+ activatedAt: state.activatedAt,
106
+ activationStart,
107
+ deliveryType,
108
+ scriptCount: scripts.length,
109
+ actions,
110
+ },
111
+ items: items.slice(0, MAX_ITEMS),
112
+ issues,
113
+ };
114
+ };
115
+
116
+ window.getSpeculationRulesInspection = inspect;
117
+ const result = inspect();
118
+
119
+ console.group('Speculation rules');
120
+ console.log(`Prerendering: ${result.details.prerendering}, activationStart: ${result.details.activationStart}ms, deliveryType: "${result.details.deliveryType}"`);
121
+ if (result.items.length > 0) console.table(result.items.map(({ action, source, eagerness, urlCount }) => ({ action, source, eagerness, urlCount })));
122
+ result.issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
123
+ console.groupEnd();
124
+
125
+ if (document.prerendering) {
126
+ return {
127
+ script: 'Speculation-Rules-Inspector',
128
+ status: 'tracking',
129
+ message: 'This page is prerendering. Call getSpeculationRulesInspection() after activation to see the final timings.',
130
+ getDataFn: 'getSpeculationRulesInspection',
131
+ };
132
+ }
133
+ return result;
134
+ })();
@@ -2,17 +2,52 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (() => {
5
+ // Waiting time for the first byte of a resource: responseStart is measured from the
6
+ // start of the page, so subtract requestStart. Cross-origin resources without a
7
+ // Timing-Allow-Origin header report 0 for both and cannot be measured.
8
+ const waitTime = (entry) => entry.responseStart - entry.requestStart;
9
+ const hasTiming = (entry) => entry.responseStart > 0;
10
+ // @shared getRootDomain
11
+ function getRootDomain(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(".");
23
+ }
24
+ // @end-shared getRootDomain
25
+
26
+ // @shared isFirstParty
27
+ function isFirstParty(hostname) {
28
+ return getRootDomain(hostname) === getRootDomain(location.hostname);
29
+ }
30
+ // @end-shared isFirstParty
31
+
32
+ const isThirdPartyUrl = (name) => {
33
+ try {
34
+ return !isFirstParty(new URL(name).hostname);
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+
5
40
  new PerformanceObserver((entryList) => {
6
41
  const entries = entryList.getEntries();
42
+ const restrictedCount = entries.filter((entry) => !hasTiming(entry)).length;
7
43
 
8
44
  const resourcesData = entries
9
- .filter((entry) => entry.responseStart > 0)
45
+ .filter(hasTiming)
10
46
  .map((entry) => {
11
- const url = new URL(entry.name);
12
- const isThirdParty = url.hostname !== location.hostname;
47
+ const isThirdParty = isThirdPartyUrl(entry.name);
13
48
 
14
49
  return {
15
- ttfb: entry.responseStart,
50
+ ttfb: waitTime(entry),
16
51
  duration: entry.duration,
17
52
  type: entry.initiatorType,
18
53
  thirdParty: isThirdParty,
@@ -44,6 +79,9 @@
44
79
  console.log(` Average TTFB: ${avgTtfb.toFixed(0)}ms`);
45
80
  console.log(` Fastest: ${minTtfb.toFixed(0)}ms | Slowest: ${maxTtfb.toFixed(0)}ms`);
46
81
  console.log(` Third-party resources: ${thirdPartyCount}`);
82
+ if (restrictedCount > 0) {
83
+ console.log(`%c ⚠️ ${restrictedCount} resource(s) not measured: missing Timing-Allow-Origin header`, "color: #f59e0b;");
84
+ }
47
85
  if (slowResources > 0) {
48
86
  console.log(`%c ⚠️ Slow resources (>500ms): ${slowResources}`, "color: #f59e0b;");
49
87
  }
@@ -82,34 +120,37 @@
82
120
  });
83
121
 
84
122
  // Synchronous return for agent
85
- const resourcesSync = performance.getEntriesByType("resource")
86
- .filter((entry) => entry.responseStart > 0)
87
- .map((entry) => {
88
- const url = new URL(entry.name);
89
- return {
90
- url: entry.name,
91
- ttfbMs: Math.round(entry.responseStart),
92
- durationMs: Math.round(entry.duration),
93
- type: entry.initiatorType,
94
- isThirdParty: url.hostname !== location.hostname,
95
- };
96
- })
123
+ const allResources = performance.getEntriesByType("resource");
124
+ const corsRestrictedCount = allResources.filter((entry) => !hasTiming(entry)).length;
125
+ const resourcesSync = allResources
126
+ .filter(hasTiming)
127
+ .map((entry) => ({
128
+ url: entry.name,
129
+ ttfbMs: Math.round(waitTime(entry)),
130
+ durationMs: Math.round(entry.duration),
131
+ type: entry.initiatorType,
132
+ isThirdParty: isThirdPartyUrl(entry.name),
133
+ }))
97
134
  .sort((a, b) => b.ttfbMs - a.ttfbMs);
98
135
  if (resourcesSync.length === 0) {
99
- return { script: "TTFB-Resources", status: "error", error: "No resources with TTFB data available" };
136
+ return { script: "TTFB-Resources", status: "error", error: "No resources with TTFB data available", corsLimitedAnalysis: corsRestrictedCount > 0, details: { corsRestrictedCount } };
100
137
  }
101
138
  const ttfbVals = resourcesSync.map((r) => r.ttfbMs);
139
+ // The list keeps the 50 slowest resources (already sorted); count and details cover all of them
140
+ const MAX_ITEMS = 50;
102
141
  return {
103
142
  script: "TTFB-Resources",
104
143
  status: "ok",
105
144
  count: resourcesSync.length,
145
+ corsLimitedAnalysis: corsRestrictedCount > 0,
106
146
  details: {
107
147
  avgTtfbMs: Math.round(ttfbVals.reduce((a, b) => a + b, 0) / ttfbVals.length),
108
148
  maxTtfbMs: Math.max(...ttfbVals),
109
149
  minTtfbMs: Math.min(...ttfbVals),
110
150
  thirdPartyCount: resourcesSync.filter((r) => r.isThirdParty).length,
111
151
  slowCount: resourcesSync.filter((r) => r.ttfbMs > 500).length,
152
+ corsRestrictedCount,
112
153
  },
113
- items: resourcesSync,
154
+ items: resourcesSync.slice(0, MAX_ITEMS),
114
155
  };
115
156
  })();
@@ -9,8 +9,12 @@
9
9
  const waitEnd = Math.max((pageNav.workerStart || pageNav.fetchStart) - activationStart, 0);
10
10
  const dnsStart = Math.max(pageNav.domainLookupStart - activationStart, 0);
11
11
  const tcpStart = Math.max(pageNav.connectStart - activationStart, 0);
12
- const sslStart = Math.max(pageNav.secureConnectionStart - activationStart, 0);
13
12
  const tcpEnd = Math.max(pageNav.connectEnd - activationStart, 0);
13
+ // secureConnectionStart is 0 on plain HTTP and reused connections: no TLS phase
14
+ const sslStart =
15
+ pageNav.secureConnectionStart > 0
16
+ ? Math.max(pageNav.secureConnectionStart - activationStart, 0)
17
+ : tcpEnd;
14
18
  const responseStart = Math.max(pageNav.responseStart - activationStart, 0);
15
19
 
16
20
  const formatMs = (ms) => ms.toFixed(2) + " ms";
@@ -90,8 +94,11 @@
90
94
  const waitEndSync = Math.max((navSync.workerStart || navSync.fetchStart) - activStartSync, 0);
91
95
  const dnsStartSync = Math.max(navSync.domainLookupStart - activStartSync, 0);
92
96
  const tcpStartSync = Math.max(navSync.connectStart - activStartSync, 0);
93
- const sslStartSync = Math.max(navSync.secureConnectionStart - activStartSync, 0);
94
97
  const tcpEndSync = Math.max(navSync.connectEnd - activStartSync, 0);
98
+ const sslStartSync =
99
+ navSync.secureConnectionStart > 0
100
+ ? Math.max(navSync.secureConnectionStart - activStartSync, 0)
101
+ : tcpEndSync;
95
102
  const subPartsSync = [
96
103
  { key: "redirectWait", duration: waitEndSync },
97
104
  { key: "serviceWorkerCache", duration: dnsStartSync - waitEndSync },
@@ -4,7 +4,8 @@
4
4
  (() => {
5
5
  new PerformanceObserver((entryList) => {
6
6
  const [pageNav] = entryList.getEntriesByType("navigation");
7
- const ttfb = pageNav.responseStart;
7
+ // Prerendered pages: measure from activation, not from the start of the prerender
8
+ const ttfb = Math.max(0, pageNav.responseStart - (pageNav.activationStart || 0));
8
9
 
9
10
  let rating, color;
10
11
  if (ttfb <= 800) {
@@ -30,7 +31,7 @@
30
31
  // Synchronous return for agent
31
32
  const [nav] = performance.getEntriesByType("navigation");
32
33
  if (!nav) return { script: "TTFB", status: "error", error: "No navigation entry" };
33
- const value = Math.round(nav.responseStart);
34
+ const value = Math.round(Math.max(0, nav.responseStart - (nav.activationStart || 0)));
34
35
  const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
35
36
  return { script: "TTFB", status: "ok", metric: "TTFB", value, unit: "ms", rating,
36
37
  thresholds: { good: 800, needsImprovement: 1800 } };
@@ -0,0 +1,216 @@
1
+ // Third-Party Impact by Domain
2
+ // Groups third-party requests by root domain and reports requests, transfer size, render-blocking and main-thread time
3
+ // https://webperf-snippets.nucliweb.net
4
+
5
+ (() => {
6
+ const MAX_ITEMS = 50;
7
+ const LOAF_WARNING_MS = 250; // Script time in long animation frames above which one domain is a problem
8
+ const MAX_LISTED_DOMAINS = 5;
9
+
10
+ // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
11
+ // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
12
+ const OWN_DOMAINS = [];
13
+
14
+ // @shared formatBytes
15
+ function formatBytes(bytes) {
16
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
17
+ if (bytes === 0) return "0 B";
18
+ const units = ["B", "KB", "MB", "GB"];
19
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
20
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
21
+ }
22
+ // @end-shared formatBytes
23
+
24
+ // A size that misses some resources is a lower bound, and one that misses all of them is unknown
25
+ const sizeLabel = (bytes, unknown, total) => {
26
+ if (total > 0 && unknown === total) return "unknown";
27
+ return (unknown > 0 ? "≥ " : "") + formatBytes(bytes);
28
+ };
29
+
30
+ // @shared getRootDomain
31
+ function getRootDomain(hostname) {
32
+ const host = hostname.replace(/\.$/, "");
33
+ // An IP address has no registrable domain, so each address is its own root
34
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
35
+ const parts = host.split(".");
36
+ if (parts.length <= 2) return host;
37
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
38
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
39
+ const tld = parts[parts.length - 1];
40
+ const sld = parts[parts.length - 2];
41
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
42
+ return parts.slice(-2).join(".");
43
+ }
44
+ // @end-shared getRootDomain
45
+
46
+ // @shared isFirstParty
47
+ function isFirstParty(hostname) {
48
+ return getRootDomain(hostname) === getRootDomain(location.hostname);
49
+ }
50
+ // @end-shared isFirstParty
51
+
52
+ const ownRoots = new Set(
53
+ OWN_DOMAINS.map((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]))
54
+ );
55
+
56
+ // Root domain of an http(s) URL, or null for anything else (data:, blob:, empty)
57
+ const rootDomainOf = (url) => {
58
+ try {
59
+ const u = new URL(url);
60
+ return /^https?:$/.test(u.protocol) ? { root: getRootDomain(u.hostname), first: isFirstParty(u.hostname) || ownRoots.has(getRootDomain(u.hostname)) } : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ const domains = new Map();
67
+ const domainEntry = (root) => {
68
+ if (!domains.has(root)) {
69
+ domains.set(root, { domain: root, requests: 0, transferBytes: 0, sizeUnknownCount: 0, renderBlocking: false, loafMs: null, loafScripts: 0 });
70
+ }
71
+ return domains.get(root);
72
+ };
73
+
74
+ let firstPartyRequests = 0;
75
+ let thirdPartyRequests = 0;
76
+ let thirdPartyBytes = 0;
77
+ let sizeUnknownCount = 0;
78
+
79
+ performance.getEntriesByType("resource").forEach((entry) => {
80
+ const parsed = rootDomainOf(entry.name);
81
+ if (!parsed) return;
82
+ if (parsed.first) {
83
+ firstPartyRequests++;
84
+ return;
85
+ }
86
+ const d = domainEntry(parsed.root);
87
+ d.requests++;
88
+ thirdPartyRequests++;
89
+
90
+ // Cross-origin resources without Timing-Allow-Origin report every size as zero
91
+ const sizeKnown = entry.transferSize > 0 || entry.encodedBodySize > 0 || entry.decodedBodySize > 0;
92
+ if (sizeKnown) {
93
+ const bytes = entry.transferSize || entry.encodedBodySize || 0;
94
+ d.transferBytes += bytes;
95
+ thirdPartyBytes += bytes;
96
+ } else {
97
+ d.sizeUnknownCount++;
98
+ sizeUnknownCount++;
99
+ }
100
+
101
+ if (entry.renderBlockingStatus === "blocking" && (entry.initiatorType === "script" || entry.initiatorType === "link")) {
102
+ d.renderBlocking = true;
103
+ }
104
+ });
105
+
106
+ // Main-thread time: script time inside long animation frames, attributed by the script's source URL
107
+ const loafSupported = !!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame");
108
+ let thirdPartyLoafMs = null;
109
+ if (loafSupported) {
110
+ thirdPartyLoafMs = 0;
111
+ domains.forEach((d) => {
112
+ d.loafMs = 0;
113
+ });
114
+ performance.getEntriesByType("long-animation-frame").forEach((frame) => {
115
+ (frame.scripts || []).forEach((script) => {
116
+ const parsed = rootDomainOf(script.sourceURL);
117
+ if (!parsed || parsed.first) return;
118
+ const d = domainEntry(parsed.root);
119
+ d.loafMs = (d.loafMs || 0) + script.duration;
120
+ d.loafScripts++;
121
+ thirdPartyLoafMs += script.duration;
122
+ });
123
+ });
124
+ domains.forEach((d) => {
125
+ d.loafMs = Math.round(d.loafMs || 0);
126
+ });
127
+ thirdPartyLoafMs = Math.round(thirdPartyLoafMs);
128
+ }
129
+
130
+ // Render-blocking first, then main-thread time, then transfer size
131
+ const all = [...domains.values()].sort(
132
+ (a, b) =>
133
+ Number(b.renderBlocking) - Number(a.renderBlocking) ||
134
+ (b.loafMs || 0) - (a.loafMs || 0) ||
135
+ b.transferBytes - a.transferBytes ||
136
+ a.domain.localeCompare(b.domain)
137
+ );
138
+
139
+ const blocking = all.filter((d) => d.renderBlocking);
140
+ const heavy = all.filter((d) => (d.loafMs || 0) > LOAF_WARNING_MS);
141
+ const corsLimitedAnalysis = sizeUnknownCount > 0;
142
+
143
+ const listDomains = (list) => {
144
+ const names = list.slice(0, MAX_LISTED_DOMAINS).map((d) => d.domain);
145
+ return names.join(", ") + (list.length > MAX_LISTED_DOMAINS ? ` and ${list.length - MAX_LISTED_DOMAINS} more` : "");
146
+ };
147
+
148
+ const issues = [];
149
+ if (blocking.length > 0) {
150
+ issues.push({
151
+ severity: "warning",
152
+ message: `${blocking.length} third-party domain(s) serve render-blocking resources (${listDomains(blocking)}); load them async or defer, or self-host them`,
153
+ });
154
+ }
155
+ heavy.slice(0, MAX_LISTED_DOMAINS).forEach((d) => {
156
+ issues.push({
157
+ severity: "warning",
158
+ message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
159
+ });
160
+ });
161
+ if (corsLimitedAnalysis) {
162
+ issues.push({
163
+ severity: "info",
164
+ message: `${sizeUnknownCount} third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound`,
165
+ });
166
+ }
167
+
168
+ console.group("%c🌐 Third-Party Impact by Domain", "font-weight: bold; font-size: 14px;");
169
+ console.log(
170
+ `Third-party domains: ${all.length} | requests: ${thirdPartyRequests} | transfer: ${sizeLabel(thirdPartyBytes, sizeUnknownCount, thirdPartyRequests)} | first-party requests: ${firstPartyRequests}`
171
+ );
172
+ if (all.length > 0) {
173
+ console.table(
174
+ all.slice(0, MAX_ITEMS).map((d) => ({
175
+ Domain: d.domain,
176
+ Requests: d.requests,
177
+ Size: sizeLabel(d.transferBytes, d.sizeUnknownCount, d.requests),
178
+ "Render-blocking": d.renderBlocking ? "yes" : "no",
179
+ "Main thread": d.loafMs === null ? "n/a" : d.loafMs + " ms",
180
+ }))
181
+ );
182
+ } else {
183
+ console.log("%c✅ No third-party requests found", "color: #22c55e;");
184
+ }
185
+ if (!loafSupported) {
186
+ console.log("%cℹ️ Long Animation Frames are not supported; main-thread time is unavailable (Chrome 123+)", "color: #3b82f6;");
187
+ }
188
+ if (corsLimitedAnalysis) {
189
+ console.log("%cℹ️ Some third-party sizes are hidden (no Timing-Allow-Origin); sizes are a lower bound", "color: #3b82f6;");
190
+ }
191
+ if (OWN_DOMAINS.length === 0 && all.length > 0) {
192
+ console.log(
193
+ '%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.',
194
+ "color: #3b82f6;"
195
+ );
196
+ }
197
+ console.groupEnd();
198
+
199
+ return {
200
+ script: "Third-Party-Impact-by-Domain",
201
+ status: "ok",
202
+ count: all.length,
203
+ corsLimitedAnalysis,
204
+ details: {
205
+ thirdPartyRequests,
206
+ thirdPartyBytes,
207
+ thirdPartyLoafMs,
208
+ firstPartyRequests,
209
+ blockingDomains: blocking.length,
210
+ loafSupported,
211
+ sizeUnknownCount,
212
+ },
213
+ items: all.slice(0, MAX_ITEMS),
214
+ issues,
215
+ };
216
+ })();
@@ -378,6 +378,23 @@
378
378
 
379
379
  console.groupEnd();
380
380
 
381
+ // One item per script (an issue replaces the valid entry of the same URL), at most 50
382
+ const itemsByUrl = new Map();
383
+ validPreloads.forEach((v) => itemsByUrl.set(v.url, {
384
+ url: v.shortUrl,
385
+ type: v.type === "dynamic" ? "dynamic" : v.type === "mitigated" ? "mitigated" : "blocking",
386
+ strategy: v.attributes || "blocking-script",
387
+ location: v.location || "unknown",
388
+ reviewNote: v.reviewNote || v.note || "",
389
+ }));
390
+ issues.forEach((i) => itemsByUrl.set(i.url, {
391
+ url: i.shortUrl,
392
+ type: "issue",
393
+ strategy: i.attributes || i.type,
394
+ location: i.location || "unknown",
395
+ reviewNote: i.fix || "",
396
+ }));
397
+
381
398
  return {
382
399
  script: "Validate-Preload-Async-Defer-Scripts",
383
400
  status: "ok",
@@ -386,9 +403,10 @@
386
403
  validCount: validPreloads.length,
387
404
  issueCount: issues.length,
388
405
  },
389
- items: preloadedScripts.map(p => ({
390
- url: p.href.split("/").pop()?.split("?")[0] || p.href,
406
+ items: Array.from(itemsByUrl.values()).slice(0, 50),
407
+ issues: issues.map(i => ({
408
+ severity: i.severity,
409
+ message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message,
391
410
  })),
392
- issues: issues.map(i => ({ severity: i.severity, message: i.message })),
393
411
  };
394
412
  })();
@@ -19,6 +19,7 @@ When TTFB is slow or the user asks "why is my server slow":
19
19
  2. **TTFB-Sub-Parts.js** - Break down into DNS, connection, server time
20
20
  3. **Service-Worker-Analysis.js** - Check for SW overhead impacting TTFB
21
21
  4. **TTFB-Resources.js** - Identify slow third-party or API endpoints
22
+ 5. **Server-Timing-Early-Hints.js** - Read backend phases from `Server-Timing` and check 103 Early Hints
22
23
 
23
24
  ### Font Loading Optimization
24
25
 
@@ -27,6 +28,15 @@ When the user asks about fonts, FOIT, FOUT, or font performance:
27
28
  1. **Fonts-Preloaded-Loaded-and-used-above-the-fold.js** - Full font audit
28
29
  2. **Resource-Hints-Validation.js** - Verify font preloads are correct
29
30
  3. **Find-render-blocking-resources.js** - Check if fonts block rendering
31
+ 4. **Webfont-Usage-Analyzer.js** - Find font faces that are unused, never loaded, or use a blocking `font-display`
32
+
33
+ ### Text Compression Audit
34
+
35
+ When the user asks about compression, gzip, brotli, or large transfer sizes for text resources:
36
+
37
+ 1. **Compression-Audit.js** - Find text resources served without compression and estimate savings
38
+ 2. **Find-render-blocking-resources.js** - Prioritize uncompressed CSS/JS that block rendering
39
+ 3. **TTFB-Resources.js** - Check server response of the uncompressed resources
30
40
 
31
41
  ### Script Performance Deep Dive
32
42
 
@@ -34,6 +44,7 @@ When scripts are suspected to slow down the page:
34
44
 
35
45
  1. **Script-Loading.js** - Identify blocking scripts and loading strategy
36
46
  2. **First-And-Third-Party-Script-Info.js** - Separate first vs third-party impact
47
+ - **Third-Party-Impact-by-Domain.js** - Rank third-party domains by render-blocking, main-thread time and size
37
48
  3. **First-And-Third-Party-Script-Timings.js** - Diagnose slow script connections
38
49
  4. **JS-Execution-Time-Breakdown.js** - Network vs parse/execution time
39
50
  5. **Inline-Script-Info-and-Size.js** - Measure inline script overhead
@@ -48,6 +59,7 @@ When the user wants to optimize resource loading priorities:
48
59
  3. **Priority-Hints-Audit.js** - Check fetchpriority usage
49
60
  4. **Prefetch-Resource-Validation.js** - Validate prefetch strategy
50
61
  5. **Validate-Preload-Async-Defer-Scripts.js** - Find conflicts
62
+ 6. **Speculation-Rules-Inspector.js** - Inspect prefetch/prerender rules and prerender activation
51
63
 
52
64
  ### CSS Optimization Workflow
53
65
 
@@ -76,6 +88,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
76
88
  - **If TTFB > 600ms** → Run **TTFB-Sub-Parts.js** to diagnose where time is spent
77
89
  - **If Service Worker detected** → Run **Service-Worker-Analysis.js** to check for SW overhead
78
90
  - **If TTFB varies significantly across resources** → Run **TTFB-Resources.js**
91
+ - **If server time dominates TTFB-Sub-Parts.js** → Run **Server-Timing-Early-Hints.js** to see which backend phase is slow
92
+
93
+ ### After Server-Timing-Early-Hints.js
94
+
95
+ - **If no `Server-Timing` metrics found** → Recommend adding `Server-Timing` headers for database, cache and render phases
96
+ - **If cross-origin resources look hidden** → Recommend `Timing-Allow-Origin` on those responses
97
+ - **If no 103 Early Hints received and server time is high** → Recommend Early Hints for the main stylesheet and LCP image
79
98
 
80
99
  ### After FCP.js
81
100
 
@@ -109,6 +128,19 @@ Use this decision tree to automatically run follow-up snippets based on results:
109
128
  2. **Priority-Hints-Audit.js** (LCP candidate)
110
129
  - **If preloads on async/defer scripts** → Run **Validate-Preload-Async-Defer-Scripts.js**
111
130
 
131
+ ### After Compression-Audit.js
132
+
133
+ - **If uncompressed CSS/JS found** → Run **Find-render-blocking-resources.js** to see if they block rendering
134
+ - **If estimated savings > 100KB** → Recommend enabling gzip or brotli on the server or CDN
135
+ - **If corsLimitedAnalysis is true** → Report the result as a lower bound and suggest `Timing-Allow-Origin` on resources you control
136
+
137
+ ### After Third-Party-Impact-by-Domain.js
138
+
139
+ - **If a domain is render-blocking** → Recommend `async`, `defer`, lazy loading or self-hosting; run **Script-Loading.js** for the loading strategy
140
+ - **If a domain's main-thread time > 250ms** → Run **Long-Animation-Frames-Script-Attribution.js** (webperf-interaction) to find the functions responsible, and recommend delaying or removing the script
141
+ - **If many domains with few requests each** → Recommend auditing tags and removing unused ones
142
+ - **If corsLimitedAnalysis is true** → Report sizes as a lower bound; use **First-And-Third-Party-Script-Timings.js** for timings of the hidden resources
143
+
112
144
  ### After Service-Worker-Analysis.js
113
145
 
114
146
  - **If SW overhead > 100ms** → Recommend Navigation Preload API
@@ -119,7 +151,14 @@ Use this decision tree to automatically run follow-up snippets based on results:
119
151
 
120
152
  - **If fonts preloaded but not used above-the-fold** → Recommend removing preloads
121
153
  - **If fonts used but not preloaded** → Recommend adding preload
122
- - **If many font variants loaded** → Suggest subsetting or reducing variants
154
+ - **If many font variants loaded** → Suggest subsetting or reducing variants, and run **Webfont-Usage-Analyzer.js** to find the unused ones
155
+
156
+ ### After Webfont-Usage-Analyzer.js
157
+
158
+ - **If fonts loaded but not used** → Recommend removing the `@font-face` declaration or the preload
159
+ - **If faces use `font-display: auto` or `block`** → Recommend `swap` or `optional`, then check **Find-render-blocking-resources.js**
160
+ - **If font bytes are high** → Suggest subsetting with `unicode-range` and WOFF2
161
+ - **If the LCP element is text** → Run **LCP.js** with the **webperf-core-web-vitals** skill
123
162
 
124
163
  ### After First-And-Third-Party-Script-Info.js
125
164
 
@@ -145,6 +184,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
145
184
  - **If >10 prefetch hints** → Recommend reducing to critical resources
146
185
  - **If individual prefetch > 500KB** → Question necessity
147
186
  - **If total prefetch > 2MB** → Flag as mobile bandwidth concern
187
+ - **If few or no prefetch hints for likely next navigations** → Run **Speculation-Rules-Inspector.js** to check for prefetch/prerender rules
188
+
189
+ ### After Speculation-Rules-Inspector.js
190
+
191
+ - **If no speculation rules found but the site has predictable navigations** → Recommend adding prefetch or prerender rules
192
+ - **If invalid JSON or wasteful rules (eager prerender of many URLs)** → Recommend fixing or lowering eagerness to `moderate` or `conservative`
193
+ - **If the page was prerendered (`activationStart` > 0)** → Subtract `activationStart` when interpreting **TTFB.js**, **FCP.js** and LCP results
148
194
 
149
195
  ### After Critical-CSS-Detection.js
150
196