webperf-snippets 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -14
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +86 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +74 -11
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- package/src/workflows/loading.js +9 -0
|
@@ -461,15 +461,32 @@
|
|
|
461
461
|
} catch {}
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
return
|
|
472
|
-
}
|
|
464
|
+
// A preload with a media query that does not match this viewport is conditional, not unused
|
|
465
|
+
const isConditionalPreload = (l) => {
|
|
466
|
+
const media = l.getAttribute("media");
|
|
467
|
+
if (!media) return false;
|
|
468
|
+
try {
|
|
469
|
+
return !window.matchMedia(media).matches;
|
|
470
|
+
} catch {
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
const isUnusedConnection = (l) => {
|
|
475
|
+
const domain = getDomain(l.href);
|
|
476
|
+
return domain && !usedDomains.has(domain) && !lazyLoadedDomainsForSummary.has(domain);
|
|
477
|
+
};
|
|
478
|
+
const unusedHintItems = [
|
|
479
|
+
...preloadLinks
|
|
480
|
+
.filter((l) => !usedResourceUrls.has(normalizeUrl(l.href)) && !isConditionalPreload(l))
|
|
481
|
+
.map((l) => ({ domain: getDomain(l.href), href: l.href, requestCount: 0, action: "remove-unused-preload", recommendedHint: null })),
|
|
482
|
+
...preconnectLinks
|
|
483
|
+
.filter(isUnusedConnection)
|
|
484
|
+
.map((l) => ({ domain: getDomain(l.href), href: l.href, requestCount: 0, action: "remove-unused-preconnect", recommendedHint: null })),
|
|
485
|
+
...dnsPrefetchLinks
|
|
486
|
+
.filter(isUnusedConnection)
|
|
487
|
+
.map((l) => ({ domain: getDomain(l.href), href: l.href, requestCount: 0, action: "remove-unused-dns-prefetch", recommendedHint: null })),
|
|
488
|
+
];
|
|
489
|
+
const totalIssues = unusedHintItems.length;
|
|
473
490
|
|
|
474
491
|
console.log("");
|
|
475
492
|
if (totalIssues === 0 && missingHints.length === 0) {
|
|
@@ -494,6 +511,24 @@
|
|
|
494
511
|
missingPreconnects: missingHints.length,
|
|
495
512
|
redundantHints: redundantDomains.length,
|
|
496
513
|
},
|
|
514
|
+
// The hints to remove (unused), the origins that need a hint and those with a redundant dns-prefetch (at most 50)
|
|
515
|
+
items: [
|
|
516
|
+
...unusedHintItems,
|
|
517
|
+
...missingHints.map(([domain, count]) => ({
|
|
518
|
+
domain,
|
|
519
|
+
href: null,
|
|
520
|
+
requestCount: count,
|
|
521
|
+
action: count >= 5 ? "add-preconnect" : "add-dns-prefetch",
|
|
522
|
+
recommendedHint: count >= 5 ? "preconnect" : "dns-prefetch",
|
|
523
|
+
})),
|
|
524
|
+
...redundantDomains.map((domain) => ({
|
|
525
|
+
domain,
|
|
526
|
+
href: null,
|
|
527
|
+
requestCount: domainRequestCounts[domain] || 0,
|
|
528
|
+
action: "remove-dns-prefetch",
|
|
529
|
+
recommendedHint: "preconnect",
|
|
530
|
+
})),
|
|
531
|
+
].slice(0, 50),
|
|
497
532
|
issues: [
|
|
498
533
|
...(totalIssues > 0 ? [{ severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []),
|
|
499
534
|
...(missingHints.length > 0 ? [{ severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []),
|
|
@@ -2,13 +2,15 @@
|
|
|
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
|
// Resource hint definitions
|
|
14
16
|
const hintTypes = [
|
|
@@ -171,6 +173,8 @@
|
|
|
171
173
|
|
|
172
174
|
// Get third-party origins that could benefit from preconnect
|
|
173
175
|
const thirdPartyOrigins = new Map();
|
|
176
|
+
// Third-party resources without Timing-Allow-Origin report every size as 0
|
|
177
|
+
let sizeUnknownCount = 0;
|
|
174
178
|
loadedResources.forEach((r) => {
|
|
175
179
|
try {
|
|
176
180
|
const url = new URL(r.name);
|
|
@@ -181,6 +185,7 @@
|
|
|
181
185
|
const data = thirdPartyOrigins.get(url.origin);
|
|
182
186
|
data.count++;
|
|
183
187
|
data.size += r.transferSize || 0;
|
|
188
|
+
if (r.transferSize === 0 && r.encodedBodySize === 0 && r.decodedBodySize === 0 && r.duration > 0) sizeUnknownCount++;
|
|
184
189
|
data.types.add(r.initiatorType);
|
|
185
190
|
}
|
|
186
191
|
} catch {}
|
|
@@ -386,15 +391,31 @@
|
|
|
386
391
|
|
|
387
392
|
console.groupEnd();
|
|
388
393
|
|
|
394
|
+
// The list keeps 50 hints: those with an error or a warning first, then in document order; count and details cover all of them
|
|
395
|
+
const MAX_ITEMS = 50;
|
|
396
|
+
const hintRank = (h) => (issues.some((i) => i.hint === h && i.severity === "error") ? 0 : issues.some((i) => i.hint === h) ? 1 : 2);
|
|
397
|
+
const rankedHints = allHints
|
|
398
|
+
.map((h, index) => ({ h, index }))
|
|
399
|
+
.sort((a, b) => hintRank(a.h) - hintRank(b.h) || a.index - b.index)
|
|
400
|
+
.map(({ h }) => h);
|
|
389
401
|
return {
|
|
390
402
|
script: "Resource-Hints",
|
|
391
403
|
status: "ok",
|
|
392
404
|
count: allHints.length,
|
|
405
|
+
corsLimitedAnalysis: sizeUnknownCount > 0,
|
|
393
406
|
details: {
|
|
407
|
+
sizeUnknownCount,
|
|
394
408
|
byType: Object.fromEntries(hintTypes.map(t => [t.rel, allHints.filter(h => h.rel === t.rel).length])),
|
|
395
409
|
missingPreconnectsCount: missingPreconnects.length,
|
|
410
|
+
missingPreconnects: missingPreconnects.slice(0, 20).map((o) => ({
|
|
411
|
+
origin: o.origin,
|
|
412
|
+
requestCount: o.count,
|
|
413
|
+
sizeBytes: o.size,
|
|
414
|
+
resourceTypes: Array.from(o.types),
|
|
415
|
+
recommendedHint: "preconnect",
|
|
416
|
+
})),
|
|
396
417
|
},
|
|
397
|
-
items:
|
|
418
|
+
items: rankedHints.slice(0, MAX_ITEMS).map(h => ({ rel: h.rel, href: h.shortHref, as: h.as, crossorigin: h.crossorigin, fetchpriority: h.fetchpriority })),
|
|
398
419
|
issues: issues.map(i => ({ severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` })),
|
|
399
420
|
};
|
|
400
421
|
})();
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
5
|
+
// @shared formatBytes
|
|
6
|
+
function formatBytes(bytes) {
|
|
7
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
6
8
|
if (bytes === 0) return "0 B";
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
// Framework detection patterns
|
|
14
16
|
const frameworks = [
|
|
@@ -168,8 +170,18 @@
|
|
|
168
170
|
console.log(` Total hydration data: ${formatBytes(totalHydrationSize)}`);
|
|
169
171
|
console.log(` Other inline scripts: ${formatBytes(otherSize)}`);
|
|
170
172
|
|
|
173
|
+
// Structured findings for the return value
|
|
174
|
+
const itemsData = [];
|
|
175
|
+
const issuesData = [];
|
|
176
|
+
|
|
171
177
|
// Detailed analysis for each framework
|
|
172
178
|
detected.forEach((fw) => {
|
|
179
|
+
if (fw.exceedsThreshold) {
|
|
180
|
+
issuesData.push({
|
|
181
|
+
severity: "warning",
|
|
182
|
+
message: `${fw.name} hydration data (${Math.round(fw.size / 1024)} KB) exceeds ${Math.round(fw.threshold / 1024)} KB threshold`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
173
185
|
console.log("");
|
|
174
186
|
console.group(
|
|
175
187
|
`%c${fw.exceedsThreshold ? "🔴" : "🟢"} ${fw.name} Analysis`,
|
|
@@ -256,6 +268,46 @@
|
|
|
256
268
|
);
|
|
257
269
|
}
|
|
258
270
|
|
|
271
|
+
// Key paths that look sensitive. Only keys are checked: a value that contains
|
|
272
|
+
// the word "token" is not a sign of leaked data.
|
|
273
|
+
const sensitiveKeys = [];
|
|
274
|
+
const findSensitiveKeys = (obj, path, depth) => {
|
|
275
|
+
if (typeof obj !== "object" || obj === null || depth > 4) return;
|
|
276
|
+
for (const key of Object.keys(obj)) {
|
|
277
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
278
|
+
if (sensitivePatterns.test(key)) sensitiveKeys.push(fullPath);
|
|
279
|
+
findSensitiveKeys(obj[key], fullPath, depth + 1);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
findSensitiveKeys(pageProps, "", 0);
|
|
283
|
+
|
|
284
|
+
propsKeys.forEach((key) => {
|
|
285
|
+
const flags = [];
|
|
286
|
+
if (largeArrays.includes(key)) flags.push("large-array");
|
|
287
|
+
if (checkDepth(pageProps[key])) flags.push("deeply-nested");
|
|
288
|
+
if (sensitiveKeys.some((k) => k === key || k.startsWith(`${key}.`))) flags.push("sensitive-key");
|
|
289
|
+
itemsData.push({
|
|
290
|
+
framework: "Next.js",
|
|
291
|
+
kind: "prop",
|
|
292
|
+
name: key,
|
|
293
|
+
sizeBytes: new Blob([JSON.stringify(pageProps[key])]).size,
|
|
294
|
+
flags,
|
|
295
|
+
detail: null,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
if (largeArrays.length > 0) {
|
|
299
|
+
issuesData.push({ severity: "warning", message: `pageProps has large arrays (${largeArrays.join(", ")}). Consider pagination.` });
|
|
300
|
+
}
|
|
301
|
+
if (checkDepth(pageProps)) {
|
|
302
|
+
issuesData.push({ severity: "warning", message: "pageProps contains deeply nested data (more than 5 levels)." });
|
|
303
|
+
}
|
|
304
|
+
if (sensitiveKeys.length > 0) {
|
|
305
|
+
issuesData.push({
|
|
306
|
+
severity: "warning",
|
|
307
|
+
message: `pageProps has keys that look sensitive (${sensitiveKeys.slice(0, 5).join(", ")}). Everything in pageProps is sent to every visitor.`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
259
311
|
if (
|
|
260
312
|
largeArrays.length === 0 &&
|
|
261
313
|
!checkDepth(pageProps) &&
|
|
@@ -293,6 +345,21 @@
|
|
|
293
345
|
};
|
|
294
346
|
});
|
|
295
347
|
console.table(islandTable);
|
|
348
|
+
|
|
349
|
+
fw.elements.forEach((island) => {
|
|
350
|
+
const props = island.getAttribute("props");
|
|
351
|
+
itemsData.push({
|
|
352
|
+
framework: "Astro",
|
|
353
|
+
kind: "island",
|
|
354
|
+
name: island.getAttribute("component-url")?.split("/").pop() || "Unknown",
|
|
355
|
+
sizeBytes: props ? new Blob([props]).size : 0,
|
|
356
|
+
flags: [],
|
|
357
|
+
detail:
|
|
358
|
+
island.getAttribute("client") ||
|
|
359
|
+
Array.from(island.attributes).find((a) => a.name.startsWith("client:"))?.name.replace("client:", "") ||
|
|
360
|
+
"unknown",
|
|
361
|
+
});
|
|
362
|
+
});
|
|
296
363
|
}
|
|
297
364
|
|
|
298
365
|
// Element reference
|
|
@@ -357,8 +424,10 @@
|
|
|
357
424
|
totalHydrationBytes: totalHydrationSize,
|
|
358
425
|
otherInlineBytes: otherSize,
|
|
359
426
|
hasExceedingThreshold: detected.some(d => d.exceedsThreshold),
|
|
427
|
+
frameworks: detected.map(fw => ({ name: fw.name, sizeBytes: fw.size, thresholdBytes: fw.threshold, exceedsThreshold: fw.exceedsThreshold })),
|
|
360
428
|
},
|
|
361
|
-
|
|
362
|
-
|
|
429
|
+
// Props and islands, the biggest first (at most 30)
|
|
430
|
+
items: itemsData.sort((a, b) => b.sizeBytes - a.sizeBytes).slice(0, 30),
|
|
431
|
+
issues: issuesData,
|
|
363
432
|
};
|
|
364
433
|
})();
|
|
@@ -2,33 +2,61 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
6
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
7
|
+
const OWN_DOMAINS = [];
|
|
8
|
+
|
|
9
|
+
// @shared formatBytes
|
|
10
|
+
function formatBytes(bytes) {
|
|
11
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
12
|
+
if (bytes === 0) return "0 B";
|
|
13
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
14
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
15
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
16
|
+
}
|
|
17
|
+
// @end-shared formatBytes
|
|
12
18
|
|
|
13
19
|
const formatMs = (ms) => (ms > 0 ? ms.toFixed(0) + "ms" : "-");
|
|
14
20
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// @shared getRootDomain
|
|
22
|
+
function getRootDomain(hostname) {
|
|
23
|
+
const host = hostname.replace(/\.$/, "");
|
|
24
|
+
// An IP address has no registrable domain, so each address is its own root
|
|
25
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
|
|
26
|
+
const parts = host.split(".");
|
|
27
|
+
if (parts.length <= 2) return host;
|
|
28
|
+
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
|
|
29
|
+
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
|
|
30
|
+
const tld = parts[parts.length - 1];
|
|
31
|
+
const sld = parts[parts.length - 2];
|
|
32
|
+
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
|
|
33
|
+
return parts.slice(-2).join(".");
|
|
34
|
+
}
|
|
35
|
+
// @end-shared getRootDomain
|
|
36
|
+
|
|
37
|
+
// @shared isFirstParty
|
|
38
|
+
function isFirstParty(hostname) {
|
|
39
|
+
const root = getRootDomain(hostname);
|
|
40
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
41
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
42
|
+
}
|
|
43
|
+
// @end-shared isFirstParty
|
|
44
|
+
|
|
45
|
+
// @shared logOwnDomainsHint
|
|
46
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
47
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
48
|
+
console.log(
|
|
49
|
+
'%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
|
|
50
|
+
"color: #3b82f6;"
|
|
51
|
+
);
|
|
24
52
|
}
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
}
|
|
54
|
+
// @end-shared logOwnDomainsHint
|
|
27
55
|
|
|
28
|
-
|
|
29
|
-
const
|
|
56
|
+
// A script whose URL cannot be parsed counts as first party
|
|
57
|
+
const isFirstPartyUrl = (url) => {
|
|
30
58
|
try {
|
|
31
|
-
return
|
|
59
|
+
return isFirstParty(new URL(url).hostname);
|
|
32
60
|
} catch {
|
|
33
61
|
return true;
|
|
34
62
|
}
|
|
@@ -40,6 +68,8 @@
|
|
|
40
68
|
if (r.initiatorType === "script") {
|
|
41
69
|
resourceTiming.set(r.name, {
|
|
42
70
|
size: r.transferSize || 0,
|
|
71
|
+
// Cross-origin scripts without Timing-Allow-Origin report every size as 0
|
|
72
|
+
sizeKnown: r.transferSize > 0 || r.encodedBodySize > 0 || r.decodedBodySize > 0,
|
|
43
73
|
duration: r.duration,
|
|
44
74
|
startTime: r.startTime,
|
|
45
75
|
});
|
|
@@ -74,7 +104,7 @@
|
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
const isBlocking = strategy === "blocking";
|
|
77
|
-
const firstParty =
|
|
107
|
+
const firstParty = isFirstPartyUrl(src);
|
|
78
108
|
|
|
79
109
|
return {
|
|
80
110
|
src,
|
|
@@ -87,6 +117,7 @@
|
|
|
87
117
|
isAsync,
|
|
88
118
|
isDefer,
|
|
89
119
|
size: timing.size || 0,
|
|
120
|
+
sizeKnown: !!timing.sizeKnown,
|
|
90
121
|
duration: timing.duration || 0,
|
|
91
122
|
startTime: timing.startTime || 0,
|
|
92
123
|
element: script,
|
|
@@ -293,19 +324,30 @@
|
|
|
293
324
|
console.log("");
|
|
294
325
|
console.log("%cFor critical inline scripts:", "font-weight: bold;");
|
|
295
326
|
console.log('%c<script>/* Only essential initialization */</script>', "font-family: monospace; color: #22c55e;");
|
|
327
|
+
logOwnDomainsHint(thirdPartyScripts.length);
|
|
296
328
|
console.groupEnd();
|
|
297
329
|
|
|
298
330
|
console.groupEnd();
|
|
299
331
|
|
|
300
332
|
const agentRating = blockingInHead.length === 0 ? "good" :
|
|
301
333
|
(blockingInHead.length <= 2 && thirdPartyBlocking.length === 0) ? "needs-improvement" : "poor";
|
|
334
|
+
// 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
|
|
335
|
+
const MAX_ITEMS = 50;
|
|
336
|
+
const rankedScripts = [...scripts].sort(
|
|
337
|
+
(a, b) =>
|
|
338
|
+
Number(b.isBlocking) - Number(a.isBlocking) ||
|
|
339
|
+
Number(a.firstParty) - Number(b.firstParty) ||
|
|
340
|
+
b.size - a.size
|
|
341
|
+
);
|
|
302
342
|
return {
|
|
303
343
|
script: "Script-Loading",
|
|
304
344
|
status: "ok",
|
|
305
345
|
count: scripts.length,
|
|
346
|
+
corsLimitedAnalysis: scripts.some((s) => !s.sizeKnown),
|
|
306
347
|
rating: agentRating,
|
|
307
348
|
details: {
|
|
308
349
|
totalSizeBytes: totalSize,
|
|
350
|
+
sizeUnknownCount: scripts.filter((s) => !s.sizeKnown).length,
|
|
309
351
|
byStrategy: {
|
|
310
352
|
blocking: blocking.length,
|
|
311
353
|
async: asyncScripts.length,
|
|
@@ -318,15 +360,21 @@
|
|
|
318
360
|
},
|
|
319
361
|
thirdPartyBlockingCount: thirdPartyBlocking.length,
|
|
320
362
|
},
|
|
321
|
-
items:
|
|
363
|
+
items: rankedScripts.slice(0, MAX_ITEMS).map((s) => ({
|
|
322
364
|
url: s.src,
|
|
323
365
|
shortName: s.shortSrc,
|
|
324
366
|
strategy: s.strategy,
|
|
325
367
|
location: s.inHead ? "head" : "body",
|
|
326
368
|
party: s.firstParty ? "first" : "third",
|
|
327
369
|
sizeBytes: s.size,
|
|
370
|
+
sizeKnown: s.sizeKnown,
|
|
328
371
|
durationMs: Math.round(s.duration),
|
|
329
372
|
})),
|
|
330
|
-
issues:
|
|
373
|
+
issues: [
|
|
374
|
+
...issues.map((i) => ({ severity: i.severity, message: i.message })),
|
|
375
|
+
...(scripts.some((s) => !s.sizeKnown)
|
|
376
|
+
? [{ 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` }]
|
|
377
|
+
: []),
|
|
378
|
+
],
|
|
331
379
|
};
|
|
332
380
|
})();
|
|
@@ -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
|
+
})();
|