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.
- package/README.md +142 -14
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +86 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +74 -11
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- 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
|
-
|
|
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
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
console.log('%c
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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: ${
|
|
271
|
+
console.log(` SW overhead: ${swOverheadMs.toFixed(1)}ms`);
|
|
107
272
|
|
|
108
|
-
if (
|
|
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 (
|
|
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} (${
|
|
142
|
-
console.log(`
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
if (rate >= 80) {
|
|
308
|
+
if (cacheHitRate >= 80) {
|
|
147
309
|
console.log('%c 🟢 Excellent cache hit rate', 'color: #22c55e;');
|
|
148
|
-
} else if (
|
|
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
|
|
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 (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
62
|
+
.filter(hasTiming)
|
|
10
63
|
.map((entry) => {
|
|
11
|
-
const
|
|
12
|
-
const isThirdParty = url.hostname !== location.hostname;
|
|
64
|
+
const isThirdParty = isThirdPartyUrl(entry.name);
|
|
13
65
|
|
|
14
66
|
return {
|
|
15
|
-
ttfb: entry
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 },
|