webperf-snippets 0.1.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 (69) hide show
  1. package/README.md +185 -20
  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/Find-render-blocking-resources.js +11 -13
  33. package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
  34. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  35. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
  36. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  37. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  38. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  39. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  40. package/snippets/Loading/Resource-Hints-Validation.js +15 -0
  41. package/snippets/Loading/Resource-Hints.js +29 -8
  42. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  43. package/snippets/Loading/Script-Loading.js +55 -25
  44. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  45. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  46. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  47. package/snippets/Loading/TTFB-Resources.js +59 -18
  48. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  49. package/snippets/Loading/TTFB.js +3 -2
  50. package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  52. package/snippets/Loading/WORKFLOWS.md +47 -1
  53. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  54. package/snippets/Media/Image-Element-Audit.js +22 -3
  55. package/snippets/Media/Oversized-Images.js +217 -0
  56. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  57. package/snippets/Media/WORKFLOWS.md +22 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  59. package/snippets/SCHEMA.md +1176 -0
  60. package/snippets/_shared/helpers.js +44 -0
  61. package/src/bin.js +86 -57
  62. package/src/crux.js +84 -0
  63. package/src/decision-tree.js +11 -1
  64. package/src/interactions.js +30 -0
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +20 -1
  67. package/src/reporters/markdown.js +103 -0
  68. package/src/runner.js +81 -17
  69. package/src/workflows/loading.js +14 -0
@@ -2,33 +2,44 @@
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
4
  (() => {
5
- const formatBytes = (bytes) => {
6
- if (!bytes || bytes === 0) return "-";
7
- const k = 1024;
8
- const sizes = ["B", "KB", "MB"];
9
- const i = Math.floor(Math.log(bytes) / Math.log(k));
10
- return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
11
- };
5
+ // @shared formatBytes
6
+ function formatBytes(bytes) {
7
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
8
+ if (bytes === 0) return "0 B";
9
+ const units = ["B", "KB", "MB", "GB"];
10
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
11
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
12
+ }
13
+ // @end-shared formatBytes
12
14
 
13
15
  const formatMs = (ms) => (ms > 0 ? ms.toFixed(0) + "ms" : "-");
14
16
 
15
- // Get current page's root domain for first/third party detection
16
- const getRootDomain = (hostname) => {
17
- const parts = hostname.split(".");
18
- if (parts.length > 2) {
19
- const sld = parts[parts.length - 2];
20
- if (["co", "com", "org", "net", "gov", "edu"].includes(sld) && sld.length <= 3) {
21
- return parts.slice(-3).join(".");
22
- }
23
- return parts.slice(-2).join(".");
24
- }
25
- return hostname;
26
- };
17
+ // @shared getRootDomain
18
+ function getRootDomain(hostname) {
19
+ const host = hostname.replace(/\.$/, "");
20
+ // An IP address has no registrable domain, so each address is its own root
21
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
22
+ const parts = host.split(".");
23
+ if (parts.length <= 2) return host;
24
+ // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
25
+ const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
26
+ const tld = parts[parts.length - 1];
27
+ const sld = parts[parts.length - 2];
28
+ if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
29
+ return parts.slice(-2).join(".");
30
+ }
31
+ // @end-shared getRootDomain
32
+
33
+ // @shared isFirstParty
34
+ function isFirstParty(hostname) {
35
+ return getRootDomain(hostname) === getRootDomain(location.hostname);
36
+ }
37
+ // @end-shared isFirstParty
27
38
 
28
- const currentDomain = getRootDomain(location.hostname);
29
- const isFirstParty = (url) => {
39
+ // A script whose URL cannot be parsed counts as first party
40
+ const isFirstPartyUrl = (url) => {
30
41
  try {
31
- return getRootDomain(new URL(url).hostname) === currentDomain;
42
+ return isFirstParty(new URL(url).hostname);
32
43
  } catch {
33
44
  return true;
34
45
  }
@@ -40,6 +51,8 @@
40
51
  if (r.initiatorType === "script") {
41
52
  resourceTiming.set(r.name, {
42
53
  size: r.transferSize || 0,
54
+ // Cross-origin scripts without Timing-Allow-Origin report every size as 0
55
+ sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
43
56
  duration: r.duration,
44
57
  startTime: r.startTime,
45
58
  });
@@ -74,7 +87,7 @@
74
87
  }
75
88
 
76
89
  const isBlocking = strategy === "blocking";
77
- const firstParty = isFirstParty(src);
90
+ const firstParty = isFirstPartyUrl(src);
78
91
 
79
92
  return {
80
93
  src,
@@ -87,6 +100,7 @@
87
100
  isAsync,
88
101
  isDefer,
89
102
  size: timing.size || 0,
103
+ sizeKnown: !!timing.sizeKnown,
90
104
  duration: timing.duration || 0,
91
105
  startTime: timing.startTime || 0,
92
106
  element: script,
@@ -299,13 +313,23 @@
299
313
 
300
314
  const agentRating = blockingInHead.length === 0 ? "good" :
301
315
  (blockingInHead.length <= 2 && thirdPartyBlocking.length === 0) ? "needs-improvement" : "poor";
316
+ // The list keeps the 50 scripts to look at first (render-blocking, then third-party, then the largest); count and details cover all of them
317
+ const MAX_ITEMS = 50;
318
+ const rankedScripts = [...scripts].sort(
319
+ (a, b) =>
320
+ Number(b.isBlocking) - Number(a.isBlocking) ||
321
+ Number(a.firstParty) - Number(b.firstParty) ||
322
+ b.size - a.size
323
+ );
302
324
  return {
303
325
  script: "Script-Loading",
304
326
  status: "ok",
305
327
  count: scripts.length,
328
+ corsLimitedAnalysis: scripts.some((s) => !s.sizeKnown),
306
329
  rating: agentRating,
307
330
  details: {
308
331
  totalSizeBytes: totalSize,
332
+ sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
309
333
  byStrategy: {
310
334
  blocking: blocking.length,
311
335
  async: asyncScripts.length,
@@ -318,15 +342,21 @@
318
342
  },
319
343
  thirdPartyBlockingCount: thirdPartyBlocking.length,
320
344
  },
321
- items: scripts.map((s) => ({
345
+ items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
322
346
  url: s.src,
323
347
  shortName: s.shortSrc,
324
348
  strategy: s.strategy,
325
349
  location: s.inHead ? "head" : "body",
326
350
  party: s.firstParty ? "first" : "third",
327
351
  sizeBytes: s.size,
352
+ sizeKnown: s.sizeKnown,
328
353
  durationMs: Math.round(s.duration),
329
354
  })),
330
- issues: issues.map((i) => ({ severity: i.severity, message: i.message })),
355
+ issues: [
356
+ ...issues.map((i) => ({ severity: i.severity, message: i.message })),
357
+ ...(scripts.some((s) => !s.sizeKnown)
358
+ ? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); total size is a lower bound` }]
359
+ : []),
360
+ ],
331
361
  };
332
362
  })();
@@ -0,0 +1,126 @@
1
+ // Server-Timing & Early Hints Viewer
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const SCRIPT = "Server-Timing-Early-Hints";
6
+ const MAX_ITEMS = 50;
7
+
8
+ const [nav] = performance.getEntriesByType("navigation");
9
+ if (!nav) {
10
+ return { script: SCRIPT, status: "error", error: "No navigation entry available" };
11
+ }
12
+ if (!Array.isArray(nav.serverTiming)) {
13
+ return {
14
+ script: SCRIPT,
15
+ status: "unsupported",
16
+ error: "Server-Timing entries are not supported in this browser",
17
+ };
18
+ }
19
+
20
+ const round = (n) => Math.round(n * 100) / 100;
21
+ // Times are relative to the time origin; a prerendered page starts at activationStart
22
+ const activationStart = Math.max(0, nav.activationStart ?? 0);
23
+ const rel = (t) => (t > 0 ? round(Math.max(0, t - activationStart)) : 0);
24
+
25
+ const toItem = (entry, source, url) => ({
26
+ source,
27
+ url,
28
+ name: entry.name,
29
+ durationMs: round(entry.duration || 0),
30
+ description: entry.description || "",
31
+ });
32
+
33
+ // Server-Timing entries of the document
34
+ const all = nav.serverTiming.map((m) => toItem(m, "navigation", location.href));
35
+
36
+ // Server-Timing entries of the resources. A cross-origin response without
37
+ // Timing-Allow-Origin exposes no Server-Timing and reports zero for every size, so it is
38
+ // counted as unreadable instead of being treated as a resource with no metrics.
39
+ let resourcesWithServerTiming = 0;
40
+ let corsRestrictedCount = 0;
41
+ for (const r of performance.getEntriesByType("resource")) {
42
+ if (r.serverTiming.length > 0) {
43
+ resourcesWithServerTiming++;
44
+ for (const m of r.serverTiming) all.push(toItem(m, "resource", r.name));
45
+ continue;
46
+ }
47
+ let crossOrigin = false;
48
+ try {
49
+ crossOrigin = new URL(r.name).origin !== location.origin;
50
+ } catch {
51
+ // Not a valid URL; treat as same-origin
52
+ }
53
+ if (crossOrigin && r.transferSize === 0 && r.encodedBodySize === 0 && r.decodedBodySize === 0) {
54
+ corsRestrictedCount++;
55
+ }
56
+ }
57
+
58
+ all.sort((a, b) => b.durationMs - a.durationMs);
59
+ const items = all.slice(0, MAX_ITEMS);
60
+
61
+ // 103 Early Hints. firstInterimResponseStart is 0 when the server sent no interim
62
+ // response, or when the browser did not expose it (Early Hints needs HTTP/2 or later).
63
+ const firstInterim = rel(nav.firstInterimResponseStart ?? 0);
64
+ const finalHeaders = rel(nav.finalResponseHeadersStart ?? nav.responseStart);
65
+ const earlyHintsReceived = firstInterim > 0;
66
+ const navigation = {
67
+ responseStartMs: rel(nav.responseStart),
68
+ firstInterimResponseStartMs: firstInterim,
69
+ finalResponseHeadersStartMs: finalHeaders,
70
+ earlyHintsReceived,
71
+ earlyHintsLeadMs: earlyHintsReceived ? round(Math.max(0, finalHeaders - firstInterim)) : 0,
72
+ serverTimingCount: nav.serverTiming.length,
73
+ };
74
+
75
+ const issues = [];
76
+ if (nav.serverTiming.length === 0) {
77
+ issues.push({
78
+ severity: "info",
79
+ message:
80
+ "The document response sends no Server-Timing header. Add one, for example Server-Timing: db;dur=53, to expose backend timings in DevTools and in the Performance API.",
81
+ });
82
+ }
83
+ if (corsRestrictedCount > 0) {
84
+ issues.push({
85
+ severity: "info",
86
+ message: `${corsRestrictedCount} cross-origin resource(s) hide their timing because they lack Timing-Allow-Origin, so their Server-Timing cannot be read. Add Timing-Allow-Origin to expose it.`,
87
+ });
88
+ }
89
+ if (!earlyHintsReceived) {
90
+ issues.push({
91
+ severity: "info",
92
+ message:
93
+ "No 103 Early Hints response was observed for the document. If the server can send Link preload or preconnect headers before the final response, Early Hints lets the browser start those fetches earlier.",
94
+ });
95
+ }
96
+
97
+ // Human output
98
+ console.group("%cServer-Timing & Early Hints", "font-weight: bold; font-size: 14px;");
99
+ console.log(
100
+ `Document: ${nav.serverTiming.length} Server-Timing metric(s); resources with Server-Timing: ${resourcesWithServerTiming}`
101
+ );
102
+ console.log(
103
+ earlyHintsReceived
104
+ ? `103 Early Hints at ${firstInterim} ms, final headers at ${finalHeaders} ms (${navigation.earlyHintsLeadMs} ms lead)`
105
+ : `No 103 Early Hints observed; final headers at ${finalHeaders} ms`
106
+ );
107
+ if (corsRestrictedCount > 0) {
108
+ console.log(`${corsRestrictedCount} cross-origin resource(s) unreadable (no Timing-Allow-Origin)`);
109
+ }
110
+ if (items.length > 0) console.table(items);
111
+ console.groupEnd();
112
+
113
+ return {
114
+ script: SCRIPT,
115
+ status: "ok",
116
+ count: all.length,
117
+ corsLimitedAnalysis: corsRestrictedCount > 0,
118
+ details: {
119
+ navigation,
120
+ resourcesWithServerTiming,
121
+ corsRestrictedCount,
122
+ },
123
+ items,
124
+ issues,
125
+ };
126
+ })();
@@ -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
  })();