webperf-snippets 0.3.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 +24 -11
- package/package.json +1 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +93 -17
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +19 -1
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +27 -2
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +19 -1
- package/snippets/Loading/Resource-Hints-Validation.js +30 -10
- package/snippets/Loading/Script-Loading.js +19 -1
- package/snippets/Loading/TTFB-Resources.js +29 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +39 -11
- package/snippets/Media/Image-Element-Audit.js +52 -8
- package/snippets/SCHEMA.md +7 -4
- package/snippets/_shared/helpers.js +18 -1
- package/src/bin.js +18 -43
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/reporters/human.js +23 -4
- package/src/reporters/markdown.js +5 -0
- package/src/runner.js +71 -19
- 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
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Run curated [WebPerf Snippets](https://webperf-snippets.nucliweb.net) headlessly
|
|
|
5
5
|
<img width="1820" height="1442" alt="webperf-snippets-CLI" src="https://github.com/user-attachments/assets/af7e6b02-8877-407e-87a4-db063468b5fb" />
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
> **Status:** v0.
|
|
8
|
+
> **Status:** v0.4. Core Web Vitals, loading, interaction and structural audit workflows, and every snippet of the collection by name. See the [Roadmap](#roadmap).
|
|
9
9
|
|
|
10
10
|
## Why
|
|
11
11
|
|
|
@@ -38,13 +38,13 @@ Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Subparts if LCP >
|
|
|
38
38
|
npx webperf-snippets https://web.dev
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Loading audit (TTFB, FCP, render-blocking, scripts, fonts):
|
|
41
|
+
Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings, third-party impact, fonts, cache; the list is in the [CLI page](https://webperf-snippets.nucliweb.net/CLI)):
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
npx webperf-snippets https://web.dev --workflow loading
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Structural checks for CI (render-blocking, fonts,
|
|
47
|
+
Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.nucliweb.net/CLI)):
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
50
|
npx webperf-snippets https://web.dev --workflow audit
|
|
@@ -72,6 +72,15 @@ Synthetic INP measurement with an interaction script:
|
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
|
|
75
|
+
npx webperf-snippets https://web.dev --snippet Interaction/Interactions --interact-script interactions.json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The Interaction snippets (`Interactions`, `Input-Latency-Breakdown`, `LongTask`, `Forced-Synchronous-Layout`...) are installed before the first step and read after the last one, so their result holds what the steps caused.
|
|
79
|
+
|
|
80
|
+
Interaction audit (long tasks, long animation frames, scroll, layout shifts, forced layouts, interaction latency). It scrolls the page, or runs your `--interact-script`; the steps that need a click are skipped when the script has none:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx webperf-snippets https://web.dev --workflow interaction
|
|
75
84
|
```
|
|
76
85
|
|
|
77
86
|
Pages behind a login, with a Playwright storage state (cookies and localStorage):
|
|
@@ -123,7 +132,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
123
132
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
124
133
|
"navMs": 1240,
|
|
125
134
|
"results": [],
|
|
126
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.
|
|
135
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "0.4.0" }
|
|
127
136
|
}
|
|
128
137
|
```
|
|
129
138
|
|
|
@@ -143,13 +152,13 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
143
152
|
|
|
144
153
|
| Option | Description |
|
|
145
154
|
| ---------------------------- | ---------------------------------------------------------------------- |
|
|
146
|
-
| `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. Options: `core-web-vitals`, `loading`, `audit`. |
|
|
147
|
-
| `--snippet <name>` | Run a single snippet by alias or `Category/Name` path.
|
|
155
|
+
| `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. Options: `core-web-vitals`, `loading`, `audit`, `interaction`. |
|
|
156
|
+
| `--snippet <name>` | Run a single snippet by name, alias or `Category/Name` path. |
|
|
148
157
|
| `--json` | Output JSON instead of formatted text. |
|
|
149
158
|
| `--markdown` | Output GitHub-renderable markdown (for PR comments). |
|
|
150
159
|
| `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
|
|
151
160
|
| `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
|
|
152
|
-
| `--interact-script <path>` | JSON file with interactions to run
|
|
161
|
+
| `--interact-script <path>` | JSON file with interactions to run after the page loads (`scroll`, `click`, `hover`, `type`, `wait`). The Interaction snippets are installed first and read afterwards. |
|
|
153
162
|
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
154
163
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
155
164
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
@@ -181,12 +190,16 @@ npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
|
|
|
181
190
|
| `lazy-conflict` | Loading/Find-Images-With-Lazy-and-Fetchpriority |
|
|
182
191
|
| `eager-below-fold` | Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport |
|
|
183
192
|
|
|
193
|
+
### Follow-up steps
|
|
194
|
+
|
|
195
|
+
The results of a run can add steps on the same page load: LCP over 2.5 s adds `LCP-Subparts`, over 4 s `LCP-Trail`; an image or video LCP adds its entropy, image or video audit; CLS over 0.1 adds `Layout-Shift-Loading-and-Interaction`, over 0.25 also the lazy images, fonts, critical CSS and images without dimensions; TTFB over 600 ms adds `TTFB-Sub-Parts`; FCP over 1.8 s adds the render-blocking check. The full table is in the [CLI page](https://webperf-snippets.nucliweb.net/CLI). Each snippet runs once, and a follow-up informs: its `error` issues do not change the exit code.
|
|
196
|
+
|
|
184
197
|
### Exit codes
|
|
185
198
|
|
|
186
199
|
| Code | Meaning |
|
|
187
200
|
| ---- | --------------------------------------------- |
|
|
188
201
|
| `0` | All checks passed. |
|
|
189
|
-
| `1` | Budget violation, or a
|
|
202
|
+
| `1` | Budget violation, a snippet errored, or a step of the workflow reported an `error` issue. |
|
|
190
203
|
| `2` | Usage error (missing URL, unknown workflow). |
|
|
191
204
|
|
|
192
205
|
## CI example
|
|
@@ -236,7 +249,7 @@ jobs:
|
|
|
236
249
|
| Input | Default | Description |
|
|
237
250
|
| ---------------- | ------------------- | -------------------------------------------------------------------------------------------- |
|
|
238
251
|
| `url` | required | URL to measure. |
|
|
239
|
-
| `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading` or `
|
|
252
|
+
| `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading`, `audit` or `interaction`. |
|
|
240
253
|
| `budget-lcp` | `2500` | LCP budget in ms. An empty value disables the check. |
|
|
241
254
|
| `budget-cls` | `0.1` | CLS budget. An empty value disables the check. |
|
|
242
255
|
| `fail-on-budget` | `true` | Fail the step when a budget is exceeded or a snippet errors. Use `"false"` to read `exit-code` and decide yourself. |
|
|
@@ -287,8 +300,8 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
287
300
|
## Roadmap
|
|
288
301
|
|
|
289
302
|
- ~~v0.2: Loading workflow (TTFB, FCP, render-blocking, scripts, fonts), shared page session, synthetic interactions for INP, markdown reporter for PR comments.~~ ✓ Released
|
|
290
|
-
- v0.3: GitHub Action wrapper
|
|
291
|
-
- v0.4:
|
|
303
|
+
- ~~v0.3: GitHub Action wrapper, auth flows (login + measure logged-in pages), CrUX field-data enrichment, `--report-to`.~~ ✓ Released
|
|
304
|
+
- ~~v0.4: Every snippet by name, a 22-step audit, an 11-step loading workflow, the interaction workflow and decision-tree follow-ups for Core Web Vitals.~~ ✓ Released
|
|
292
305
|
|
|
293
306
|
## How it works
|
|
294
307
|
|
package/package.json
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// https://webperf-snippets.nucliweb.net
|
|
4
4
|
|
|
5
5
|
(async () => {
|
|
6
|
+
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
|
|
7
|
+
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
|
|
8
|
+
const OWN_DOMAINS = [];
|
|
9
|
+
|
|
6
10
|
// --- Helpers ---
|
|
7
11
|
|
|
8
12
|
// @shared getRootDomain
|
|
@@ -23,10 +27,23 @@
|
|
|
23
27
|
|
|
24
28
|
// @shared isFirstParty
|
|
25
29
|
function isFirstParty(hostname) {
|
|
26
|
-
|
|
30
|
+
const root = getRootDomain(hostname);
|
|
31
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
32
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
27
33
|
}
|
|
28
34
|
// @end-shared isFirstParty
|
|
29
35
|
|
|
36
|
+
// @shared logOwnDomainsHint
|
|
37
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
38
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
39
|
+
console.log(
|
|
40
|
+
'%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.',
|
|
41
|
+
"color: #3b82f6;"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// @end-shared logOwnDomainsHint
|
|
46
|
+
|
|
30
47
|
// @shared formatBytes
|
|
31
48
|
function formatBytes(bytes) {
|
|
32
49
|
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
@@ -377,13 +394,17 @@
|
|
|
377
394
|
|
|
378
395
|
// --- Phase 6: Value-add metrics ---
|
|
379
396
|
|
|
380
|
-
|
|
397
|
+
// Only resources whose cache headers were read have a cache policy to judge. One whose headers
|
|
398
|
+
// could not be read, or that was only inferred as cached, says nothing about the policy: counting it
|
|
399
|
+
// as "not effective" made the figure measure how much was analyzable instead of the cache.
|
|
400
|
+
const analyzableEntries = entries.filter((e) => e.headers !== null);
|
|
401
|
+
const effectiveEntries = analyzableEntries.filter((e) =>
|
|
381
402
|
EFFECTIVE_CACHE_STRATEGIES.has(e.cacheStrategy)
|
|
382
403
|
);
|
|
383
404
|
const cacheEfficiencyPercent =
|
|
384
|
-
|
|
385
|
-
? Math.round((effectiveEntries.length /
|
|
386
|
-
:
|
|
405
|
+
analyzableEntries.length > 0
|
|
406
|
+
? Math.round((effectiveEntries.length / analyzableEntries.length) * 100)
|
|
407
|
+
: null;
|
|
387
408
|
|
|
388
409
|
const bandwidthSavedBytes = entries
|
|
389
410
|
.filter((e) => e.isCached)
|
|
@@ -425,6 +446,24 @@
|
|
|
425
446
|
const costlyDuplicates = uniqueDuplicates.filter((d) => d.costly);
|
|
426
447
|
const cachedDuplicates = uniqueDuplicates.filter((d) => !d.costly);
|
|
427
448
|
|
|
449
|
+
// These two findings need no cache headers, so they also cover resources on other origins. They
|
|
450
|
+
// are anti-patterns like the header-based ones: they reach `items` and `issues`.
|
|
451
|
+
for (const entry of uncompressedResources) {
|
|
452
|
+
entry.antiPatterns.push({
|
|
453
|
+
id: "uncompressed",
|
|
454
|
+
severity: "warning",
|
|
455
|
+
message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}) — enable Brotli or gzip`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
for (const duplicate of costlyDuplicates) {
|
|
459
|
+
// One finding per URL, on the first request, not one per request
|
|
460
|
+
entries.find((e) => e.url === duplicate.url).antiPatterns.push({
|
|
461
|
+
id: "duplicate-uncached",
|
|
462
|
+
severity: "warning",
|
|
463
|
+
message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network — load it once or make it cacheable`,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
428
467
|
// --- Phase 7: Console output ---
|
|
429
468
|
|
|
430
469
|
if (entries.length === 0) {
|
|
@@ -459,6 +498,7 @@
|
|
|
459
498
|
);
|
|
460
499
|
|
|
461
500
|
const headersAnalyzed = entries.filter((e) => e.headers !== null).length;
|
|
501
|
+
const headerCoveragePercent = Math.round((headersAnalyzed / entries.length) * 100);
|
|
462
502
|
const corsRestricted = entries.filter((e) => e.isCorsRestricted).length;
|
|
463
503
|
|
|
464
504
|
// Resources excluded from header analysis — cross-origin or non-static types.
|
|
@@ -474,6 +514,26 @@
|
|
|
474
514
|
!FETCHABLE_TYPES.has(e.type))
|
|
475
515
|
);
|
|
476
516
|
|
|
517
|
+
// Header analysis only reaches same-origin static assets. When most of the page is elsewhere (a CDN
|
|
518
|
+
// or another origin for the assets), say so and name the hosts, so an empty list is not read as "clean".
|
|
519
|
+
let coverageIssue = null;
|
|
520
|
+
if (headerCoveragePercent < 50) {
|
|
521
|
+
const hostCounts = new Map();
|
|
522
|
+
for (const e of entries.filter((e) => e.headers === null)) {
|
|
523
|
+
hostCounts.set(e.host, (hostCounts.get(e.host) || 0) + 1);
|
|
524
|
+
}
|
|
525
|
+
const topHosts = [...hostCounts.entries()]
|
|
526
|
+
.sort((a, b) => b[1] - a[1])
|
|
527
|
+
.slice(0, 3)
|
|
528
|
+
.map(([host]) => host)
|
|
529
|
+
.join(", ");
|
|
530
|
+
coverageIssue = {
|
|
531
|
+
severity: "info",
|
|
532
|
+
id: "header-coverage",
|
|
533
|
+
message: `Cache headers were read for ${headersAnalyzed} of ${entries.length} resources (${headerCoveragePercent}%): the rest are on other origins or are not static assets, so their Cache-Control cannot be read from the page. Most of them are on ${topHosts}; check those hosts in the Network panel or with curl -I`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
477
537
|
console.group(
|
|
478
538
|
"%c🔍 Cache Strategy Analysis",
|
|
479
539
|
"font-weight: bold; font-size: 14px;"
|
|
@@ -485,13 +545,18 @@
|
|
|
485
545
|
console.log(
|
|
486
546
|
` Headers analyzed: ${headersAnalyzed} | CORS-restricted: ${corsRestricted} | Excluded (non-static/cross-origin): ${excludedFromHeaders.length}`
|
|
487
547
|
);
|
|
548
|
+
if (coverageIssue) console.log(` ℹ️ ${coverageIssue.message}`);
|
|
488
549
|
if (excludedFromHeaders.length > 0) {
|
|
489
550
|
const excludedTypes = [...new Set(excludedFromHeaders.map((e) => e.type))].join(", ");
|
|
490
551
|
console.log(
|
|
491
552
|
` ℹ️ Excluded resources (${excludedTypes}) are analyzed with Tier 1 data only (Performance API) — header inspection requires same-origin static assets due to CORS restrictions.`
|
|
492
553
|
);
|
|
493
554
|
}
|
|
494
|
-
console.log(
|
|
555
|
+
console.log(
|
|
556
|
+
cacheEfficiencyPercent === null
|
|
557
|
+
? ` Cache efficiency: n/a (no cache headers could be read)`
|
|
558
|
+
: ` Cache efficiency: ${cacheEfficiencyPercent}% (of the ${analyzableEntries.length} resources whose cache headers could be read)`
|
|
559
|
+
);
|
|
495
560
|
console.log(` Bandwidth saved by cache: ${formatBytes(bandwidthSavedBytes)}`);
|
|
496
561
|
if (cdnHitRate !== null) {
|
|
497
562
|
console.log(
|
|
@@ -596,6 +661,8 @@
|
|
|
596
661
|
{ Pattern: "expires-without-cc", Severity: "🟡 warning", Meaning: "Uses Expires header without Cache-Control — outdated pattern, less reliable" },
|
|
597
662
|
{ Pattern: "maxage-zero-static", Severity: "🟡 warning", Meaning: "Static asset with max-age=0 — browser revalidates on every request" },
|
|
598
663
|
{ Pattern: "short-cache-static", Severity: "🟡 warning", Meaning: "Static asset with max-age < 3600 — consider a longer cache duration" },
|
|
664
|
+
{ Pattern: "uncompressed", Severity: "🟡 warning", Meaning: "Text resource sent without Brotli or gzip — works for any origin, no headers needed" },
|
|
665
|
+
{ Pattern: "duplicate-uncached", Severity: "🟡 warning", Meaning: "Same URL loaded more than once with at least one request to the network" },
|
|
599
666
|
{ Pattern: "versioned-not-immutable", Severity: "🔵 info", Meaning: "Hashed URL without immutable directive — adding it avoids conditional GET requests" },
|
|
600
667
|
]);
|
|
601
668
|
console.groupEnd();
|
|
@@ -719,9 +786,9 @@
|
|
|
719
786
|
"🟡 Replace Expires headers with Cache-Control: max-age — Expires is an outdated mechanism and less reliable."
|
|
720
787
|
);
|
|
721
788
|
}
|
|
722
|
-
if (
|
|
789
|
+
if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
|
|
723
790
|
recommendations.push(
|
|
724
|
-
`🔴 Cache efficiency is low (${cacheEfficiencyPercent}
|
|
791
|
+
`🔴 Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
|
|
725
792
|
);
|
|
726
793
|
}
|
|
727
794
|
if (cdnHitRate !== null && cdnHitRate < 70) {
|
|
@@ -763,12 +830,15 @@
|
|
|
763
830
|
console.groupEnd();
|
|
764
831
|
}
|
|
765
832
|
|
|
833
|
+
logOwnDomainsHint(entries.filter((e) => !e.firstParty).length);
|
|
766
834
|
console.groupEnd(); // Cache Strategy Analysis
|
|
767
835
|
|
|
768
836
|
// --- Return structured object ---
|
|
769
837
|
|
|
770
838
|
const cacheEfficiencyRating =
|
|
771
|
-
cacheEfficiencyPercent
|
|
839
|
+
cacheEfficiencyPercent === null
|
|
840
|
+
? null
|
|
841
|
+
: cacheEfficiencyPercent >= 80
|
|
772
842
|
? "good"
|
|
773
843
|
: cacheEfficiencyPercent >= 50
|
|
774
844
|
? "needs-improvement"
|
|
@@ -791,8 +861,10 @@
|
|
|
791
861
|
headersAnalyzed,
|
|
792
862
|
corsRestricted,
|
|
793
863
|
excludedFromHeaders: excludedFromHeaders.length,
|
|
864
|
+
headerCoveragePercent,
|
|
794
865
|
cacheEfficiencyPercent,
|
|
795
866
|
cacheEfficiencyRating,
|
|
867
|
+
cacheEfficiencyResources: analyzableEntries.length,
|
|
796
868
|
bandwidthSavedBytes,
|
|
797
869
|
strategyDistribution: strategyGroups,
|
|
798
870
|
cdnDetected: cdnEntries.length > 0,
|
|
@@ -804,8 +876,9 @@
|
|
|
804
876
|
uncompressedCount: uncompressedResources.length,
|
|
805
877
|
protocolDistribution,
|
|
806
878
|
},
|
|
807
|
-
// Only resources with actionable
|
|
808
|
-
//
|
|
879
|
+
// Only resources with actionable findings (cache anti-patterns, a weak cache strategy, a costly
|
|
880
|
+
// duplicate or uncompressed text), at most 50: the ones with the most anti-patterns first, then the
|
|
881
|
+
// largest. Clean resources are left out to reduce noise.
|
|
809
882
|
items: actionableEntries
|
|
810
883
|
.sort((a, b) => b.antiPatterns.length - a.antiPatterns.length || b.decodedBodySize - a.decodedBodySize)
|
|
811
884
|
.slice(0, MAX_ITEMS)
|
|
@@ -819,12 +892,15 @@
|
|
|
819
892
|
isCached: e.isCached,
|
|
820
893
|
antiPatterns: e.antiPatterns.map((p) => p.id),
|
|
821
894
|
})),
|
|
822
|
-
issues:
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
895
|
+
issues: [
|
|
896
|
+
...allAntiPatterns.map((p) => ({
|
|
897
|
+
severity: p.severity,
|
|
898
|
+
id: p.id,
|
|
899
|
+
resource: p.resource,
|
|
900
|
+
message: p.message,
|
|
901
|
+
})),
|
|
902
|
+
...(coverageIssue ? [coverageIssue] : []),
|
|
903
|
+
],
|
|
828
904
|
recommendations,
|
|
829
905
|
};
|
|
830
906
|
})();
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
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
|
+
|
|
5
9
|
// Auto-detect first-party: same root domain (handles subdomains)
|
|
6
10
|
// @shared getRootDomain
|
|
7
11
|
function getRootDomain(hostname) {
|
|
@@ -21,10 +25,23 @@
|
|
|
21
25
|
|
|
22
26
|
// @shared isFirstParty
|
|
23
27
|
function isFirstParty(hostname) {
|
|
24
|
-
|
|
28
|
+
const root = getRootDomain(hostname);
|
|
29
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
30
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
25
31
|
}
|
|
26
32
|
// @end-shared isFirstParty
|
|
27
33
|
|
|
34
|
+
// @shared logOwnDomainsHint
|
|
35
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
36
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
37
|
+
console.log(
|
|
38
|
+
'%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.',
|
|
39
|
+
"color: #3b82f6;"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// @end-shared logOwnDomainsHint
|
|
44
|
+
|
|
28
45
|
// @shared formatBytes
|
|
29
46
|
function formatBytes(bytes) {
|
|
30
47
|
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
@@ -222,6 +239,7 @@
|
|
|
222
239
|
console.groupEnd();
|
|
223
240
|
}
|
|
224
241
|
|
|
242
|
+
logOwnDomainsHint(thirdParty.length);
|
|
225
243
|
console.groupEnd();
|
|
226
244
|
|
|
227
245
|
// 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
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
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
|
+
|
|
5
9
|
// Auto-detect first-party by root domain
|
|
6
10
|
// @shared getRootDomain
|
|
7
11
|
function getRootDomain(hostname) {
|
|
@@ -21,10 +25,23 @@
|
|
|
21
25
|
|
|
22
26
|
// @shared isFirstParty
|
|
23
27
|
function isFirstParty(hostname) {
|
|
24
|
-
|
|
28
|
+
const root = getRootDomain(hostname);
|
|
29
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
30
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
25
31
|
}
|
|
26
32
|
// @end-shared isFirstParty
|
|
27
33
|
|
|
34
|
+
// @shared logOwnDomainsHint
|
|
35
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
36
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
37
|
+
console.log(
|
|
38
|
+
'%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.',
|
|
39
|
+
"color: #3b82f6;"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// @end-shared logOwnDomainsHint
|
|
44
|
+
|
|
28
45
|
// Gather script timing data
|
|
29
46
|
const scripts = performance
|
|
30
47
|
.getEntriesByType("resource")
|
|
@@ -51,6 +68,12 @@
|
|
|
51
68
|
timings.tcp = r.secureConnectionStart - r.connectStart;
|
|
52
69
|
}
|
|
53
70
|
|
|
71
|
+
// Without Timing-Allow-Origin the browser reports the phases as 0, so response would be
|
|
72
|
+
// the whole duration since the epoch. Only the total is real, the phases are unknown.
|
|
73
|
+
if (!hasTiming) {
|
|
74
|
+
for (const phase of ["dns", "tcp", "tls", "request", "response"]) timings[phase] = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
return {
|
|
55
78
|
name: r.name,
|
|
56
79
|
shortName: url.pathname.split("/").pop() || url.hostname,
|
|
@@ -96,6 +119,7 @@
|
|
|
96
119
|
const thirdStats = calcStats(thirdParty);
|
|
97
120
|
|
|
98
121
|
// Format helpers
|
|
122
|
+
const roundOrNull = (ms) => (ms === null ? null : Math.round(ms));
|
|
99
123
|
const formatMs = (ms) => (ms > 0 ? ms.toFixed(1) + "ms" : "-");
|
|
100
124
|
const formatBar = (value, max) => {
|
|
101
125
|
if (value <= 0 || max <= 0) return "";
|
|
@@ -274,6 +298,7 @@
|
|
|
274
298
|
console.groupEnd();
|
|
275
299
|
}
|
|
276
300
|
|
|
301
|
+
logOwnDomainsHint(thirdParty.length);
|
|
277
302
|
console.groupEnd();
|
|
278
303
|
|
|
279
304
|
// The list keeps the 50 slowest scripts; count and details cover all of them
|
|
@@ -292,7 +317,7 @@
|
|
|
292
317
|
firstPartyAvgTotalMs: Math.round(firstStats.stats.total?.avg || 0),
|
|
293
318
|
thirdPartyAvgTotalMs: Math.round(thirdStats.stats.total?.avg || 0),
|
|
294
319
|
},
|
|
295
|
-
items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs:
|
|
320
|
+
items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: roundOrNull(s.dns), tcpMs: roundOrNull(s.tcp), requestMs: roundOrNull(s.request), responseMs: roundOrNull(s.response), hasTiming: s.hasTiming })),
|
|
296
321
|
issues: [
|
|
297
322
|
...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
|
|
298
323
|
...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
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
|
+
|
|
5
9
|
// Helper to extract font filename from URL
|
|
6
10
|
function getFontName(url) {
|
|
7
11
|
try {
|
|
@@ -30,10 +34,23 @@
|
|
|
30
34
|
|
|
31
35
|
// @shared isFirstParty
|
|
32
36
|
function isFirstParty(hostname) {
|
|
33
|
-
|
|
37
|
+
const root = getRootDomain(hostname);
|
|
38
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
39
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
34
40
|
}
|
|
35
41
|
// @end-shared isFirstParty
|
|
36
42
|
|
|
43
|
+
// @shared logOwnDomainsHint
|
|
44
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
45
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
46
|
+
console.log(
|
|
47
|
+
'%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.',
|
|
48
|
+
"color: #3b82f6;"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// @end-shared logOwnDomainsHint
|
|
53
|
+
|
|
37
54
|
// Check if URL is third-party (a different root domain)
|
|
38
55
|
function isThirdParty(url) {
|
|
39
56
|
try {
|
|
@@ -313,6 +330,7 @@
|
|
|
313
330
|
console.log("4. Self-host fonts when possible for better control");
|
|
314
331
|
console.log("5. Subset fonts to include only needed characters");
|
|
315
332
|
console.log("6. Use WOFF2 format for best compression");
|
|
333
|
+
logOwnDomainsHint(preloadedFonts.filter((f) => f.thirdParty).length);
|
|
316
334
|
console.groupEnd();
|
|
317
335
|
|
|
318
336
|
console.groupEnd();
|
|
@@ -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,16 +511,19 @@
|
|
|
494
511
|
missingPreconnects: missingHints.length,
|
|
495
512
|
redundantHints: redundantDomains.length,
|
|
496
513
|
},
|
|
497
|
-
//
|
|
514
|
+
// The hints to remove (unused), the origins that need a hint and those with a redundant dns-prefetch (at most 50)
|
|
498
515
|
items: [
|
|
516
|
+
...unusedHintItems,
|
|
499
517
|
...missingHints.map(([domain, count]) => ({
|
|
500
518
|
domain,
|
|
519
|
+
href: null,
|
|
501
520
|
requestCount: count,
|
|
502
521
|
action: count >= 5 ? "add-preconnect" : "add-dns-prefetch",
|
|
503
522
|
recommendedHint: count >= 5 ? "preconnect" : "dns-prefetch",
|
|
504
523
|
})),
|
|
505
524
|
...redundantDomains.map((domain) => ({
|
|
506
525
|
domain,
|
|
526
|
+
href: null,
|
|
507
527
|
requestCount: domainRequestCounts[domain] || 0,
|
|
508
528
|
action: "remove-dns-prefetch",
|
|
509
529
|
recommendedHint: "preconnect",
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
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
|
+
|
|
5
9
|
// @shared formatBytes
|
|
6
10
|
function formatBytes(bytes) {
|
|
7
11
|
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
@@ -32,10 +36,23 @@
|
|
|
32
36
|
|
|
33
37
|
// @shared isFirstParty
|
|
34
38
|
function isFirstParty(hostname) {
|
|
35
|
-
|
|
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);
|
|
36
42
|
}
|
|
37
43
|
// @end-shared isFirstParty
|
|
38
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
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// @end-shared logOwnDomainsHint
|
|
55
|
+
|
|
39
56
|
// A script whose URL cannot be parsed counts as first party
|
|
40
57
|
const isFirstPartyUrl = (url) => {
|
|
41
58
|
try {
|
|
@@ -307,6 +324,7 @@
|
|
|
307
324
|
console.log("");
|
|
308
325
|
console.log("%cFor critical inline scripts:", "font-weight: bold;");
|
|
309
326
|
console.log('%c<script>/* Only essential initialization */</script>', "font-family: monospace; color: #22c55e;");
|
|
327
|
+
logOwnDomainsHint(thirdPartyScripts.length);
|
|
310
328
|
console.groupEnd();
|
|
311
329
|
|
|
312
330
|
console.groupEnd();
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
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
|
+
|
|
5
9
|
// Waiting time for the first byte of a resource: responseStart is measured from the
|
|
6
10
|
// start of the page, so subtract requestStart. Cross-origin resources without a
|
|
7
11
|
// Timing-Allow-Origin header report 0 for both and cannot be measured.
|
|
@@ -25,10 +29,23 @@
|
|
|
25
29
|
|
|
26
30
|
// @shared isFirstParty
|
|
27
31
|
function isFirstParty(hostname) {
|
|
28
|
-
|
|
32
|
+
const root = getRootDomain(hostname);
|
|
33
|
+
if (root === getRootDomain(location.hostname)) return true;
|
|
34
|
+
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
|
|
29
35
|
}
|
|
30
36
|
// @end-shared isFirstParty
|
|
31
37
|
|
|
38
|
+
// @shared logOwnDomainsHint
|
|
39
|
+
function logOwnDomainsHint(thirdPartyCount) {
|
|
40
|
+
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
|
|
41
|
+
console.log(
|
|
42
|
+
'%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.',
|
|
43
|
+
"color: #3b82f6;"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// @end-shared logOwnDomainsHint
|
|
48
|
+
|
|
32
49
|
const isThirdPartyUrl = (name) => {
|
|
33
50
|
try {
|
|
34
51
|
return !isFirstParty(new URL(name).hostname);
|
|
@@ -113,6 +130,7 @@
|
|
|
113
130
|
});
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
logOwnDomainsHint(thirdPartyCount);
|
|
116
134
|
console.groupEnd();
|
|
117
135
|
}).observe({
|
|
118
136
|
type: "resource",
|
|
@@ -133,7 +151,16 @@
|
|
|
133
151
|
}))
|
|
134
152
|
.sort((a, b) => b.ttfbMs - a.ttfbMs);
|
|
135
153
|
if (resourcesSync.length === 0) {
|
|
136
|
-
|
|
154
|
+
// Nothing to measure is a valid result, such as a page with every resource inline or hidden by CORS
|
|
155
|
+
return {
|
|
156
|
+
script: "TTFB-Resources",
|
|
157
|
+
status: "ok",
|
|
158
|
+
count: 0,
|
|
159
|
+
corsLimitedAnalysis: corsRestrictedCount > 0,
|
|
160
|
+
details: { avgTtfbMs: null, maxTtfbMs: null, minTtfbMs: null, thirdPartyCount: 0, slowCount: 0, corsRestrictedCount },
|
|
161
|
+
items: [],
|
|
162
|
+
issues: [{ severity: "info", message: "No resources with TTFB data available" }],
|
|
163
|
+
};
|
|
137
164
|
}
|
|
138
165
|
const ttfbVals = resourcesSync.map((r) => r.ttfbMs);
|
|
139
166
|
// The list keeps the 50 slowest resources (already sorted); count and details cover all of them
|