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.
- package/README.md +185 -20
- 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 +52 -30
- 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/Find-render-blocking-resources.js +11 -13
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -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 +15 -0
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +55 -25
- 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 +59 -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 +216 -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 +22 -3
- 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 +1176 -0
- package/snippets/_shared/helpers.js +44 -0
- package/src/bin.js +86 -57
- package/src/crux.js +84 -0
- package/src/decision-tree.js +11 -1
- package/src/interactions.js +30 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +103 -0
- package/src/runner.js +81 -17
- package/src/workflows/loading.js +14 -0
|
@@ -2,33 +2,44 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
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
|
-
//
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
const
|
|
39
|
+
// A script whose URL cannot be parsed counts as first party
|
|
40
|
+
const isFirstPartyUrl = (url) => {
|
|
30
41
|
try {
|
|
31
|
-
return
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
})();
|