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.
- package/README.md +139 -0
- package/package.json +54 -0
- package/snippets/CoreWebVitals/CLS.js +88 -0
- package/snippets/CoreWebVitals/INP.js +432 -0
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
- package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
- package/snippets/CoreWebVitals/LCP.js +135 -0
- package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
- package/snippets/Interaction/Interactions.js +220 -0
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
- package/snippets/Interaction/Long-Animation-Frames.js +298 -0
- package/snippets/Interaction/LongTask.js +203 -0
- package/snippets/Interaction/Scroll-Performance.js +359 -0
- package/snippets/Interaction/WORKFLOWS.md +205 -0
- package/snippets/Loading/Back-Forward-Cache.js +473 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
- package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
- package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
- package/snippets/Loading/Content-Visibility.js +352 -0
- package/snippets/Loading/Critical-CSS-Detection.js +329 -0
- package/snippets/Loading/Event-Processing-Time.js +179 -0
- package/snippets/Loading/FCP.js +101 -0
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
- package/snippets/Loading/Find-render-blocking-resources.js +161 -0
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
- package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
- package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
- package/snippets/Loading/Priority-Hints-Audit.js +336 -0
- package/snippets/Loading/Resource-Hints-Validation.js +503 -0
- package/snippets/Loading/Resource-Hints.js +400 -0
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
- package/snippets/Loading/Script-Loading.js +332 -0
- package/snippets/Loading/Service-Worker-Analysis.js +192 -0
- package/snippets/Loading/TTFB-Resources.js +115 -0
- package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
- package/snippets/Loading/TTFB.js +37 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
- package/snippets/Loading/WORKFLOWS.md +163 -0
- package/snippets/Media/Image-Element-Audit.js +350 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
- package/snippets/Media/Video-Element-Audit.js +282 -0
- package/snippets/Media/WORKFLOWS.md +190 -0
- package/snippets/README.md +98 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
- package/snippets/Resources/WORKFLOWS.md +173 -0
- package/src/bin.js +229 -0
- package/src/decision-tree.js +19 -0
- package/src/load-snippet.js +23 -0
- package/src/reporters/human.js +179 -0
- package/src/reporters/json.js +3 -0
- package/src/runner.js +61 -0
- package/src/workflows/audit.js +21 -0
- package/src/workflows/cwv.js +10 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
// Cache Strategy Analysis
|
|
2
|
+
// Analyzes HTTP caching strategies, CDN hit rates, and cache anti-patterns across all page resources
|
|
3
|
+
// https://webperf-snippets.nucliweb.net
|
|
4
|
+
|
|
5
|
+
(async () => {
|
|
6
|
+
// --- Helpers ---
|
|
7
|
+
|
|
8
|
+
function getRootDomain(hostname) {
|
|
9
|
+
const parts = hostname.split(".");
|
|
10
|
+
if (parts.length > 2) {
|
|
11
|
+
const sld = parts[parts.length - 2];
|
|
12
|
+
if (
|
|
13
|
+
sld.length <= 3 &&
|
|
14
|
+
["co", "com", "org", "net", "gov", "edu"].includes(sld)
|
|
15
|
+
) {
|
|
16
|
+
return parts.slice(-3).join(".");
|
|
17
|
+
}
|
|
18
|
+
return parts.slice(-2).join(".");
|
|
19
|
+
}
|
|
20
|
+
return hostname;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const currentRootDomain = getRootDomain(location.hostname);
|
|
24
|
+
|
|
25
|
+
function isFirstParty(hostname) {
|
|
26
|
+
return getRootDomain(hostname) === currentRootDomain;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatBytes(bytes) {
|
|
30
|
+
if (!bytes || bytes === 0) return "N/A";
|
|
31
|
+
const k = 1024;
|
|
32
|
+
const sizes = ["B", "KB", "MB"];
|
|
33
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
34
|
+
return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getResourceType(entry) {
|
|
38
|
+
// Use file extension as primary signal â initiatorType reflects the loader
|
|
39
|
+
// (e.g. a font loaded from CSS has initiatorType "css", not "font")
|
|
40
|
+
const path = (entry.name || "").split("?")[0].toLowerCase();
|
|
41
|
+
if (/\.(woff2?|ttf|otf|eot)$/.test(path)) return "font";
|
|
42
|
+
if (/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/.test(path)) return "image";
|
|
43
|
+
if (/\.(mp4|webm|ogg|mov)$/.test(path)) return "video";
|
|
44
|
+
if (/\.(mp3|wav|flac|aac)$/.test(path)) return "audio";
|
|
45
|
+
if (/\.css$/.test(path)) return "style";
|
|
46
|
+
if (/\.js$/.test(path)) return "script";
|
|
47
|
+
|
|
48
|
+
// Fall back to initiatorType map
|
|
49
|
+
const map = {
|
|
50
|
+
script: "script",
|
|
51
|
+
link: "style",
|
|
52
|
+
css: "style",
|
|
53
|
+
img: "image",
|
|
54
|
+
image: "image",
|
|
55
|
+
video: "video",
|
|
56
|
+
audio: "audio",
|
|
57
|
+
font: "font",
|
|
58
|
+
fetch: "fetch",
|
|
59
|
+
xmlhttprequest: "xhr",
|
|
60
|
+
iframe: "iframe",
|
|
61
|
+
other: "other",
|
|
62
|
+
};
|
|
63
|
+
return map[entry.initiatorType] || entry.initiatorType || "other";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectHashInUrl(url) {
|
|
67
|
+
// Detects content-hash patterns in file paths or query strings.
|
|
68
|
+
// Real content hashes are hex-only (a-f0-9) â broad alphanumeric patterns
|
|
69
|
+
// produce false positives on descriptive filenames like "sprite-placeholder.png".
|
|
70
|
+
const hashPatterns = [
|
|
71
|
+
// Hex hash embedded in filename: .abc12345def.ext or -abc12345def.ext (min 8 hex chars)
|
|
72
|
+
/[.-][a-f0-9]{8,32}\.(js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)(\?|$)/,
|
|
73
|
+
// Query string version params: ?v=abc123, ?hash=..., ?rev=...
|
|
74
|
+
/[?&](v|version|hash|rev|_v)=[a-zA-Z0-9._-]+/,
|
|
75
|
+
];
|
|
76
|
+
return hashPatterns.some((re) => re.test(url));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isStaticAsset(type) {
|
|
80
|
+
return ["script", "style", "font", "image"].includes(type);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseCacheControl(headerValue) {
|
|
84
|
+
const directives = {};
|
|
85
|
+
if (!headerValue) return directives;
|
|
86
|
+
for (const part of headerValue.split(",")) {
|
|
87
|
+
const [key, val] = part.trim().split("=");
|
|
88
|
+
const k = key.trim().toLowerCase();
|
|
89
|
+
directives[k] = val !== undefined ? val.trim().replace(/"/g, "") : true;
|
|
90
|
+
}
|
|
91
|
+
return directives;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function classifyCacheStrategy(headers) {
|
|
95
|
+
if (!headers) return "unknown";
|
|
96
|
+
|
|
97
|
+
const cc = parseCacheControl(headers["cache-control"] || "");
|
|
98
|
+
const hasEtag = !!headers["etag"];
|
|
99
|
+
const hasLastModified = !!headers["last-modified"];
|
|
100
|
+
const hasExpires = !!headers["expires"];
|
|
101
|
+
const hasValidators = hasEtag || hasLastModified;
|
|
102
|
+
|
|
103
|
+
if (cc["no-store"]) return "no-store";
|
|
104
|
+
if (cc["no-cache"]) return "no-cache";
|
|
105
|
+
if (cc["immutable"]) return "immutable";
|
|
106
|
+
if (cc["stale-while-revalidate"]) return "swr";
|
|
107
|
+
if (cc["must-revalidate"] && hasValidators) return "must-revalidate";
|
|
108
|
+
|
|
109
|
+
const maxAge =
|
|
110
|
+
cc["max-age"] !== undefined ? parseInt(cc["max-age"], 10) : null;
|
|
111
|
+
const sMaxAge =
|
|
112
|
+
cc["s-maxage"] !== undefined ? parseInt(cc["s-maxage"], 10) : null;
|
|
113
|
+
const effectiveMaxAge = sMaxAge ?? maxAge;
|
|
114
|
+
|
|
115
|
+
if (effectiveMaxAge !== null) {
|
|
116
|
+
if (effectiveMaxAge === 0) return "no-cache";
|
|
117
|
+
if (effectiveMaxAge < 3600) return "short";
|
|
118
|
+
if (effectiveMaxAge <= 86400) return "medium";
|
|
119
|
+
return "long";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (hasExpires && !headers["cache-control"]) return "expires-only";
|
|
123
|
+
if (Object.keys(cc).length === 0 && !hasExpires) return "none";
|
|
124
|
+
|
|
125
|
+
return "unknown";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function detectCdnStatus(headers) {
|
|
129
|
+
if (!headers) return { cdn: "unknown", status: "unknown" };
|
|
130
|
+
|
|
131
|
+
if (headers["cf-cache-status"]) {
|
|
132
|
+
return { cdn: "Cloudflare", status: headers["cf-cache-status"] };
|
|
133
|
+
}
|
|
134
|
+
if (headers["x-vercel-cache"]) {
|
|
135
|
+
return { cdn: "Vercel", status: headers["x-vercel-cache"] };
|
|
136
|
+
}
|
|
137
|
+
if (headers["x-cache"]) {
|
|
138
|
+
const val = headers["x-cache"].toUpperCase();
|
|
139
|
+
const cdn = headers["x-amz-cf-id"] ? "CloudFront" : "CDN";
|
|
140
|
+
return { cdn, status: val.includes("HIT") ? "HIT" : "MISS" };
|
|
141
|
+
}
|
|
142
|
+
if (headers["age"]) {
|
|
143
|
+
return {
|
|
144
|
+
cdn: "Proxy/CDN",
|
|
145
|
+
status: `HIT (Age: ${headers["age"]}s)`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (headers["via"]) {
|
|
149
|
+
return { cdn: `Proxy (${headers["via"]})`, status: "proxied" };
|
|
150
|
+
}
|
|
151
|
+
if (headers["server-timing"]) {
|
|
152
|
+
const st = headers["server-timing"].toLowerCase();
|
|
153
|
+
if (st.includes("cdn-cache") || st.includes("cache;")) {
|
|
154
|
+
return {
|
|
155
|
+
cdn: "CDN (Server-Timing)",
|
|
156
|
+
status: st.includes("hit") ? "HIT" : "MISS",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { cdn: "none", status: "no-cdn-headers" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Phase 1: Gather resource entries ---
|
|
164
|
+
|
|
165
|
+
// Exclude HEAD/aborted-GET entries injected by this snippet's own fetch calls
|
|
166
|
+
// on previous runs. Those entries have no body (encodedBodySize=0, decodedBodySize=0)
|
|
167
|
+
// but did transfer bytes (transferSize>0, i.e. only response headers were received).
|
|
168
|
+
// Real page resources always have encodedBodySize > 0 (or transferSize = 0 if cached).
|
|
169
|
+
const resources = performance.getEntriesByType("resource").filter(
|
|
170
|
+
(r) => !(r.encodedBodySize === 0 && r.decodedBodySize === 0 && r.transferSize > 0)
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Detect duplicate URLs
|
|
174
|
+
const urlCounts = new Map();
|
|
175
|
+
for (const r of resources) {
|
|
176
|
+
urlCounts.set(r.name, (urlCounts.get(r.name) || 0) + 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const entries = resources.map((r) => {
|
|
180
|
+
let url;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(r.name);
|
|
183
|
+
} catch {
|
|
184
|
+
url = { hostname: location.hostname, pathname: r.name };
|
|
185
|
+
}
|
|
186
|
+
const firstParty = isFirstParty(url.hostname);
|
|
187
|
+
const isCached = r.encodedBodySize > 0 && r.transferSize === 0;
|
|
188
|
+
const isCorsRestricted =
|
|
189
|
+
r.encodedBodySize === 0 && r.transferSize === 0 && !firstParty;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
url: r.name,
|
|
193
|
+
shortName:
|
|
194
|
+
(url.pathname || r.name).split("/").filter(Boolean).pop() ||
|
|
195
|
+
url.hostname,
|
|
196
|
+
host: url.hostname,
|
|
197
|
+
firstParty,
|
|
198
|
+
type: getResourceType(r),
|
|
199
|
+
transferSize: r.transferSize || 0,
|
|
200
|
+
encodedBodySize: r.encodedBodySize || 0,
|
|
201
|
+
decodedBodySize: r.decodedBodySize || 0,
|
|
202
|
+
isCached,
|
|
203
|
+
isCorsRestricted,
|
|
204
|
+
nextHopProtocol: r.nextHopProtocol || "unknown",
|
|
205
|
+
duration: r.duration,
|
|
206
|
+
hasHash: detectHashInUrl(r.name),
|
|
207
|
+
isDuplicate: (urlCounts.get(r.name) || 1) > 1,
|
|
208
|
+
// Populated in Phase 2
|
|
209
|
+
headers: null,
|
|
210
|
+
cacheStrategy: null,
|
|
211
|
+
cdnInfo: null,
|
|
212
|
+
antiPatterns: [],
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// --- Phase 2: Fetch headers for same-origin static assets ---
|
|
217
|
+
|
|
218
|
+
// Scope: true same-origin only (same scheme+host+port, not just root domain).
|
|
219
|
+
// isFirstParty() matches root domains (api.fedex.com = first party for www.fedex.com)
|
|
220
|
+
// but the browser enforces CORS per full origin, causing console errors.
|
|
221
|
+
// Also limit to static asset types â API endpoints and iframes tend to reject HEAD
|
|
222
|
+
// (503/403) and pollute the console without adding useful cache strategy data.
|
|
223
|
+
const FETCHABLE_TYPES = new Set(["script", "style", "font", "image"]);
|
|
224
|
+
|
|
225
|
+
const fetchableEntries = entries.filter(
|
|
226
|
+
(e) => e.url.startsWith(location.origin + "/") && FETCHABLE_TYPES.has(e.type)
|
|
227
|
+
);
|
|
228
|
+
const uniqueUrls = [...new Set(fetchableEntries.map((e) => e.url))];
|
|
229
|
+
|
|
230
|
+
const headerCache = new Map();
|
|
231
|
+
const BATCH_SIZE = 10;
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < uniqueUrls.length; i += BATCH_SIZE) {
|
|
234
|
+
const batch = uniqueUrls.slice(i, i + BATCH_SIZE);
|
|
235
|
+
await Promise.allSettled(
|
|
236
|
+
batch.map(async (url) => {
|
|
237
|
+
try {
|
|
238
|
+
const resp = await fetch(url, { method: "HEAD", cache: "no-store" });
|
|
239
|
+
if (!resp.ok) {
|
|
240
|
+
headerCache.set(url, null);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const headers = {};
|
|
244
|
+
for (const [key, value] of resp.headers) {
|
|
245
|
+
headers[key.toLowerCase()] = value;
|
|
246
|
+
}
|
|
247
|
+
headerCache.set(url, headers);
|
|
248
|
+
} catch {
|
|
249
|
+
// Network error or CORS â skip silently, Tier 1 heuristics apply
|
|
250
|
+
headerCache.set(url, null);
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Attach headers to entries
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
entry.headers = headerCache.get(entry.url) || null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Phase 3: Classify cache strategies ---
|
|
262
|
+
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (entry.headers) {
|
|
265
|
+
entry.cacheStrategy = classifyCacheStrategy(entry.headers);
|
|
266
|
+
} else if (entry.isCorsRestricted) {
|
|
267
|
+
entry.cacheStrategy = "unknown (CORS)";
|
|
268
|
+
} else if (entry.isCached) {
|
|
269
|
+
// Tier 1 heuristic: no headers but we know it was cached
|
|
270
|
+
entry.cacheStrategy = "cached (inferred)";
|
|
271
|
+
} else {
|
|
272
|
+
entry.cacheStrategy = "unknown";
|
|
273
|
+
}
|
|
274
|
+
entry.cdnInfo = detectCdnStatus(entry.headers);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- Phase 4: Detect anti-patterns ---
|
|
278
|
+
|
|
279
|
+
const EFFECTIVE_CACHE_STRATEGIES = new Set([
|
|
280
|
+
"immutable",
|
|
281
|
+
"long",
|
|
282
|
+
"medium",
|
|
283
|
+
"swr",
|
|
284
|
+
"must-revalidate",
|
|
285
|
+
"cached (inferred)",
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (!entry.headers) continue;
|
|
290
|
+
const cc = parseCacheControl(entry.headers["cache-control"] || "");
|
|
291
|
+
const maxAge =
|
|
292
|
+
cc["max-age"] !== undefined ? parseInt(cc["max-age"], 10) : null;
|
|
293
|
+
const strategy = entry.cacheStrategy;
|
|
294
|
+
const type = entry.type;
|
|
295
|
+
|
|
296
|
+
if (isStaticAsset(type) && cc["no-store"]) {
|
|
297
|
+
entry.antiPatterns.push({
|
|
298
|
+
id: "static-no-store",
|
|
299
|
+
severity: "error",
|
|
300
|
+
message: `${type} asset with no-store â static assets should be cacheable`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) {
|
|
305
|
+
entry.antiPatterns.push({
|
|
306
|
+
id: "versioned-short-cache",
|
|
307
|
+
severity: "warning",
|
|
308
|
+
message: `Versioned URL (hash detected) but max-age=${maxAge}s â consider long cache or immutable`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) {
|
|
313
|
+
entry.antiPatterns.push({
|
|
314
|
+
id: "versioned-not-immutable",
|
|
315
|
+
severity: "info",
|
|
316
|
+
message: `Versioned URL (hash detected) without Cache-Control: immutable`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (
|
|
321
|
+
entry.decodedBodySize > 100 * 1024 &&
|
|
322
|
+
["none", "no-store", "no-cache"].includes(strategy)
|
|
323
|
+
) {
|
|
324
|
+
entry.antiPatterns.push({
|
|
325
|
+
id: "large-no-cache",
|
|
326
|
+
severity: "error",
|
|
327
|
+
message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (entry.headers["expires"] && !entry.headers["cache-control"]) {
|
|
332
|
+
entry.antiPatterns.push({
|
|
333
|
+
id: "expires-without-cc",
|
|
334
|
+
severity: "warning",
|
|
335
|
+
message: `Uses Expires header without Cache-Control (outdated pattern)`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (isStaticAsset(type) && maxAge === 0) {
|
|
340
|
+
entry.antiPatterns.push({
|
|
341
|
+
id: "maxage-zero-static",
|
|
342
|
+
severity: "warning",
|
|
343
|
+
message: `Static asset with max-age=0 â forces revalidation on every request`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (
|
|
348
|
+
isStaticAsset(type) &&
|
|
349
|
+
maxAge !== null &&
|
|
350
|
+
maxAge > 0 &&
|
|
351
|
+
maxAge < 3600 &&
|
|
352
|
+
!cc["no-store"]
|
|
353
|
+
) {
|
|
354
|
+
entry.antiPatterns.push({
|
|
355
|
+
id: "short-cache-static",
|
|
356
|
+
severity: "warning",
|
|
357
|
+
message: `Static asset with max-age=${maxAge}s (< 1h) â consider a longer cache duration`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Phase 5: CDN analysis ---
|
|
363
|
+
|
|
364
|
+
const cdnEntries = entries.filter(
|
|
365
|
+
(e) => e.cdnInfo && e.cdnInfo.cdn !== "none" && e.cdnInfo.cdn !== "unknown"
|
|
366
|
+
);
|
|
367
|
+
const cdnHits = cdnEntries.filter((e) =>
|
|
368
|
+
e.cdnInfo.status.toUpperCase().startsWith("HIT")
|
|
369
|
+
).length;
|
|
370
|
+
const cdnHitRate =
|
|
371
|
+
cdnEntries.length > 0
|
|
372
|
+
? Math.round((cdnHits / cdnEntries.length) * 100)
|
|
373
|
+
: null;
|
|
374
|
+
|
|
375
|
+
// --- Phase 6: Value-add metrics ---
|
|
376
|
+
|
|
377
|
+
const effectiveEntries = entries.filter((e) =>
|
|
378
|
+
EFFECTIVE_CACHE_STRATEGIES.has(e.cacheStrategy)
|
|
379
|
+
);
|
|
380
|
+
const cacheEfficiencyPercent =
|
|
381
|
+
entries.length > 0
|
|
382
|
+
? Math.round((effectiveEntries.length / entries.length) * 100)
|
|
383
|
+
: 0;
|
|
384
|
+
|
|
385
|
+
const bandwidthSavedBytes = entries
|
|
386
|
+
.filter((e) => e.isCached)
|
|
387
|
+
.reduce((sum, e) => sum + e.decodedBodySize, 0);
|
|
388
|
+
|
|
389
|
+
// Protocol distribution
|
|
390
|
+
const protocolDistribution = {};
|
|
391
|
+
for (const entry of entries) {
|
|
392
|
+
const proto = entry.nextHopProtocol || "unknown";
|
|
393
|
+
protocolDistribution[proto] = (protocolDistribution[proto] || 0) + 1;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Uncompressed text resources (encoded â decoded, size > 1KB, text type)
|
|
397
|
+
const uncompressedResources = entries.filter(
|
|
398
|
+
(e) =>
|
|
399
|
+
e.encodedBodySize > 1024 &&
|
|
400
|
+
e.decodedBodySize > 0 &&
|
|
401
|
+
e.encodedBodySize / e.decodedBodySize > 0.9 &&
|
|
402
|
+
["script", "style", "fetch", "xhr"].includes(e.type)
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Duplicate resources â group by URL and track whether any load was uncached
|
|
406
|
+
const uniqueDuplicates = [...new Set(
|
|
407
|
+
entries.filter((e) => e.isDuplicate).map((e) => e.url)
|
|
408
|
+
)].map((url) => {
|
|
409
|
+
const group = entries.filter((e) => e.url === url);
|
|
410
|
+
const uncachedLoads = group.filter((e) => !e.isCached).length;
|
|
411
|
+
return {
|
|
412
|
+
url,
|
|
413
|
+
shortName: group[0]?.shortName || url,
|
|
414
|
+
count: urlCounts.get(url),
|
|
415
|
+
type: group[0]?.type || "unknown",
|
|
416
|
+
uncachedLoads,
|
|
417
|
+
// Only "costly" if at least one load went to the network
|
|
418
|
+
costly: uncachedLoads > 0,
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
// Separate costly duplicates (network requests) from cached duplicates (cheap)
|
|
422
|
+
const costlyDuplicates = uniqueDuplicates.filter((d) => d.costly);
|
|
423
|
+
const cachedDuplicates = uniqueDuplicates.filter((d) => !d.costly);
|
|
424
|
+
|
|
425
|
+
// --- Phase 7: Console output ---
|
|
426
|
+
|
|
427
|
+
const allAntiPatterns = entries.flatMap((e) =>
|
|
428
|
+
e.antiPatterns.map((ap) => ({ ...ap, resource: e.shortName, url: e.url }))
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const strategyGroups = {};
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
const s = entry.cacheStrategy;
|
|
434
|
+
if (!strategyGroups[s]) strategyGroups[s] = 0;
|
|
435
|
+
strategyGroups[s]++;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const noCacheEntries = entries.filter((e) =>
|
|
439
|
+
["none", "no-store", "no-cache"].includes(e.cacheStrategy)
|
|
440
|
+
);
|
|
441
|
+
const shortCacheEntries = entries.filter(
|
|
442
|
+
(e) => e.cacheStrategy === "short"
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const headersAnalyzed = entries.filter((e) => e.headers !== null).length;
|
|
446
|
+
const corsRestricted = entries.filter((e) => e.isCorsRestricted).length;
|
|
447
|
+
|
|
448
|
+
// Resources excluded from header analysis â cross-origin or non-static types.
|
|
449
|
+
// Cross-origin (different scheme/host/port) cannot be fetched due to CORS.
|
|
450
|
+
// Non-static types (xhr, fetch, iframe, beaconâĻ) are excluded intentionally:
|
|
451
|
+
// their servers commonly reject HEAD requests (4xx/5xx) and their caching
|
|
452
|
+
// rules differ from static assets â they are still visible in Tier 1 data.
|
|
453
|
+
const excludedFromHeaders = entries.filter(
|
|
454
|
+
(e) =>
|
|
455
|
+
e.headers === null &&
|
|
456
|
+
!e.isCorsRestricted &&
|
|
457
|
+
(!e.url.startsWith(location.origin + "/") ||
|
|
458
|
+
!FETCHABLE_TYPES.has(e.type))
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
console.group(
|
|
462
|
+
"%cđ Cache Strategy Analysis",
|
|
463
|
+
"font-weight: bold; font-size: 14px;"
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
console.log("");
|
|
467
|
+
console.log("%cđ Summary", "font-weight: bold;");
|
|
468
|
+
console.log(` Total resources: ${entries.length}`);
|
|
469
|
+
console.log(
|
|
470
|
+
` Headers analyzed: ${headersAnalyzed} | CORS-restricted: ${corsRestricted} | Excluded (non-static/cross-origin): ${excludedFromHeaders.length}`
|
|
471
|
+
);
|
|
472
|
+
if (excludedFromHeaders.length > 0) {
|
|
473
|
+
const excludedTypes = [...new Set(excludedFromHeaders.map((e) => e.type))].join(", ");
|
|
474
|
+
console.log(
|
|
475
|
+
` âšī¸ Excluded resources (${excludedTypes}) are analyzed with Tier 1 data only (Performance API) â header inspection requires same-origin static assets due to CORS restrictions.`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
console.log(` Cache efficiency: ${cacheEfficiencyPercent}%`);
|
|
479
|
+
console.log(` Bandwidth saved by cache: ${formatBytes(bandwidthSavedBytes)}`);
|
|
480
|
+
if (cdnHitRate !== null) {
|
|
481
|
+
console.log(
|
|
482
|
+
` CDN hit rate: ${cdnHitRate}% (${cdnHits}/${cdnEntries.length} resources)`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
if (allAntiPatterns.length > 0) {
|
|
486
|
+
const errors = allAntiPatterns.filter((p) => p.severity === "error").length;
|
|
487
|
+
const warnings = allAntiPatterns.filter(
|
|
488
|
+
(p) => p.severity === "warning"
|
|
489
|
+
).length;
|
|
490
|
+
const infos = allAntiPatterns.filter((p) => p.severity === "info").length;
|
|
491
|
+
console.log(
|
|
492
|
+
` Anti-patterns: ${allAntiPatterns.length} (${errors} errors, ${warnings} warnings, ${infos} info)`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Strategy distribution
|
|
497
|
+
console.log("");
|
|
498
|
+
console.group("%cđ Cache Strategy Distribution", "font-weight: bold;");
|
|
499
|
+
const strategyOrder = [
|
|
500
|
+
"immutable",
|
|
501
|
+
"long",
|
|
502
|
+
"medium",
|
|
503
|
+
"short",
|
|
504
|
+
"swr",
|
|
505
|
+
"must-revalidate",
|
|
506
|
+
"no-cache",
|
|
507
|
+
"no-store",
|
|
508
|
+
"expires-only",
|
|
509
|
+
"none",
|
|
510
|
+
"cached (inferred)",
|
|
511
|
+
"unknown (CORS)",
|
|
512
|
+
"unknown",
|
|
513
|
+
];
|
|
514
|
+
const strategyTable = strategyOrder
|
|
515
|
+
.filter((s) => strategyGroups[s])
|
|
516
|
+
.map((s) => ({
|
|
517
|
+
Strategy: s,
|
|
518
|
+
Count: strategyGroups[s],
|
|
519
|
+
"% of Total": `${Math.round((strategyGroups[s] / entries.length) * 100)}%`,
|
|
520
|
+
Effective: EFFECTIVE_CACHE_STRATEGIES.has(s) ? "â
" : "â",
|
|
521
|
+
}));
|
|
522
|
+
console.table(strategyTable);
|
|
523
|
+
console.groupEnd();
|
|
524
|
+
|
|
525
|
+
// Resources without cache
|
|
526
|
+
if (noCacheEntries.length > 0) {
|
|
527
|
+
console.log("");
|
|
528
|
+
console.group(
|
|
529
|
+
`%cđĢ Resources Without Cache (${noCacheEntries.length})`,
|
|
530
|
+
"font-weight: bold; color: #ef4444;"
|
|
531
|
+
);
|
|
532
|
+
console.table(
|
|
533
|
+
noCacheEntries.map((e) => ({
|
|
534
|
+
Resource: e.shortName,
|
|
535
|
+
Type: e.type,
|
|
536
|
+
Strategy: e.cacheStrategy,
|
|
537
|
+
Size: formatBytes(e.decodedBodySize),
|
|
538
|
+
Host: e.host,
|
|
539
|
+
"First Party": e.firstParty ? "â
" : "â",
|
|
540
|
+
}))
|
|
541
|
+
);
|
|
542
|
+
console.groupEnd();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Short cache resources
|
|
546
|
+
if (shortCacheEntries.length > 0) {
|
|
547
|
+
console.log("");
|
|
548
|
+
console.group(
|
|
549
|
+
`%câą Short Cache Resources (${shortCacheEntries.length})`,
|
|
550
|
+
"font-weight: bold; color: #f59e0b;"
|
|
551
|
+
);
|
|
552
|
+
console.table(
|
|
553
|
+
shortCacheEntries.map((e) => {
|
|
554
|
+
const cc = parseCacheControl(e.headers?.["cache-control"] || "");
|
|
555
|
+
return {
|
|
556
|
+
Resource: e.shortName,
|
|
557
|
+
Type: e.type,
|
|
558
|
+
"max-age": cc["max-age"] ? `${cc["max-age"]}s` : "N/A",
|
|
559
|
+
Size: formatBytes(e.decodedBodySize),
|
|
560
|
+
"Has Hash": e.hasHash ? "â
" : "â",
|
|
561
|
+
Host: e.host,
|
|
562
|
+
};
|
|
563
|
+
})
|
|
564
|
+
);
|
|
565
|
+
console.groupEnd();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Anti-patterns
|
|
569
|
+
if (allAntiPatterns.length > 0) {
|
|
570
|
+
console.log("");
|
|
571
|
+
console.group(
|
|
572
|
+
`%câ ī¸ Cache Anti-patterns (${allAntiPatterns.length})`,
|
|
573
|
+
"font-weight: bold; color: #f59e0b;"
|
|
574
|
+
);
|
|
575
|
+
console.groupCollapsed("đ Legend");
|
|
576
|
+
console.table([
|
|
577
|
+
{ Pattern: "static-no-store", Severity: "đ´ error", Meaning: "JS/CSS/font/image with no-store â static assets should always be cacheable" },
|
|
578
|
+
{ Pattern: "large-no-cache", Severity: "đ´ error", Meaning: "Resource > 100 KB with no effective cache â significant bandwidth waste per visit" },
|
|
579
|
+
{ Pattern: "versioned-short-cache", Severity: "đĄ warning", Meaning: "Hashed URL with max-age < 86400 â safe to use immutable since URL changes with content" },
|
|
580
|
+
{ Pattern: "expires-without-cc", Severity: "đĄ warning", Meaning: "Uses Expires header without Cache-Control â outdated pattern, less reliable" },
|
|
581
|
+
{ Pattern: "maxage-zero-static", Severity: "đĄ warning", Meaning: "Static asset with max-age=0 â browser revalidates on every request" },
|
|
582
|
+
{ Pattern: "short-cache-static", Severity: "đĄ warning", Meaning: "Static asset with max-age < 3600 â consider a longer cache duration" },
|
|
583
|
+
{ Pattern: "versioned-not-immutable", Severity: "đĩ info", Meaning: "Hashed URL without immutable directive â adding it avoids conditional GET requests" },
|
|
584
|
+
]);
|
|
585
|
+
console.groupEnd();
|
|
586
|
+
for (const severity of ["error", "warning", "info"]) {
|
|
587
|
+
const filtered = allAntiPatterns.filter((p) => p.severity === severity);
|
|
588
|
+
if (filtered.length === 0) continue;
|
|
589
|
+
const icon = { error: "đ´", warning: "đĄ", info: "đĩ" }[severity];
|
|
590
|
+
console.group(`${icon} ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${filtered.length})`);
|
|
591
|
+
console.table(
|
|
592
|
+
filtered.map((p) => ({
|
|
593
|
+
Resource: p.resource,
|
|
594
|
+
Pattern: p.id,
|
|
595
|
+
Detail: p.message,
|
|
596
|
+
}))
|
|
597
|
+
);
|
|
598
|
+
console.groupEnd();
|
|
599
|
+
}
|
|
600
|
+
console.groupEnd();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// CDN status
|
|
604
|
+
if (cdnEntries.length > 0) {
|
|
605
|
+
console.log("");
|
|
606
|
+
console.group(
|
|
607
|
+
`%cđ CDN Cache Status`,
|
|
608
|
+
"font-weight: bold; color: #3b82f6;"
|
|
609
|
+
);
|
|
610
|
+
console.log(
|
|
611
|
+
` Hit rate: ${cdnHitRate}% (${cdnHits} hits / ${cdnEntries.length} CDN resources)`
|
|
612
|
+
);
|
|
613
|
+
console.table(
|
|
614
|
+
cdnEntries.map((e) => ({
|
|
615
|
+
Resource: e.shortName,
|
|
616
|
+
CDN: e.cdnInfo.cdn,
|
|
617
|
+
Status: e.cdnInfo.status,
|
|
618
|
+
"Cache-Control": e.headers?.["cache-control"] || "N/A",
|
|
619
|
+
Type: e.type,
|
|
620
|
+
}))
|
|
621
|
+
);
|
|
622
|
+
console.groupEnd();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Additional insights
|
|
626
|
+
console.log("");
|
|
627
|
+
console.group("%cđĄ Additional Insights", "font-weight: bold;");
|
|
628
|
+
|
|
629
|
+
// Protocol
|
|
630
|
+
console.group("Protocol Distribution");
|
|
631
|
+
console.table(
|
|
632
|
+
Object.entries(protocolDistribution)
|
|
633
|
+
.sort((a, b) => b[1] - a[1])
|
|
634
|
+
.map(([proto, count]) => ({
|
|
635
|
+
Protocol: proto,
|
|
636
|
+
Count: count,
|
|
637
|
+
"% of Total": `${Math.round((count / entries.length) * 100)}%`,
|
|
638
|
+
}))
|
|
639
|
+
);
|
|
640
|
+
console.groupEnd();
|
|
641
|
+
|
|
642
|
+
// Uncompressed
|
|
643
|
+
if (uncompressedResources.length > 0) {
|
|
644
|
+
console.group(`Likely Uncompressed Resources (${uncompressedResources.length})`);
|
|
645
|
+
console.table(
|
|
646
|
+
uncompressedResources.map((e) => ({
|
|
647
|
+
Resource: e.shortName,
|
|
648
|
+
Type: e.type,
|
|
649
|
+
"Encoded Size": formatBytes(e.encodedBodySize),
|
|
650
|
+
"Decoded Size": formatBytes(e.decodedBodySize),
|
|
651
|
+
"Compression Ratio": `${((1 - e.encodedBodySize / e.decodedBodySize) * 100).toFixed(0)}%`,
|
|
652
|
+
}))
|
|
653
|
+
);
|
|
654
|
+
console.groupEnd();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Duplicates â show costly (network) first, then cached-only
|
|
658
|
+
if (costlyDuplicates.length > 0) {
|
|
659
|
+
console.group(
|
|
660
|
+
`Duplicate Resources â Network Impact (${costlyDuplicates.length} URLs with uncached loads)`
|
|
661
|
+
);
|
|
662
|
+
console.table(
|
|
663
|
+
costlyDuplicates.map((d) => ({
|
|
664
|
+
Resource: d.shortName,
|
|
665
|
+
Type: d.type,
|
|
666
|
+
"Times Loaded": d.count,
|
|
667
|
+
"Uncached Loads": d.uncachedLoads,
|
|
668
|
+
}))
|
|
669
|
+
);
|
|
670
|
+
console.groupEnd();
|
|
671
|
+
}
|
|
672
|
+
if (cachedDuplicates.length > 0) {
|
|
673
|
+
console.group(
|
|
674
|
+
`Duplicate Resources â Cache Only (${cachedDuplicates.length} URLs loaded multiple times, all cached)`
|
|
675
|
+
);
|
|
676
|
+
console.table(
|
|
677
|
+
cachedDuplicates.map((d) => ({
|
|
678
|
+
Resource: d.shortName,
|
|
679
|
+
Type: d.type,
|
|
680
|
+
"Times Loaded": d.count,
|
|
681
|
+
}))
|
|
682
|
+
);
|
|
683
|
+
console.groupEnd();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
console.groupEnd(); // Additional Insights
|
|
687
|
+
|
|
688
|
+
// Recommendations
|
|
689
|
+
const recommendations = [];
|
|
690
|
+
|
|
691
|
+
if (noCacheEntries.filter((e) => isStaticAsset(e.type)).length > 0) {
|
|
692
|
+
recommendations.push(
|
|
693
|
+
"đ´ Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (shortCacheEntries.filter((e) => e.hasHash).length > 0) {
|
|
697
|
+
recommendations.push(
|
|
698
|
+
"đĄ Versioned files (with content hash) should use long cache or Cache-Control: immutable â they can be cached indefinitely since the URL changes on update."
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
if (allAntiPatterns.some((p) => p.id === "expires-without-cc")) {
|
|
702
|
+
recommendations.push(
|
|
703
|
+
"đĄ Replace Expires headers with Cache-Control: max-age â Expires is an outdated mechanism and less reliable."
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (cacheEfficiencyPercent < 50) {
|
|
707
|
+
recommendations.push(
|
|
708
|
+
`đ´ Cache efficiency is low (${cacheEfficiencyPercent}%). Review caching strategy for all static assets.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (cdnHitRate !== null && cdnHitRate < 70) {
|
|
712
|
+
recommendations.push(
|
|
713
|
+
`đĄ CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (protocolDistribution["http/1.1"] > 0) {
|
|
717
|
+
recommendations.push(
|
|
718
|
+
`đĄ ${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (uncompressedResources.length > 0) {
|
|
722
|
+
recommendations.push(
|
|
723
|
+
`đĄ ${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
if (costlyDuplicates.length > 0) {
|
|
727
|
+
recommendations.push(
|
|
728
|
+
`đĄ ${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
|
|
729
|
+
);
|
|
730
|
+
} else if (cachedDuplicates.length > 0) {
|
|
731
|
+
recommendations.push(
|
|
732
|
+
`âšī¸ ${cachedDuplicates.length} resources are requested multiple times but served from cache â low performance impact, but worth reviewing for correctness.`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (corsRestricted > 0 && headersAnalyzed === 0) {
|
|
736
|
+
recommendations.push(
|
|
737
|
+
`âšī¸ All resources are cross-origin CORS-restricted â header analysis was limited. Add Timing-Allow-Origin for more insights.`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (recommendations.length > 0) {
|
|
742
|
+
console.log("");
|
|
743
|
+
console.group("%cđ Recommendations", "font-weight: bold;");
|
|
744
|
+
for (const rec of recommendations) {
|
|
745
|
+
console.log(` ${rec}`);
|
|
746
|
+
}
|
|
747
|
+
console.groupEnd();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
console.groupEnd(); // Cache Strategy Analysis
|
|
751
|
+
|
|
752
|
+
// --- Return structured object ---
|
|
753
|
+
|
|
754
|
+
const cacheEfficiencyRating =
|
|
755
|
+
cacheEfficiencyPercent >= 80
|
|
756
|
+
? "good"
|
|
757
|
+
: cacheEfficiencyPercent >= 50
|
|
758
|
+
? "needs-improvement"
|
|
759
|
+
: "poor";
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
script: "Cache-Strategy-Analysis",
|
|
763
|
+
status: "ok",
|
|
764
|
+
count: entries.length,
|
|
765
|
+
details: {
|
|
766
|
+
totalResources: entries.length,
|
|
767
|
+
headersAnalyzed,
|
|
768
|
+
corsRestricted,
|
|
769
|
+
excludedFromHeaders: excludedFromHeaders.length,
|
|
770
|
+
cacheEfficiencyPercent,
|
|
771
|
+
cacheEfficiencyRating,
|
|
772
|
+
bandwidthSavedBytes,
|
|
773
|
+
strategyDistribution: strategyGroups,
|
|
774
|
+
cdnDetected: cdnEntries.length > 0,
|
|
775
|
+
cdnHitRate,
|
|
776
|
+
cdnHitRateRating:
|
|
777
|
+
cdnHitRate === null ? null : cdnHitRate >= 70 ? "good" : "poor",
|
|
778
|
+
antiPatternCount: allAntiPatterns.length,
|
|
779
|
+
duplicatesCostlyCount: costlyDuplicates.length,
|
|
780
|
+
uncompressedCount: uncompressedResources.length,
|
|
781
|
+
protocolDistribution,
|
|
782
|
+
},
|
|
783
|
+
// Only resources with actionable issues â omits clean resources to reduce noise
|
|
784
|
+
items: entries
|
|
785
|
+
.filter(
|
|
786
|
+
(e) =>
|
|
787
|
+
e.antiPatterns.length > 0 ||
|
|
788
|
+
["none", "no-store", "no-cache", "short"].includes(e.cacheStrategy)
|
|
789
|
+
)
|
|
790
|
+
.map((e) => ({
|
|
791
|
+
shortName: e.shortName,
|
|
792
|
+
host: e.host,
|
|
793
|
+
type: e.type,
|
|
794
|
+
firstParty: e.firstParty,
|
|
795
|
+
cacheStrategy: e.cacheStrategy,
|
|
796
|
+
sizeBytes: e.decodedBodySize,
|
|
797
|
+
isCached: e.isCached,
|
|
798
|
+
antiPatterns: e.antiPatterns.map((p) => p.id),
|
|
799
|
+
})),
|
|
800
|
+
issues: allAntiPatterns.map((p) => ({
|
|
801
|
+
severity: p.severity,
|
|
802
|
+
id: p.id,
|
|
803
|
+
resource: p.resource,
|
|
804
|
+
message: p.message,
|
|
805
|
+
})),
|
|
806
|
+
recommendations,
|
|
807
|
+
};
|
|
808
|
+
})();
|