webperf-snippets 0.1.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 (67) hide show
  1. package/README.md +139 -0
  2. package/package.json +54 -0
  3. package/snippets/CoreWebVitals/CLS.js +88 -0
  4. package/snippets/CoreWebVitals/INP.js +432 -0
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
  9. package/snippets/CoreWebVitals/LCP.js +135 -0
  10. package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
  12. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
  15. package/snippets/Interaction/Interactions.js +220 -0
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
  19. package/snippets/Interaction/Long-Animation-Frames.js +298 -0
  20. package/snippets/Interaction/LongTask.js +203 -0
  21. package/snippets/Interaction/Scroll-Performance.js +359 -0
  22. package/snippets/Interaction/WORKFLOWS.md +205 -0
  23. package/snippets/Loading/Back-Forward-Cache.js +473 -0
  24. package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
  25. package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
  26. package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
  27. package/snippets/Loading/Content-Visibility.js +352 -0
  28. package/snippets/Loading/Critical-CSS-Detection.js +329 -0
  29. package/snippets/Loading/Event-Processing-Time.js +179 -0
  30. package/snippets/Loading/FCP.js +101 -0
  31. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
  32. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
  33. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
  34. package/snippets/Loading/Find-render-blocking-resources.js +161 -0
  35. package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
  36. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
  37. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
  38. package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
  39. package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
  40. package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
  41. package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
  42. package/snippets/Loading/Priority-Hints-Audit.js +336 -0
  43. package/snippets/Loading/Resource-Hints-Validation.js +503 -0
  44. package/snippets/Loading/Resource-Hints.js +400 -0
  45. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
  46. package/snippets/Loading/Script-Loading.js +332 -0
  47. package/snippets/Loading/Service-Worker-Analysis.js +192 -0
  48. package/snippets/Loading/TTFB-Resources.js +115 -0
  49. package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
  50. package/snippets/Loading/TTFB.js +37 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
  52. package/snippets/Loading/WORKFLOWS.md +163 -0
  53. package/snippets/Media/Image-Element-Audit.js +350 -0
  54. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
  55. package/snippets/Media/Video-Element-Audit.js +282 -0
  56. package/snippets/Media/WORKFLOWS.md +190 -0
  57. package/snippets/README.md +98 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
  59. package/snippets/Resources/WORKFLOWS.md +173 -0
  60. package/src/bin.js +229 -0
  61. package/src/decision-tree.js +19 -0
  62. package/src/load-snippet.js +23 -0
  63. package/src/reporters/human.js +179 -0
  64. package/src/reporters/json.js +3 -0
  65. package/src/runner.js +61 -0
  66. package/src/workflows/audit.js +21 -0
  67. package/src/workflows/cwv.js +10 -0
@@ -0,0 +1,329 @@
1
+ // Critical CSS Detection
2
+ // Analyzes render-blocking stylesheets, inline CSS budget, and loading strategy
3
+ // https://webperf-snippets.nucliweb.net
4
+
5
+ (() => {
6
+ const formatBytes = (bytes) => {
7
+ if (bytes === 0) return "0 B";
8
+ const k = 1024;
9
+ const sizes = ["B", "KB", "MB"];
10
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
11
+ return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
12
+ };
13
+
14
+ const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
15
+
16
+ // --- External stylesheets ---
17
+ const linkStylesheets = Array.from(
18
+ document.querySelectorAll('link[rel="stylesheet"]')
19
+ );
20
+
21
+ const perfEntries = performance.getEntriesByType("resource");
22
+
23
+ const externalCSSData = linkStylesheets.map((link) => {
24
+ const href = link.href;
25
+ const media = link.getAttribute("media") || "all";
26
+
27
+ const perfEntry = perfEntries.find((e) => e.name === href);
28
+
29
+ // Prefer browser-reported render-blocking status (PerformanceResourceTiming API,
30
+ // Chrome 107+). This correctly handles the media="print" onload="this.media='all'"
31
+ // deferral pattern — by page load time the media attribute has already changed to
32
+ // "all", so a DOM-only check would produce false positives.
33
+ let isRenderBlocking;
34
+ if (perfEntry && perfEntry.renderBlockingStatus !== undefined) {
35
+ isRenderBlocking = perfEntry.renderBlockingStatus === "blocking";
36
+ } else {
37
+ // Fallback for browsers without renderBlockingStatus support.
38
+ // Note: may produce false positives when the media/onload deferral pattern is used.
39
+ isRenderBlocking =
40
+ media === "all" ||
41
+ media === "" ||
42
+ media === "screen" ||
43
+ (media.toLowerCase().includes("screen") &&
44
+ !media.toLowerCase().includes("print"));
45
+ }
46
+ const transferSize = perfEntry ? perfEntry.transferSize : 0;
47
+ const decodedSize = perfEntry ? perfEntry.decodedBodySize : 0;
48
+
49
+ const preloadLink = document.querySelector(
50
+ `link[rel="preload"][as="style"][href="${href}"]`
51
+ );
52
+
53
+ let ruleCount = 0;
54
+ let corsBlocked = false;
55
+ try {
56
+ const sheet = Array.from(document.styleSheets).find(
57
+ (s) => s.href === href
58
+ );
59
+ if (sheet && sheet.cssRules) ruleCount = sheet.cssRules.length;
60
+ } catch {
61
+ corsBlocked = true;
62
+ }
63
+
64
+ return {
65
+ filename: href.split("/").pop().split("?")[0] || href,
66
+ href,
67
+ media,
68
+ isRenderBlocking,
69
+ transferSize,
70
+ decodedSize,
71
+ ruleCount,
72
+ corsBlocked,
73
+ preloaded: preloadLink !== null,
74
+ };
75
+ });
76
+
77
+ // --- Inline styles ---
78
+ const inlineStyles = Array.from(document.querySelectorAll("style")).map(
79
+ (style, i) => {
80
+ const content = style.innerHTML;
81
+ const size = new Blob([content]).size;
82
+ const ruleCount = (content.match(/\{[^}]*\}/g) || []).length;
83
+ return {
84
+ index: i + 1,
85
+ size,
86
+ ruleCount,
87
+ inHead: style.closest("head") !== null,
88
+ };
89
+ }
90
+ );
91
+
92
+ // --- Totals ---
93
+ const renderBlockingCSS = externalCSSData.filter((s) => s.isRenderBlocking);
94
+ const nonBlockingCSS = externalCSSData.filter((s) => !s.isRenderBlocking);
95
+ const totalInlineBytes = inlineStyles.reduce((sum, s) => sum + s.size, 0);
96
+ const totalRenderBlockingDecoded = renderBlockingCSS.reduce(
97
+ (sum, s) => sum + s.decodedSize,
98
+ 0
99
+ );
100
+ const totalExternalTransfer = externalCSSData.reduce(
101
+ (sum, s) => sum + s.transferSize,
102
+ 0
103
+ );
104
+
105
+ // --- Output ---
106
+ console.group(
107
+ "%cšŸŽÆ Critical CSS Detection",
108
+ "font-weight: bold; font-size: 14px;"
109
+ );
110
+
111
+ console.log("");
112
+ console.log("%cPage CSS Summary:", "font-weight: bold;");
113
+ console.log(` External stylesheets: ${externalCSSData.length}`);
114
+ console.log(` Render-blocking: ${renderBlockingCSS.length}`);
115
+ console.log(` Non-blocking (media query): ${nonBlockingCSS.length}`);
116
+ console.log(` Inline <style> blocks: ${inlineStyles.length}`);
117
+ console.log(` Total external CSS (wire): ${formatBytes(totalExternalTransfer)}`);
118
+ console.log(` Total inline CSS: ${formatBytes(totalInlineBytes)}`);
119
+
120
+ // Render-blocking stylesheets
121
+ if (renderBlockingCSS.length > 0) {
122
+ console.log("");
123
+ console.group(
124
+ `%c🚫 Render-Blocking Stylesheets (${renderBlockingCSS.length})`,
125
+ "color: #ef4444; font-weight: bold;"
126
+ );
127
+
128
+ console.table(
129
+ renderBlockingCSS.map((s) => ({
130
+ File: s.filename,
131
+ Media: s.media,
132
+ "Transfer Size": formatBytes(s.transferSize),
133
+ "Decoded Size": formatBytes(s.decodedSize),
134
+ Rules: s.corsBlocked ? "CORS blocked" : s.ruleCount,
135
+ Preloaded: s.preloaded ? "āœ… Yes" : "āŒ No",
136
+ }))
137
+ );
138
+
139
+ const budgetPct = Math.min(
140
+ (totalRenderBlockingDecoded / criticalBudget) * 100,
141
+ 200
142
+ );
143
+ const barWidth = Math.min(Math.round(budgetPct / 10), 20);
144
+ const bar = "ā–ˆ".repeat(barWidth) + "ā–‘".repeat(20 - barWidth);
145
+ console.log(
146
+ `\n Total render-blocking: ${formatBytes(totalRenderBlockingDecoded)}`
147
+ );
148
+ console.log(` vs. 14 KB budget: [${bar}] ${budgetPct.toFixed(0)}%`);
149
+
150
+ console.groupEnd();
151
+ } else {
152
+ console.log("");
153
+ console.log(
154
+ "%cāœ… No render-blocking stylesheets detected.",
155
+ "color: #22c55e; font-weight: bold;"
156
+ );
157
+ }
158
+
159
+ // Non-blocking stylesheets
160
+ if (nonBlockingCSS.length > 0) {
161
+ console.log("");
162
+ console.group(
163
+ `%cāœ… Non-Blocking Stylesheets (${nonBlockingCSS.length})`,
164
+ "color: #22c55e; font-weight: bold;"
165
+ );
166
+ console.table(
167
+ nonBlockingCSS.map((s) => ({
168
+ File: s.filename,
169
+ Media: s.media,
170
+ "Transfer Size": formatBytes(s.transferSize),
171
+ Rules: s.corsBlocked ? "CORS blocked" : s.ruleCount,
172
+ }))
173
+ );
174
+ console.groupEnd();
175
+ }
176
+
177
+ // Inline styles
178
+ if (inlineStyles.length > 0) {
179
+ console.log("");
180
+ const inlineBudgetPct = ((totalInlineBytes / criticalBudget) * 100).toFixed(0);
181
+ const inlineRating =
182
+ totalInlineBytes <= criticalBudget * 0.5
183
+ ? { label: "Good", color: "#22c55e" }
184
+ : totalInlineBytes <= criticalBudget
185
+ ? { label: "Acceptable", color: "#f59e0b" }
186
+ : { label: "Too Large", color: "#ef4444" };
187
+
188
+ console.group(
189
+ `%cšŸ“ Inline CSS (${inlineStyles.length} block${inlineStyles.length > 1 ? "s" : ""})`,
190
+ "color: #3b82f6; font-weight: bold;"
191
+ );
192
+ console.log(` Total size: ${formatBytes(totalInlineBytes)}`);
193
+ console.log(` Budget used: ${inlineBudgetPct}% of 14 KB`);
194
+ console.log(
195
+ ` Rating: %c${inlineRating.label}`,
196
+ `color: ${inlineRating.color}; font-weight: bold;`
197
+ );
198
+ console.table(
199
+ inlineStyles.map((s) => ({
200
+ "#": s.index,
201
+ Location: s.inHead ? "head" : "body",
202
+ Size: formatBytes(s.size),
203
+ "Rules (~)": s.ruleCount,
204
+ }))
205
+ );
206
+ console.groupEnd();
207
+ }
208
+
209
+ // Issues
210
+ const issues = [];
211
+
212
+ if (renderBlockingCSS.length > 0 && inlineStyles.length === 0) {
213
+ issues.push({
214
+ severity: "high",
215
+ message: "No critical CSS inlined — all CSS loads via render-blocking requests",
216
+ suggestion:
217
+ 'Extract above-the-fold styles into a <style> tag in <head> and defer the rest',
218
+ });
219
+ }
220
+
221
+ if (totalRenderBlockingDecoded > criticalBudget) {
222
+ issues.push({
223
+ severity: "high",
224
+ message: `Render-blocking CSS (${formatBytes(totalRenderBlockingDecoded)}) exceeds the 14 KB critical budget`,
225
+ suggestion:
226
+ "Inline critical CSS and defer non-critical stylesheets using the media/onload pattern",
227
+ });
228
+ }
229
+
230
+ const largeUnpreloaded = renderBlockingCSS.filter(
231
+ (s) => !s.preloaded && s.transferSize > 10 * 1024
232
+ );
233
+ if (largeUnpreloaded.length > 0) {
234
+ issues.push({
235
+ severity: "medium",
236
+ message: `${largeUnpreloaded.length} large render-blocking stylesheet(s) without preload`,
237
+ details: largeUnpreloaded.map(
238
+ (s) => `• ${s.filename} (${formatBytes(s.transferSize)})`
239
+ ),
240
+ suggestion: 'Add <link rel="preload" as="style" href="..."> to start download earlier',
241
+ });
242
+ }
243
+
244
+ if (totalInlineBytes > criticalBudget) {
245
+ issues.push({
246
+ severity: "high",
247
+ message: `Inline CSS (${formatBytes(totalInlineBytes)}) exceeds the 14 KB critical budget`,
248
+ suggestion:
249
+ "Keep inline critical CSS under 14 KB. Move non-critical styles to an external deferred file.",
250
+ });
251
+ }
252
+
253
+ const bodyInlineStyles = inlineStyles.filter((s) => !s.inHead);
254
+ if (bodyInlineStyles.length > 0) {
255
+ issues.push({
256
+ severity: "medium",
257
+ message: `${bodyInlineStyles.length} inline style block(s) found in <body>`,
258
+ suggestion:
259
+ "Move critical styles to <head> to avoid potential render-blocking reflows",
260
+ });
261
+ }
262
+
263
+ if (issues.length > 0) {
264
+ console.log("");
265
+ console.group("%cāš ļø Issues Found", "color: #f59e0b; font-weight: bold;");
266
+ issues.forEach((issue) => {
267
+ const icon =
268
+ issue.severity === "high" ? "šŸ”“" : issue.severity === "medium" ? "🟔" : "šŸ”µ";
269
+ console.log(`\n${icon} ${issue.message}`);
270
+ if (issue.details) issue.details.forEach((d) => console.log(` ${d}`));
271
+ console.log(` → ${issue.suggestion}`);
272
+ });
273
+ console.groupEnd();
274
+ } else {
275
+ console.log("");
276
+ console.log(
277
+ "%cāœ… No critical CSS issues detected.",
278
+ "color: #22c55e; font-weight: bold;"
279
+ );
280
+ }
281
+
282
+ // Best practices
283
+ console.log("");
284
+ console.group(
285
+ "%cšŸ“– Critical CSS Best Practices",
286
+ "color: #3b82f6; font-weight: bold;"
287
+ );
288
+ console.log("");
289
+ console.log("• Inline critical above-the-fold CSS in <head> (target: < 14 KB)");
290
+ console.log("• Defer non-critical CSS:");
291
+ console.log(
292
+ ' <link rel="stylesheet" href="styles.css" media="print" onload="this.media=\'all\'">'
293
+ );
294
+ console.log('• Preload critical external CSS: <link rel="preload" as="style" href="...">');
295
+ console.log("• Use Chrome DevTools Coverage tab (Ctrl+Shift+P → Coverage) to find unused CSS");
296
+ console.log("• Automate critical CSS extraction with tools like critical or Critters");
297
+ console.groupEnd();
298
+
299
+ console.groupEnd();
300
+
301
+ return {
302
+ script: "Critical-CSS-Detection",
303
+ status: "ok",
304
+ count: externalCSSData.length,
305
+ details: {
306
+ renderBlockingCount: renderBlockingCSS.length,
307
+ nonBlockingCount: nonBlockingCSS.length,
308
+ inlineCount: inlineStyles.length,
309
+ totalExternalTransferBytes: totalExternalTransfer,
310
+ renderBlockingDecodedBytes: totalRenderBlockingDecoded,
311
+ inlineBytes: totalInlineBytes,
312
+ criticalBudgetBytes: criticalBudget,
313
+ },
314
+ items: externalCSSData.map(s => ({
315
+ filename: s.filename,
316
+ media: s.media,
317
+ isRenderBlocking: s.isRenderBlocking,
318
+ transferBytes: s.transferSize,
319
+ decodedBytes: s.decodedSize,
320
+ ruleCount: s.ruleCount,
321
+ preloaded: s.preloaded,
322
+ corsBlocked: s.corsBlocked,
323
+ })),
324
+ issues: issues.map(i => ({
325
+ severity: i.severity === 'high' ? 'error' : 'warning',
326
+ message: i.message,
327
+ })),
328
+ };
329
+ })();
@@ -0,0 +1,179 @@
1
+ // Event Processing Time - Analyze page load phases
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const phases = [
6
+ { name: "Redirect", start: "redirectStart", end: "redirectEnd", icon: "šŸ”„" },
7
+ { name: "DNS Lookup", start: "domainLookupStart", end: "domainLookupEnd", icon: "šŸ”" },
8
+ { name: "TCP Connection", start: "connectStart", end: "connectEnd", icon: "šŸ”Œ" },
9
+ { name: "SSL/TLS", start: "secureConnectionStart", end: "connectEnd", icon: "šŸ”’" },
10
+ { name: "Request", start: "requestStart", end: "responseStart", icon: "šŸ“¤" },
11
+ { name: "Response", start: "responseStart", end: "responseEnd", icon: "šŸ“„" },
12
+ { name: "DOM Processing", start: "responseEnd", end: "domContentLoadedEventStart", icon: "šŸ—ļø" },
13
+ { name: "DOMContentLoaded", start: "domContentLoadedEventStart", end: "domContentLoadedEventEnd", icon: "šŸ“„" },
14
+ { name: "Resource Loading", start: "domContentLoadedEventEnd", end: "loadEventStart", icon: "šŸ“¦" },
15
+ { name: "Load Event", start: "loadEventStart", end: "loadEventEnd", icon: "āœ…" },
16
+ ];
17
+
18
+ new PerformanceObserver((list) => {
19
+ const [entry] = list.getEntries();
20
+
21
+ const formatMs = (ms) => ms.toFixed(2) + " ms";
22
+ const formatBar = (ms, total) => {
23
+ const pct = total > 0 ? (ms / total) * 100 : 0;
24
+ const width = Math.min(Math.round(pct / 2.5), 40);
25
+ return "ā–ˆ".repeat(width) + "ā–‘".repeat(Math.max(0, 20 - width));
26
+ };
27
+
28
+ // Calculate all phase durations
29
+ const phaseData = phases
30
+ .map((phase) => {
31
+ const start = entry[phase.start] || 0;
32
+ const end = entry[phase.end] || 0;
33
+ let duration = end - start;
34
+
35
+ // Handle SSL which shares end with TCP
36
+ if (phase.name === "SSL/TLS" && entry.secureConnectionStart > 0) {
37
+ duration = entry.connectEnd - entry.secureConnectionStart;
38
+ } else if (phase.name === "TCP Connection" && entry.secureConnectionStart > 0) {
39
+ duration = entry.secureConnectionStart - entry.connectStart;
40
+ }
41
+
42
+ return {
43
+ ...phase,
44
+ start,
45
+ end,
46
+ duration: Math.max(0, duration),
47
+ };
48
+ })
49
+ .filter((p) => p.duration > 0 || p.name === "Redirect");
50
+
51
+ const totalTime = entry.loadEventEnd - entry.startTime;
52
+ const networkTime = (entry.responseEnd || 0) - entry.startTime;
53
+ const processingTime = totalTime - networkTime;
54
+
55
+ // Rating based on total load time
56
+ let rating, color;
57
+ if (totalTime <= 2500) {
58
+ rating = "Fast";
59
+ color = "#22c55e";
60
+ } else if (totalTime <= 4000) {
61
+ rating = "Moderate";
62
+ color = "#f59e0b";
63
+ } else {
64
+ rating = "Slow";
65
+ color = "#ef4444";
66
+ }
67
+
68
+ console.group(
69
+ `%cā±ļø Page Load: ${totalTime.toFixed(0)}ms (${rating})`,
70
+ `color: ${color}; font-weight: bold; font-size: 14px;`
71
+ );
72
+
73
+ // Summary
74
+ console.log("");
75
+ console.log("%cSummary:", "font-weight: bold;");
76
+ console.log(` Network time: ${networkTime.toFixed(0)}ms (${((networkTime / totalTime) * 100).toFixed(0)}%)`);
77
+ console.log(` Processing time: ${processingTime.toFixed(0)}ms (${((processingTime / totalTime) * 100).toFixed(0)}%)`);
78
+ console.log(` Total: ${totalTime.toFixed(0)}ms`);
79
+
80
+ // Timeline visualization
81
+ console.log("");
82
+ console.log("%cTimeline:", "font-weight: bold;");
83
+
84
+ phaseData.forEach(({ name, duration, icon }) => {
85
+ const pct = (duration / totalTime) * 100;
86
+ const bar = formatBar(duration, totalTime);
87
+ const durationStr = formatMs(duration).padStart(12);
88
+ const pctStr = `${pct.toFixed(1)}%`.padStart(7);
89
+
90
+ // Color code long phases
91
+ let phaseColor = "";
92
+ if (duration > totalTime * 0.3) {
93
+ phaseColor = "color: #ef4444;"; // Red for >30%
94
+ } else if (duration > totalTime * 0.15) {
95
+ phaseColor = "color: #f59e0b;"; // Orange for >15%
96
+ }
97
+
98
+ console.log(
99
+ `%c${icon} ${name.padEnd(18)} ${durationStr} ${pctStr} ${bar}`,
100
+ phaseColor
101
+ );
102
+ });
103
+
104
+ // Key milestones
105
+ console.log("");
106
+ console.log("%cKey Milestones:", "font-weight: bold;");
107
+ console.log(` TTFB (Time to First Byte): ${entry.responseStart.toFixed(0)}ms`);
108
+ console.log(` DOMContentLoaded: ${entry.domContentLoadedEventEnd.toFixed(0)}ms`);
109
+ console.log(` Load Complete: ${entry.loadEventEnd.toFixed(0)}ms`);
110
+
111
+ // Find bottleneck
112
+ const longestPhase = phaseData.reduce((a, b) =>
113
+ a.duration > b.duration ? a : b
114
+ );
115
+
116
+ if (longestPhase.duration > totalTime * 0.25) {
117
+ console.log("");
118
+ console.log("%cšŸ’” Bottleneck Detected:", "color: #3b82f6; font-weight: bold;");
119
+ console.log(
120
+ ` ${longestPhase.name} takes ${((longestPhase.duration / totalTime) * 100).toFixed(0)}% of total load time`
121
+ );
122
+
123
+ const tips = {
124
+ "Redirect": "Minimize redirects. Update links to final URLs.",
125
+ "DNS Lookup": "Use DNS prefetching (<link rel='dns-prefetch'>). Consider faster DNS.",
126
+ "TCP Connection": "Enable HTTP/2 or HTTP/3. Use connection preconnect.",
127
+ "SSL/TLS": "Enable TLS 1.3. Optimize certificate chain. Use session resumption.",
128
+ "Request": "Optimize server response time. Add caching headers.",
129
+ "Response": "Enable compression. Reduce HTML size. Use streaming.",
130
+ "DOM Processing": "Simplify HTML structure. Defer non-critical scripts.",
131
+ "DOMContentLoaded": "Reduce synchronous JavaScript. Use defer/async.",
132
+ "Resource Loading": "Lazy load images. Prioritize critical resources.",
133
+ "Load Event": "Reduce page weight. Optimize resource loading order.",
134
+ };
135
+
136
+ console.log(` Tip: ${tips[longestPhase.name] || "Investigate this phase."}`);
137
+ }
138
+
139
+ console.groupEnd();
140
+ }).observe({ type: "navigation", buffered: true });
141
+
142
+ // Synchronous return for agent
143
+ const [navSync] = performance.getEntriesByType("navigation");
144
+ if (!navSync) return { script: "Event-Processing-Time", status: "error", error: "No navigation entry" };
145
+ const totalTimeSync = navSync.loadEventEnd - navSync.startTime;
146
+ const networkTimeSync = (navSync.responseEnd || 0) - navSync.startTime;
147
+ const ratingSync = totalTimeSync <= 2500 ? "good" : totalTimeSync <= 4000 ? "needs-improvement" : "poor";
148
+ const phasesSync = [
149
+ { key: "redirect", duration: Math.max(0, navSync.redirectEnd - navSync.redirectStart) },
150
+ { key: "dnsLookup", duration: Math.max(0, navSync.domainLookupEnd - navSync.domainLookupStart) },
151
+ { key: "tcpConnection", duration: Math.max(0, (navSync.secureConnectionStart > 0 ? navSync.secureConnectionStart : navSync.connectEnd) - navSync.connectStart) },
152
+ { key: "sslTls", duration: navSync.secureConnectionStart > 0 ? Math.max(0, navSync.connectEnd - navSync.secureConnectionStart) : 0 },
153
+ { key: "request", duration: Math.max(0, navSync.responseStart - navSync.requestStart) },
154
+ { key: "response", duration: Math.max(0, navSync.responseEnd - navSync.responseStart) },
155
+ { key: "domProcessing", duration: Math.max(0, navSync.domContentLoadedEventStart - navSync.responseEnd) },
156
+ { key: "domContentLoaded", duration: Math.max(0, navSync.domContentLoadedEventEnd - navSync.domContentLoadedEventStart) },
157
+ { key: "resourceLoading", duration: Math.max(0, navSync.loadEventStart - navSync.domContentLoadedEventEnd) },
158
+ { key: "loadEvent", duration: Math.max(0, navSync.loadEventEnd - navSync.loadEventStart) },
159
+ ];
160
+ const nonZeroPhases = phasesSync.filter((p) => p.duration > 0);
161
+ const slowestPhase = nonZeroPhases.length > 0 ? nonZeroPhases.reduce((a, b) => a.duration > b.duration ? a : b) : phasesSync[0];
162
+ return {
163
+ script: "Event-Processing-Time",
164
+ status: "ok",
165
+ value: Math.round(totalTimeSync),
166
+ unit: "ms",
167
+ rating: ratingSync,
168
+ thresholds: { good: 2500, needsImprovement: 4000 },
169
+ details: {
170
+ networkTimeMs: Math.round(networkTimeSync),
171
+ processingTimeMs: Math.round(totalTimeSync - networkTimeSync),
172
+ ttfbMs: Math.round(navSync.responseStart),
173
+ domContentLoadedMs: Math.round(navSync.domContentLoadedEventEnd),
174
+ loadCompleteMs: Math.round(navSync.loadEventEnd),
175
+ phases: Object.fromEntries(phasesSync.map((p) => [p.key, { value: Math.round(p.duration), unit: "ms" }])),
176
+ slowestPhase: slowestPhase.key,
177
+ },
178
+ };
179
+ })();
@@ -0,0 +1,101 @@
1
+ // FCP Analysis
2
+ // First Contentful Paint with render-blocking phase breakdown
3
+ // https://webperf-snippets.nucliweb.net
4
+
5
+ (() => {
6
+ const valueToRating = (ms) =>
7
+ ms <= 1800 ? "good" : ms <= 3000 ? "needs-improvement" : "poor";
8
+
9
+ const RATING = {
10
+ good: { icon: "🟢", color: "#0CCE6A" },
11
+ "needs-improvement": { icon: "🟔", color: "#FFA400" },
12
+ poor: { icon: "šŸ”“", color: "#FF4E42" },
13
+ };
14
+
15
+ const ms = (val) => `${Math.round(val)}ms`;
16
+
17
+ new PerformanceObserver((list) => {
18
+ const fcpEntry = list.getEntriesByName("first-contentful-paint")[0];
19
+ if (!fcpEntry) return;
20
+
21
+ const fcpTime = fcpEntry.startTime;
22
+ const rating = valueToRating(fcpTime);
23
+ const { icon, color } = RATING[rating];
24
+
25
+ const navEntry = performance.getEntriesByType("navigation")[0];
26
+ const ttfb = navEntry?.responseStart ?? 0;
27
+
28
+ // Render-blocking resources (Chrome 107+, requires renderBlockingStatus API)
29
+ const resources = performance.getEntriesByType("resource");
30
+ const blockingResources = resources.filter(
31
+ (r) => r.renderBlockingStatus === "blocking"
32
+ );
33
+
34
+ const lastBlockingEnd = blockingResources.length
35
+ ? Math.max(...blockingResources.map((r) => r.responseEnd))
36
+ : ttfb;
37
+
38
+ console.group(
39
+ `%cFCP: ${icon} ${(fcpTime / 1000).toFixed(2)}s (${rating})`,
40
+ `color: ${color}; font-weight: bold; font-size: 14px;`
41
+ );
42
+
43
+ // Phase breakdown
44
+ console.log("");
45
+ console.log("%cPhase Breakdown:", "font-weight: bold;");
46
+ console.log(` TTFB: ${ms(ttfb)}`);
47
+ if (blockingResources.length > 0) {
48
+ console.log(
49
+ ` Render-blocking load: ${ms(Math.max(0, lastBlockingEnd - ttfb))}`
50
+ );
51
+ }
52
+ console.log(
53
+ ` Render delay: ${ms(Math.max(0, fcpTime - lastBlockingEnd))}`
54
+ );
55
+
56
+ // Render-blocking summary
57
+ console.log("");
58
+ if (blockingResources.length > 0) {
59
+ const totalBlockingTime = Math.max(0, lastBlockingEnd - ttfb);
60
+ console.log(
61
+ `%c⚠ ${blockingResources.length} render-blocking resource(s) — adding ${ms(totalBlockingTime)} before paint`,
62
+ "color: #FFA400; font-weight: bold;"
63
+ );
64
+ blockingResources
65
+ .sort((a, b) => b.responseEnd - a.responseEnd)
66
+ .forEach((r) => {
67
+ const filename = r.name.split("/").pop().split("?")[0] || r.name;
68
+ const type = r.initiatorType === "link" ? "CSS" : "JS";
69
+ console.log(` [${type}] ${ms(r.duration)} ${filename}`);
70
+ });
71
+ console.log("");
72
+ console.log(
73
+ "%c→ Find Render-Blocking Resources: %chttps://webperf-snippets.nucliweb.net/Loading/Find-render-blocking-resources",
74
+ "color: #3b82f6;",
75
+ "color: #3b82f6; text-decoration: underline;"
76
+ );
77
+ } else {
78
+ console.log(
79
+ "%cāœ“ No render-blocking resources detected",
80
+ "color: #22c55e;"
81
+ );
82
+ }
83
+
84
+ console.groupEnd();
85
+ }).observe({ type: "paint", buffered: true });
86
+
87
+ // Synchronous return for agent
88
+ const fcpEntrySync = performance.getEntriesByName("first-contentful-paint")[0];
89
+ if (!fcpEntrySync) return { script: "FCP", status: "error", error: "No FCP entry yet" };
90
+ const fcpTimeSync = fcpEntrySync.startTime;
91
+ const ratingSync = valueToRating(fcpTimeSync);
92
+ return {
93
+ script: "FCP",
94
+ status: "ok",
95
+ metric: "FCP",
96
+ value: Math.round(fcpTimeSync),
97
+ unit: "ms",
98
+ rating: ratingSync,
99
+ thresholds: { good: 1800, needsImprovement: 3000 },
100
+ };
101
+ })();