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
@@ -7,7 +7,15 @@
7
7
  '%c⚠️ Service Workers not supported in this browser',
8
8
  'color: #f59e0b; font-weight: bold;'
9
9
  );
10
- return;
10
+ return {
11
+ script: "Service-Worker-Analysis",
12
+ status: "unsupported",
13
+ error: "Service Workers not supported in this browser",
14
+ count: 0,
15
+ details: {},
16
+ items: [],
17
+ issues: [{ severity: "error", message: "Service Workers not supported in this browser" }],
18
+ };
11
19
  }
12
20
 
13
21
  const registrations = await navigator.serviceWorker.getRegistrations();
@@ -17,10 +25,152 @@
17
25
 
18
26
  // Resources intercepted by SW (workerStart > 0)
19
27
  const swResources = resources.filter((r) => r.workerStart > 0);
20
- const fromCache = swResources.filter((r) => r.transferSize === 0);
28
+ // transferSize 0 with a body means served from cache. With no body size either, the
29
+ // resource is cross-origin without Timing-Allow-Origin and its source is unknown.
30
+ const fromCache = swResources.filter((r) => r.transferSize === 0 && r.encodedBodySize > 0);
21
31
  const fromNetwork = swResources.filter((r) => r.transferSize > 0);
32
+ const unknownSource = swResources.filter((r) => r.transferSize === 0 && r.encodedBodySize === 0);
33
+ const knownSource = fromCache.length + fromNetwork.length;
22
34
  const notIntercepted = resources.filter((r) => r.workerStart === 0);
23
35
 
36
+ // SW Startup overhead
37
+ let swOverheadMs = null;
38
+ if (navEntry && navEntry.workerStart > 0) {
39
+ const workerStart = navEntry.workerStart - navEntry.startTime;
40
+ const fetchStart = navEntry.fetchStart - navEntry.startTime;
41
+ swOverheadMs = Math.round(Math.max(fetchStart - workerStart, 0) * 10) / 10;
42
+ }
43
+
44
+ // Cache hit rate
45
+ // Computed over the resources whose source is known
46
+ const cacheHitRate = knownSource > 0
47
+ ? parseFloat(((fromCache.length / knownSource) * 100).toFixed(1))
48
+ : null;
49
+ const savedBytes = fromCache.reduce((sum, r) => sum + (r.encodedBodySize || 0), 0);
50
+
51
+ // Navigation preload states (collected once, reused in console output and return)
52
+ const preloadStates = new Map();
53
+ for (const reg of registrations) {
54
+ if (reg.navigationPreload) {
55
+ try {
56
+ const state = await reg.navigationPreload.getState();
57
+ preloadStates.set(reg, state);
58
+ } catch {
59
+ // Access may be restricted
60
+ }
61
+ }
62
+ }
63
+
64
+ // Cache Storage inventory
65
+ const cacheStorage = [];
66
+ if ('caches' in window) {
67
+ try {
68
+ const cacheNames = await caches.keys();
69
+ for (const name of cacheNames) {
70
+ const cache = await caches.open(name);
71
+ const keys = await cache.keys();
72
+ cacheStorage.push({ name, entries: keys.length });
73
+ }
74
+ } catch {
75
+ // Cross-origin restrictions may prevent cache access
76
+ }
77
+ }
78
+
79
+ const cacheStorageTop = [...cacheStorage].sort((a, b) => b.entries - a.entries).slice(0, 20);
80
+
81
+ // Build registration items (homogeneous shape)
82
+ const items = registrations.map((reg) => {
83
+ const preload = preloadStates.get(reg) || null;
84
+ let state = "none";
85
+ let scriptURL = null;
86
+ if (reg.active) {
87
+ state = "active";
88
+ scriptURL = reg.active.scriptURL;
89
+ } else if (reg.waiting) {
90
+ state = "waiting";
91
+ scriptURL = reg.waiting.scriptURL;
92
+ } else if (reg.installing) {
93
+ state = "installing";
94
+ scriptURL = reg.installing.scriptURL;
95
+ }
96
+ return {
97
+ scope: reg.scope,
98
+ scriptURL,
99
+ state,
100
+ hasWaiting: !!reg.waiting,
101
+ hasInstalling: !!reg.installing,
102
+ navigationPreloadEnabled: preload ? preload.enabled : null,
103
+ navigationPreloadHeaderValue: preload?.headerValue || null,
104
+ };
105
+ });
106
+
107
+ // Build issues
108
+ const issues = [];
109
+
110
+ if (registrations.length === 0) {
111
+ issues.push({ severity: "info", message: "No Service Workers registered" });
112
+ }
113
+
114
+ if (registrations.length > 0 && !controller) {
115
+ issues.push({
116
+ severity: "warning",
117
+ message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload.",
118
+ });
119
+ }
120
+
121
+ for (const item of items) {
122
+ if (item.hasWaiting) {
123
+ issues.push({
124
+ severity: "warning",
125
+ message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`,
126
+ });
127
+ }
128
+ if (item.navigationPreloadEnabled === false) {
129
+ issues.push({
130
+ severity: "info",
131
+ message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`,
132
+ });
133
+ }
134
+ }
135
+
136
+ if (swOverheadMs !== null && swOverheadMs > 100) {
137
+ issues.push({
138
+ severity: "error",
139
+ message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.`,
140
+ });
141
+ } else if (swOverheadMs !== null && swOverheadMs > 50) {
142
+ issues.push({
143
+ severity: "warning",
144
+ message: `Moderate SW startup time (${swOverheadMs}ms)`,
145
+ });
146
+ }
147
+
148
+ if (cacheHitRate !== null && cacheHitRate < 50) {
149
+ issues.push({
150
+ severity: "error",
151
+ message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.`,
152
+ });
153
+ } else if (cacheHitRate !== null && cacheHitRate < 80) {
154
+ issues.push({
155
+ severity: "warning",
156
+ message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.`,
157
+ });
158
+ }
159
+
160
+ if (unknownSource.length > 0) {
161
+ issues.push({
162
+ severity: "info",
163
+ message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`,
164
+ });
165
+ }
166
+
167
+ const rating = cacheHitRate === null ? null
168
+ : cacheHitRate >= 80 ? "good"
169
+ : cacheHitRate >= 50 ? "needs-improvement"
170
+ : "poor";
171
+
172
+ // --- Console output ---
173
+
24
174
  console.group(
25
175
  '%c⚙️ Service Worker Analysis',
26
176
  'font-weight: bold; font-size: 14px;'
@@ -33,7 +183,28 @@
33
183
  if (registrations.length === 0) {
34
184
  console.log('%c ❌ No Service Workers registered', 'color: #ef4444;');
35
185
  console.groupEnd();
36
- return;
186
+ return {
187
+ script: "Service-Worker-Analysis",
188
+ status: "ok",
189
+ count: 0,
190
+ details: {
191
+ controlled: false,
192
+ controllerState: null,
193
+ swOverheadMs: null,
194
+ totalResources: resources.length,
195
+ swIntercepted: 0,
196
+ notIntercepted: resources.length,
197
+ fromCache: 0,
198
+ fromNetwork: 0,
199
+ fromUnknown: 0,
200
+ cacheHitRate: null,
201
+ savedBytes: 0,
202
+ cacheStorageCount: cacheStorage.length,
203
+ cacheStorage: cacheStorageTop,
204
+ },
205
+ items: [],
206
+ issues,
207
+ };
37
208
  }
38
209
 
39
210
  for (const reg of registrations) {
@@ -61,23 +232,18 @@
61
232
  console.log('%c 🔄 Installing...', 'color: #3b82f6;');
62
233
  }
63
234
 
64
- // Navigation Preload
65
- if (reg.navigationPreload) {
66
- try {
67
- const preload = await reg.navigationPreload.getState();
68
- console.log('');
69
- console.log('%c 🚀 Navigation Preload:', 'font-weight: bold;');
70
- if (preload.enabled) {
71
- console.log('%c ✅ Enabled', 'color: #22c55e;');
72
- console.log(` Header value: "${preload.headerValue}"`);
73
- } else {
74
- console.log('%c ❌ Disabled', 'color: #ef4444;');
75
- console.log(
76
- ' 💡 Enable with: registration.navigationPreload.enable()'
77
- );
78
- }
79
- } catch {
80
- // Access may be restricted
235
+ const preload = preloadStates.get(reg);
236
+ if (preload !== undefined) {
237
+ console.log('');
238
+ console.log('%c 🚀 Navigation Preload:', 'font-weight: bold;');
239
+ if (preload.enabled) {
240
+ console.log('%c ✅ Enabled', 'color: #22c55e;');
241
+ console.log(` Header value: "${preload.headerValue}"`);
242
+ } else {
243
+ console.log('%c ❌ Disabled', 'color: #ef4444;');
244
+ console.log(
245
+ ' 💡 Enable with: registration.navigationPreload.enable()'
246
+ );
81
247
  }
82
248
  }
83
249
  }
@@ -97,20 +263,19 @@
97
263
  if (navEntry && navEntry.workerStart > 0) {
98
264
  const workerStart = navEntry.workerStart - navEntry.startTime;
99
265
  const fetchStart = navEntry.fetchStart - navEntry.startTime;
100
- const swOverhead = Math.max(fetchStart - workerStart, 0);
101
266
 
102
267
  console.log('');
103
268
  console.log('%c⏱️ SW Startup Overhead:', 'font-weight: bold;');
104
269
  console.log(` Worker start: ${workerStart.toFixed(1)}ms`);
105
270
  console.log(` Fetch start: ${fetchStart.toFixed(1)}ms`);
106
- console.log(` SW overhead: ${swOverhead.toFixed(1)}ms`);
271
+ console.log(` SW overhead: ${swOverheadMs.toFixed(1)}ms`);
107
272
 
108
- if (swOverhead > 100) {
273
+ if (swOverheadMs > 100) {
109
274
  console.log(
110
275
  '%c 🔴 High SW startup time — enable Navigation Preload',
111
276
  'color: #ef4444;'
112
277
  );
113
- } else if (swOverhead > 50) {
278
+ } else if (swOverheadMs > 50) {
114
279
  console.log(
115
280
  '%c 🟡 Moderate SW startup time',
116
281
  'color: #f59e0b;'
@@ -131,21 +296,18 @@
131
296
  console.log(` Not intercepted: ${notIntercepted.length}`);
132
297
 
133
298
  if (swResources.length > 0) {
134
- const hitRate = ((fromCache.length / swResources.length) * 100).toFixed(1);
135
- const savedKB = (
136
- fromCache.reduce((sum, r) => sum + (r.encodedBodySize || 0), 0) / 1024
137
- ).toFixed(1);
138
-
139
299
  console.log('');
140
300
  console.log(' SW-intercepted breakdown:');
141
- console.log(` ├─ Served from cache: ${fromCache.length} (${hitRate}% hit rate)`);
142
- console.log(` └─ Fetched from network: ${fromNetwork.length}`);
143
- console.log(` Network bytes saved: ~${savedKB} KB`);
301
+ console.log(` ├─ Served from cache: ${fromCache.length} (${cacheHitRate ?? 0}% hit rate)`);
302
+ console.log(` ${unknownSource.length > 0 ? '├' : '└'}─ Fetched from network: ${fromNetwork.length}`);
303
+ if (unknownSource.length > 0) {
304
+ console.log(` └─ Unknown source: ${unknownSource.length} (cross-origin, missing Timing-Allow-Origin)`);
305
+ }
306
+ console.log(` Network bytes saved: ~${(savedBytes / 1024).toFixed(1)} KB`);
144
307
 
145
- const rate = parseFloat(hitRate);
146
- if (rate >= 80) {
308
+ if (cacheHitRate >= 80) {
147
309
  console.log('%c 🟢 Excellent cache hit rate', 'color: #22c55e;');
148
- } else if (rate >= 50) {
310
+ } else if (cacheHitRate >= 50) {
149
311
  console.log('%c 🟡 Good cache hit rate', 'color: #f59e0b;');
150
312
  } else {
151
313
  console.log(
@@ -158,7 +320,7 @@
158
320
  console.log('%c📋 SW-intercepted Resources (top 20):', 'font-weight: bold;');
159
321
  console.table(
160
322
  swResources.slice(0, 20).map((r) => ({
161
- 'Cache': r.transferSize === 0 ? '✅ Cache' : '🌐 Network',
323
+ 'Cache': r.transferSize > 0 ? '🌐 Network' : r.encodedBodySize > 0 ? '✅ Cache' : '❓ Unknown',
162
324
  'Transfer (KB)': r.transferSize > 0 ? (r.transferSize / 1024).toFixed(1) : '0',
163
325
  'Duration (ms)': r.duration.toFixed(0),
164
326
  'Type': r.initiatorType,
@@ -168,25 +330,39 @@
168
330
  }
169
331
 
170
332
  // Cache Storage inventory
171
- if ('caches' in window) {
172
- try {
173
- const cacheNames = await caches.keys();
174
- if (cacheNames.length > 0) {
175
- console.log('');
176
- console.log('%c💾 Cache Storage:', 'font-weight: bold;');
177
- let totalEntries = 0;
178
- for (const name of cacheNames) {
179
- const cache = await caches.open(name);
180
- const keys = await cache.keys();
181
- totalEntries += keys.length;
182
- console.log(` ├─ "${name}": ${keys.length} entries`);
183
- }
184
- console.log(` Total entries: ${totalEntries}`);
185
- }
186
- } catch {
187
- // Cross-origin restrictions may prevent cache access
333
+ if (cacheStorage.length > 0) {
334
+ const totalCacheEntries = cacheStorage.reduce((sum, c) => sum + c.entries, 0);
335
+ console.log('');
336
+ console.log('%c💾 Cache Storage:', 'font-weight: bold;');
337
+ for (const { name, entries } of cacheStorage) {
338
+ console.log(` ├─ "${name}": ${entries} entries`);
188
339
  }
340
+ console.log(` Total entries: ${totalCacheEntries}`);
189
341
  }
190
342
 
191
343
  console.groupEnd();
344
+
345
+ return {
346
+ script: "Service-Worker-Analysis",
347
+ status: "ok",
348
+ count: registrations.length,
349
+ ...(rating !== null && { rating }),
350
+ details: {
351
+ controlled: !!controller,
352
+ controllerState: controller?.state || null,
353
+ swOverheadMs,
354
+ totalResources: resources.length,
355
+ swIntercepted: swResources.length,
356
+ notIntercepted: notIntercepted.length,
357
+ fromCache: fromCache.length,
358
+ fromNetwork: fromNetwork.length,
359
+ fromUnknown: unknownSource.length,
360
+ cacheHitRate,
361
+ savedBytes,
362
+ cacheStorageCount: cacheStorage.length,
363
+ cacheStorage: cacheStorageTop,
364
+ },
365
+ items,
366
+ issues,
367
+ };
192
368
  })();
@@ -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,69 @@
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
+
9
+ // Waiting time for the first byte of a resource: responseStart is measured from the
10
+ // start of the page, so subtract requestStart. Cross-origin resources without a
11
+ // Timing-Allow-Origin header report 0 for both and cannot be measured.
12
+ const waitTime = (entry) => entry.responseStart - entry.requestStart;
13
+ const hasTiming = (entry) => entry.responseStart > 0;
14
+ // @shared getRootDomain
15
+ function getRootDomain(hostname) {
16
+ const host = hostname.replace(/\.$/, "");
17
+ // An IP address has no registrable domain, so each address is its own root
18
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
19
+ const parts = host.split(".");
20
+ if (parts.length <= 2) return host;
21
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
22
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
23
+ const tld = parts[parts.length - 1];
24
+ const sld = parts[parts.length - 2];
25
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
26
+ return parts.slice(-2).join(".");
27
+ }
28
+ // @end-shared getRootDomain
29
+
30
+ // @shared isFirstParty
31
+ function isFirstParty(hostname) {
32
+ const root = getRootDomain(hostname);
33
+ if (root === getRootDomain(location.hostname)) return true;
34
+ return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
35
+ }
36
+ // @end-shared isFirstParty
37
+
38
+ // @shared logOwnDomainsHint
39
+ function logOwnDomainsHint(thirdPartyCount) {
40
+ if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
41
+ console.log(
42
+ '%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.',
43
+ "color: #3b82f6;"
44
+ );
45
+ }
46
+ }
47
+ // @end-shared logOwnDomainsHint
48
+
49
+ const isThirdPartyUrl = (name) => {
50
+ try {
51
+ return !isFirstParty(new URL(name).hostname);
52
+ } catch {
53
+ return false;
54
+ }
55
+ };
56
+
5
57
  new PerformanceObserver((entryList) => {
6
58
  const entries = entryList.getEntries();
59
+ const restrictedCount = entries.filter((entry) => !hasTiming(entry)).length;
7
60
 
8
61
  const resourcesData = entries
9
- .filter((entry) => entry.responseStart > 0)
62
+ .filter(hasTiming)
10
63
  .map((entry) => {
11
- const url = new URL(entry.name);
12
- const isThirdParty = url.hostname !== location.hostname;
64
+ const isThirdParty = isThirdPartyUrl(entry.name);
13
65
 
14
66
  return {
15
- ttfb: entry.responseStart,
67
+ ttfb: waitTime(entry),
16
68
  duration: entry.duration,
17
69
  type: entry.initiatorType,
18
70
  thirdParty: isThirdParty,
@@ -44,6 +96,9 @@
44
96
  console.log(` Average TTFB: ${avgTtfb.toFixed(0)}ms`);
45
97
  console.log(` Fastest: ${minTtfb.toFixed(0)}ms | Slowest: ${maxTtfb.toFixed(0)}ms`);
46
98
  console.log(` Third-party resources: ${thirdPartyCount}`);
99
+ if (restrictedCount > 0) {
100
+ console.log(`%c ⚠️ ${restrictedCount} resource(s) not measured: missing Timing-Allow-Origin header`, "color: #f59e0b;");
101
+ }
47
102
  if (slowResources > 0) {
48
103
  console.log(`%c ⚠️ Slow resources (>500ms): ${slowResources}`, "color: #f59e0b;");
49
104
  }
@@ -75,6 +130,7 @@
75
130
  });
76
131
  }
77
132
 
133
+ logOwnDomainsHint(thirdPartyCount);
78
134
  console.groupEnd();
79
135
  }).observe({
80
136
  type: "resource",
@@ -82,34 +138,46 @@
82
138
  });
83
139
 
84
140
  // 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
- })
141
+ const allResources = performance.getEntriesByType("resource");
142
+ const corsRestrictedCount = allResources.filter((entry) => !hasTiming(entry)).length;
143
+ const resourcesSync = allResources
144
+ .filter(hasTiming)
145
+ .map((entry) => ({
146
+ url: entry.name,
147
+ ttfbMs: Math.round(waitTime(entry)),
148
+ durationMs: Math.round(entry.duration),
149
+ type: entry.initiatorType,
150
+ isThirdParty: isThirdPartyUrl(entry.name),
151
+ }))
97
152
  .sort((a, b) => b.ttfbMs - a.ttfbMs);
98
153
  if (resourcesSync.length === 0) {
99
- return { script: "TTFB-Resources", status: "error", error: "No resources with TTFB data available" };
154
+ // Nothing to measure is a valid result, such as a page with every resource inline or hidden by CORS
155
+ return {
156
+ script: "TTFB-Resources",
157
+ status: "ok",
158
+ count: 0,
159
+ corsLimitedAnalysis: corsRestrictedCount > 0,
160
+ details: { avgTtfbMs: null, maxTtfbMs: null, minTtfbMs: null, thirdPartyCount: 0, slowCount: 0, corsRestrictedCount },
161
+ items: [],
162
+ issues: [{ severity: "info", message: "No resources with TTFB data available" }],
163
+ };
100
164
  }
101
165
  const ttfbVals = resourcesSync.map((r) => r.ttfbMs);
166
+ // The list keeps the 50 slowest resources (already sorted); count and details cover all of them
167
+ const MAX_ITEMS = 50;
102
168
  return {
103
169
  script: "TTFB-Resources",
104
170
  status: "ok",
105
171
  count: resourcesSync.length,
172
+ corsLimitedAnalysis: corsRestrictedCount > 0,
106
173
  details: {
107
174
  avgTtfbMs: Math.round(ttfbVals.reduce((a, b) => a + b, 0) / ttfbVals.length),
108
175
  maxTtfbMs: Math.max(...ttfbVals),
109
176
  minTtfbMs: Math.min(...ttfbVals),
110
177
  thirdPartyCount: resourcesSync.filter((r) => r.isThirdParty).length,
111
178
  slowCount: resourcesSync.filter((r) => r.ttfbMs > 500).length,
179
+ corsRestrictedCount,
112
180
  },
113
- items: resourcesSync,
181
+ items: resourcesSync.slice(0, MAX_ITEMS),
114
182
  };
115
183
  })();
@@ -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 },