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 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.2. Core Web Vitals, loading audit, and structural checks. See [Roadmap](#roadmap) for what's next.
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, priority hints, resource hints):
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.3.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 before evaluation (for INP). |
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 snippet errored. |
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 `audit`. |
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: Auth flows (login + measure logged-in pages), CrUX field-data enrichment.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
5
5
  "type": "module",
6
6
  "bin": "./src/bin.js",
@@ -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
- return getRootDomain(hostname) === getRootDomain(location.hostname);
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
- const effectiveEntries = entries.filter((e) =>
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
- entries.length > 0
385
- ? Math.round((effectiveEntries.length / entries.length) * 100)
386
- : 0;
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(` Cache efficiency: ${cacheEfficiencyPercent}%`);
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 (entries.length > 0 && cacheEfficiencyPercent < 50) {
789
+ if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
723
790
  recommendations.push(
724
- `🔴 Cache efficiency is low (${cacheEfficiencyPercent}%). Review caching strategy for all static assets.`
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 >= 80
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 issues, at most 50: the ones with the most anti-patterns
808
- // first, then the largest. Clean resources are left out to reduce noise.
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: allAntiPatterns.map((p) => ({
823
- severity: p.severity,
824
- id: p.id,
825
- resource: p.resource,
826
- message: p.message,
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
- return getRootDomain(hostname) === getRootDomain(location.hostname);
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
- return getRootDomain(hostname) === getRootDomain(location.hostname);
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: Math.round(s.dns), tcpMs: Math.round(s.tcp), requestMs: Math.round(s.request), responseMs: Math.round(s.response), hasTiming: s.hasTiming })),
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
- return getRootDomain(hostname) === getRootDomain(location.hostname);
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
- const totalIssues = preloadLinks.filter(l => !usedResourceUrls.has(normalizeUrl(l.href))).length +
465
- preconnectLinks.filter(l => {
466
- const domain = getDomain(l.href);
467
- return domain && !usedDomains.has(domain) && !lazyLoadedDomainsForSummary.has(domain);
468
- }).length +
469
- dnsPrefetchLinks.filter(l => {
470
- const domain = getDomain(l.href);
471
- return domain && !usedDomains.has(domain) && !lazyLoadedDomainsForSummary.has(domain);
472
- }).length;
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
- // Origins that need a hint, and those with a redundant dns-prefetch (at most 50)
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
- return getRootDomain(hostname) === getRootDomain(location.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);
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
- return getRootDomain(hostname) === getRootDomain(location.hostname);
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
- return { script: "TTFB-Resources", status: "error", error: "No resources with TTFB data available", corsLimitedAnalysis: corsRestrictedCount > 0, details: { corsRestrictedCount } };
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