From 299635ac50bd4de855edbee2622ea06b0dd74272 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Sun, 25 Oct 2026 11:10:09 +0100 Subject: [PATCH] feat(snippets): give the issues of the network, hint and cache snippets of Loading a stable code Back-Forward-Cache, Cache-Strategy-Analysis, Client-Side-Redirect-Detection, Compression-Audit, Prefetch-Resource-Validation, Priority-Hints-Audit, Resource-Hints, Resource-Hints-Validation, Server-Timing-Early-Hints, Service-Worker-Analysis, Speculation-Rules-Inspector, TTFB-Resources and TTFB-Sub-Parts carry a code in every issue, with their tables under Issue codes in snippets/SCHEMA.md, and leave PENDING_ISSUE_CODES. The issues about one resource name its URL in target, a Service Worker its scope, and a reason the browser gives for not restoring the page from bfcache, under one code, the reason and its frame, since the reasons are text of the browser and not literals of the snippet. Cache-Strategy-Analysis keeps its id, which the code repeats. The catalog test reads codes in single quotes, as three of these snippets are written. No message changes. --- .../references/schema.md | 137 ++++++++++++++++++ .../webperf-interaction/references/schema.md | 137 ++++++++++++++++++ .../webperf-loading/references/schema.md | 137 ++++++++++++++++++ .../scripts/Back-Forward-Cache.js | 4 +- .../scripts/Cache-Strategy-Analysis.js | 4 +- .../scripts/Client-Side-Redirect-Detection.js | 4 +- .../scripts/Compression-Audit.js | 4 +- .../scripts/Prefetch-Resource-Validation.js | 4 +- .../scripts/Priority-Hints-Audit.js | 4 +- .../scripts/Resource-Hints-Validation.js | 4 +- .../webperf-loading/scripts/Resource-Hints.js | 4 +- .../scripts/Server-Timing-Early-Hints.js | 4 +- .../scripts/Service-Worker-Analysis.js | 4 +- .../scripts/Speculation-Rules-Inspector.js | 4 +- .../webperf-loading/scripts/TTFB-Resources.js | 4 +- .../webperf-loading/scripts/TTFB-Sub-Parts.js | 4 +- .../skills/webperf-media/references/schema.md | 137 ++++++++++++++++++ .../webperf-resources/references/schema.md | 137 ++++++++++++++++++ CHANGELOG.md | 2 +- cli/tests/e2e/audit.test.js | 2 +- cli/tests/e2e/bfcache.test.js | 3 + cli/tests/e2e/cache-strategy.test.js | 2 + cli/tests/e2e/loading-returns.test.js | 5 +- cli/tests/e2e/speculation-rules.test.js | 2 +- cli/tests/helpers/issue-codes.js | 23 +-- cli/tests/unit/issue-codes.test.js | 4 + .../references/schema.md | 137 ++++++++++++++++++ .../webperf-interaction/references/schema.md | 137 ++++++++++++++++++ .../webperf-loading/references/schema.md | 137 ++++++++++++++++++ .../scripts/Back-Forward-Cache.js | 11 +- .../scripts/Cache-Strategy-Analysis.js | 14 +- .../scripts/Client-Side-Redirect-Detection.js | 5 + .../scripts/Compression-Audit.js | 3 + .../scripts/Prefetch-Resource-Validation.js | 15 +- .../scripts/Priority-Hints-Audit.js | 14 +- .../scripts/Resource-Hints-Validation.js | 3 + .../webperf-loading/scripts/Resource-Hints.js | 12 +- .../scripts/Server-Timing-Early-Hints.js | 3 + .../scripts/Service-Worker-Analysis.js | 16 +- .../scripts/Speculation-Rules-Inspector.js | 7 + .../webperf-loading/scripts/TTFB-Resources.js | 1 + .../webperf-loading/scripts/TTFB-Sub-Parts.js | 1 + .../webperf-media/references/schema.md | 137 ++++++++++++++++++ .../webperf-resources/references/schema.md | 137 ++++++++++++++++++ .../scripts/Back-Forward-Cache.js | 11 +- .../scripts/Cache-Strategy-Analysis.js | 14 +- .../scripts/Client-Side-Redirect-Detection.js | 5 + .../scripts/Compression-Audit.js | 3 + .../scripts/Prefetch-Resource-Validation.js | 15 +- .../scripts/Priority-Hints-Audit.js | 14 +- .../scripts/Resource-Hints-Validation.js | 3 + .../webperf-loading/scripts/Resource-Hints.js | 12 +- .../scripts/Server-Timing-Early-Hints.js | 3 + .../scripts/Service-Worker-Analysis.js | 16 +- .../scripts/Speculation-Rules-Inspector.js | 7 + .../webperf-loading/scripts/TTFB-Resources.js | 1 + .../webperf-loading/scripts/TTFB-Sub-Parts.js | 1 + dist/webperf-loading/Back-Forward-Cache.js | 11 +- .../Cache-Strategy-Analysis.js | 14 +- .../Client-Side-Redirect-Detection.js | 5 + dist/webperf-loading/Compression-Audit.js | 3 + .../Prefetch-Resource-Validation.js | 15 +- dist/webperf-loading/Priority-Hints-Audit.js | 14 +- .../Resource-Hints-Validation.js | 3 + dist/webperf-loading/Resource-Hints.js | 12 +- .../Server-Timing-Early-Hints.js | 3 + .../Service-Worker-Analysis.js | 16 +- .../Speculation-Rules-Inspector.js | 7 + dist/webperf-loading/TTFB-Resources.js | 1 + dist/webperf-loading/TTFB-Sub-Parts.js | 1 + public/llms-full.txt | 100 ++++++++++--- .../references/schema.md | 137 ++++++++++++++++++ .../webperf-interaction/references/schema.md | 137 ++++++++++++++++++ skills/webperf-loading/references/schema.md | 137 ++++++++++++++++++ .../scripts/Back-Forward-Cache.js | 4 +- .../scripts/Cache-Strategy-Analysis.js | 4 +- .../scripts/Client-Side-Redirect-Detection.js | 4 +- .../scripts/Compression-Audit.js | 4 +- .../scripts/Prefetch-Resource-Validation.js | 4 +- .../scripts/Priority-Hints-Audit.js | 4 +- .../scripts/Resource-Hints-Validation.js | 4 +- .../webperf-loading/scripts/Resource-Hints.js | 4 +- .../scripts/Server-Timing-Early-Hints.js | 4 +- .../scripts/Service-Worker-Analysis.js | 4 +- .../scripts/Speculation-Rules-Inspector.js | 4 +- .../webperf-loading/scripts/TTFB-Resources.js | 4 +- .../webperf-loading/scripts/TTFB-Sub-Parts.js | 4 +- skills/webperf-media/references/schema.md | 137 ++++++++++++++++++ skills/webperf-resources/references/schema.md | 137 ++++++++++++++++++ snippets/Loading/Back-Forward-Cache.js | 7 + snippets/Loading/Cache-Strategy-Analysis.js | 12 ++ .../Loading/Client-Side-Redirect-Detection.js | 8 +- snippets/Loading/Compression-Audit.js | 3 + .../Loading/Prefetch-Resource-Validation.js | 15 +- snippets/Loading/Priority-Hints-Audit.js | 10 ++ snippets/Loading/Resource-Hints-Validation.js | 6 +- snippets/Loading/Resource-Hints.js | 8 +- snippets/Loading/Server-Timing-Early-Hints.js | 3 + snippets/Loading/Service-Worker-Analysis.js | 14 +- .../Loading/Speculation-Rules-Inspector.js | 11 +- snippets/Loading/TTFB-Resources.js | 2 +- snippets/Loading/TTFB-Sub-Parts.js | 1 + snippets/SCHEMA.md | 137 ++++++++++++++++++ 103 files changed, 2724 insertions(+), 130 deletions(-) diff --git a/.claude/skills/webperf-core-web-vitals/references/schema.md b/.claude/skills/webperf-core-web-vitals/references/schema.md index 988a08ea..975142c7 100644 --- a/.claude/skills/webperf-core-web-vitals/references/schema.md +++ b/.claude/skills/webperf-core-web-vitals/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/.claude/skills/webperf-interaction/references/schema.md b/.claude/skills/webperf-interaction/references/schema.md index 988a08ea..975142c7 100644 --- a/.claude/skills/webperf-interaction/references/schema.md +++ b/.claude/skills/webperf-interaction/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/.claude/skills/webperf-loading/references/schema.md b/.claude/skills/webperf-loading/references/schema.md index 988a08ea..975142c7 100644 --- a/.claude/skills/webperf-loading/references/schema.md +++ b/.claude/skills/webperf-loading/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/.claude/skills/webperf-loading/scripts/Back-Forward-Cache.js b/.claude/skills/webperf-loading/scripts/Back-Forward-Cache.js index 5fc63e68..9325a3b3 100644 --- a/.claude/skills/webperf-loading/scripts/Back-Forward-Cache.js +++ b/.claude/skills/webperf-loading/scripts/Back-Forward-Cache.js @@ -1,2 +1,2 @@ -// snippets/Loading/Back-Forward-Cache.js | sha256:95f8a72cfe7b5f88 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Back-Forward-Cache.js -(async()=>{const e={supported:"PerformanceNavigationTiming"in window,wasRestored:!1,eligibility:null,blockingReasons:[],notRestoredReasons:null,recommendations:[]};window.addEventListener("pageshow",o=>{o.persisted&&(e.wasRestored=!0)});const o={"unload-listener":"unload event listeners block bfcache. Use pagehide or visibilitychange instead.","response-cache-control-no-store":"Cache-Control: no-store on the page response prevents caching. Use no-cache instead.",websocket:"Open WebSocket connections prevent bfcache. Close them on pagehide.",broadcastchannel:"Open BroadcastChannel instances prevent bfcache. Close them on pagehide.","indexeddb-connection":"Open IndexedDB connections prevent bfcache. Close them on pagehide.",masked:"The browser does not disclose the exact reason (for example, a cross-origin frame)."},n=e=>e&&{url:e.url??null,src:e.src??null,id:e.id??null,name:e.name??null,reasons:(e.reasons||[]).map(e=>"string"==typeof e?e:e?.reason).filter(Boolean),children:(e.children||[]).map(n)},s=(e,o)=>[...e.reasons.map(e=>({reason:e,frame:o})),...e.children.flatMap((e,o)=>s(e,e.src||e.url||e.name||e.id||`iframe ${o+1}`))],r=async()=>{const r=[],t=performance.getEntriesByType("navigation")[0];e.notRestoredReasons=n(t?.notRestoredReasons);const a=e.notRestoredReasons?s(e.notRestoredReasons,e.notRestoredReasons.url||"main frame"):[];if(a.forEach(({reason:e,frame:n})=>{r.push({reason:`${e} (${n})`,severity:"high",source:"browser",description:o[e.toLowerCase()]||"Reported by the browser as a bfcache blocker"})}),null===window.onunload||a.some(({reason:e})=>"unload-listener"===e)||r.push({reason:"window.onunload handler set",severity:"high",source:"detected",description:o["unload-listener"]}),/^https?:$/.test(location.protocol))try{const e=(await fetch(function(e){try{const o=new URL(e,location.href);return/^https?:$/.test(o.protocol)?(o.hash="webperf-snippets",o.href):e}catch{return e}}(location.href),{method:"HEAD",credentials:"same-origin"})).headers.get("cache-control")||"";/no-store/i.test(e)&&!a.some(({reason:e})=>"response-cache-control-no-store"===e)&&r.push({reason:"Cache-Control: no-store on the page response",severity:"medium",source:"detected",description:o["response-cache-control-no-store"]})}catch{}const i=document.querySelectorAll("iframe").length;return i>0&&r.push({reason:`${i} iframe(s) on the page`,severity:"info",source:"note",description:"A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation."}),"serviceWorker"in navigator&&navigator.serviceWorker.controller&&r.push({reason:"Service Worker controls the page",severity:"info",source:"note",description:"Service Workers are compatible with bfcache."}),e.blockingReasons=r,e.recommendations=[...new Set(r.filter(e=>"info"!==e.severity).map(e=>e.description))],e.eligibility=r.some(e=>"browser"===e.source)?"blocked":r.some(e=>"detected"===e.source)?"likely-blocked":"no-blockers-detected",e.eligibility},t=(e,o)=>{" ".repeat(o),e.reasons.forEach(e=>{e.toLowerCase()}),e.children.forEach((e,n)=>{t(e,o+3)})},a=()=>{performance.getEntriesByType("navigation"),e.notRestoredReasons&&t(e.notRestoredReasons,3),e.recommendations.length>0&&e.recommendations.forEach((e,o)=>{})},i=()=>({script:"Back-Forward-Cache",status:"ok",details:{eligibility:e.eligibility,wasRestored:e.wasRestored,supported:e.supported,navigationType:performance.getEntriesByType("navigation")[0]?.type??null,notRestoredReasons:e.notRestoredReasons},issues:e.blockingReasons.map(e=>({severity:"high"===e.severity?"error":"medium"===e.severity?"warning":"info",message:e.reason}))});return window.checkBfcache=async()=>(await r(),a(),i()),await r(),a(),{...i(),message:"bfcache analysis complete. Call checkBfcache() to re-run analysis."}})(); \ No newline at end of file +// snippets/Loading/Back-Forward-Cache.js | sha256:6b9e604a83c42bfb | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Back-Forward-Cache.js +(async()=>{const e={supported:"PerformanceNavigationTiming"in window,wasRestored:!1,eligibility:null,blockingReasons:[],notRestoredReasons:null,recommendations:[]};window.addEventListener("pageshow",o=>{o.persisted&&(e.wasRestored=!0)});const o={"unload-listener":"unload event listeners block bfcache. Use pagehide or visibilitychange instead.","response-cache-control-no-store":"Cache-Control: no-store on the page response prevents caching. Use no-cache instead.",websocket:"Open WebSocket connections prevent bfcache. Close them on pagehide.",broadcastchannel:"Open BroadcastChannel instances prevent bfcache. Close them on pagehide.","indexeddb-connection":"Open IndexedDB connections prevent bfcache. Close them on pagehide.",masked:"The browser does not disclose the exact reason (for example, a cross-origin frame)."},n=e=>e&&{url:e.url??null,src:e.src??null,id:e.id??null,name:e.name??null,reasons:(e.reasons||[]).map(e=>"string"==typeof e?e:e?.reason).filter(Boolean),children:(e.children||[]).map(n)},s=(e,o)=>[...e.reasons.map(e=>({reason:e,frame:o})),...e.children.flatMap((e,o)=>s(e,e.src||e.url||e.name||e.id||`iframe ${o+1}`))],r=async()=>{const r=[],t=performance.getEntriesByType("navigation")[0];e.notRestoredReasons=n(t?.notRestoredReasons);const a=e.notRestoredReasons?s(e.notRestoredReasons,e.notRestoredReasons.url||"main frame"):[];if(a.forEach(({reason:e,frame:n})=>{r.push({code:"not-restored",reason:`${e} (${n})`,severity:"high",source:"browser",description:o[e.toLowerCase()]||"Reported by the browser as a bfcache blocker"})}),null===window.onunload||a.some(({reason:e})=>"unload-listener"===e)||r.push({code:"unload-handler",reason:"window.onunload handler set",severity:"high",source:"detected",description:o["unload-listener"]}),/^https?:$/.test(location.protocol))try{const e=(await fetch(function(e){try{const o=new URL(e,location.href);return/^https?:$/.test(o.protocol)?(o.hash="webperf-snippets",o.href):e}catch{return e}}(location.href),{method:"HEAD",credentials:"same-origin"})).headers.get("cache-control")||"";/no-store/i.test(e)&&!a.some(({reason:e})=>"response-cache-control-no-store"===e)&&r.push({code:"cache-control-no-store",reason:"Cache-Control: no-store on the page response",severity:"medium",source:"detected",description:o["response-cache-control-no-store"]})}catch{}const i=document.querySelectorAll("iframe").length;return i>0&&r.push({code:"iframes",reason:`${i} iframe(s) on the page`,severity:"info",source:"note",description:"A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation."}),"serviceWorker"in navigator&&navigator.serviceWorker.controller&&r.push({code:"service-worker",reason:"Service Worker controls the page",severity:"info",source:"note",description:"Service Workers are compatible with bfcache."}),e.blockingReasons=r,e.recommendations=[...new Set(r.filter(e=>"info"!==e.severity).map(e=>e.description))],e.eligibility=r.some(e=>"browser"===e.source)?"blocked":r.some(e=>"detected"===e.source)?"likely-blocked":"no-blockers-detected",e.eligibility},t=(e,o)=>{" ".repeat(o),e.reasons.forEach(e=>{e.toLowerCase()}),e.children.forEach((e,n)=>{t(e,o+3)})},a=()=>{performance.getEntriesByType("navigation"),e.notRestoredReasons&&t(e.notRestoredReasons,3),e.recommendations.length>0&&e.recommendations.forEach((e,o)=>{})},i=()=>({script:"Back-Forward-Cache",status:"ok",details:{eligibility:e.eligibility,wasRestored:e.wasRestored,supported:e.supported,navigationType:performance.getEntriesByType("navigation")[0]?.type??null,notRestoredReasons:e.notRestoredReasons},issues:e.blockingReasons.map(e=>({code:e.code,severity:"high"===e.severity?"error":"medium"===e.severity?"warning":"info",message:e.reason,..."not-restored"===e.code?{target:e.reason}:{}}))});return window.checkBfcache=async()=>(await r(),a(),i()),await r(),a(),{...i(),message:"bfcache analysis complete. Call checkBfcache() to re-run analysis."}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js b/.claude/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js index e7785fd8..adb987e4 100644 --- a/.claude/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js +++ b/.claude/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js @@ -1,2 +1,2 @@ -// snippets/Loading/Cache-Strategy-Analysis.js | sha256:7b1eb7a352f67f8d | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Cache-Strategy-Analysis.js -(async()=>{function e(e){try{const t=new URL(e,location.href);return/^https?:$/.test(t.protocol)?(t.hash="webperf-snippets",t.href):e}catch{return e}}const t=[];function n(e){const t=e.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(":"))return t;const n=t.split(".");return n.length<=2?t:2===n[n.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(n[n.length-2])?n.slice(-3).join("."):n.slice(-2).join(".")}function s(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}function r(e){const t=(e.name||"").split("?")[0].toLowerCase();return/\.(woff2?|ttf|otf|eot)$/.test(t)?"font":/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/.test(t)?"image":/\.(mp4|webm|ogg|mov)$/.test(t)?"video":/\.(mp3|wav|flac|aac)$/.test(t)?"audio":/\.css$/.test(t)?"style":/\.js$/.test(t)?"script":{script:"script",link:"style",css:"style",img:"image",image:"image",video:"video",audio:"audio",font:"font",fetch:"fetch",xmlhttprequest:"xhr",iframe:"iframe",other:"other"}[e.initiatorType]||e.initiatorType||"other"}function o(e){return[/[.-][a-f0-9]{8,32}\.(js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)(\?|$)/,/[?&](v|version|hash|rev|_v)=[a-zA-Z0-9._-]+/].some(t=>t.test(e))}function a(e){return["script","style","font","image"].includes(e)}function i(e){const t={};if(!e)return t;for(const n of e.split(",")){const[e,s]=n.trim().split("=");t[e.trim().toLowerCase()]=void 0===s||s.trim().replace(/"/g,"")}return t}function c(e){if(!e)return"unknown";const t=i(e["cache-control"]||""),n=!!e.expires;if(t["no-store"])return"no-store";if(t["no-cache"])return"no-cache";if(t.immutable)return"immutable";if(t["stale-while-revalidate"])return"swr";if(t["must-revalidate"]&&(e.etag||e["last-modified"]))return"must-revalidate";const s=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,r=(void 0!==t["s-maxage"]?parseInt(t["s-maxage"],10):null)??s;return null!==r?0===r?"no-cache":r<3600?"short":r<=86400?"medium":"long":n&&!e["cache-control"]?"expires-only":0!==Object.keys(t).length||n?"unknown":"none"}function l(e){if(!e)return{cdn:"unknown",status:"unknown"};if(e["cf-cache-status"])return{cdn:"Cloudflare",status:e["cf-cache-status"]};if(e["x-vercel-cache"])return{cdn:"Vercel",status:e["x-vercel-cache"]};if(e["x-cache"]){const t=e["x-cache"].toUpperCase();return{cdn:e["x-amz-cf-id"]?"CloudFront":"CDN",status:t.includes("HIT")?"HIT":"MISS"}}if(e.age)return{cdn:"Proxy/CDN",status:`HIT (Age: ${e.age}s)`};if(e.via)return{cdn:`Proxy (${e.via})`,status:"proxied"};if(e["server-timing"]){const t=e["server-timing"].toLowerCase();if(t.includes("cdn-cache")||t.includes("cache;"))return{cdn:"CDN (Server-Timing)",status:t.includes("hit")?"HIT":"MISS"}}return{cdn:"none",status:"no-cdn-headers"}}const h=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),d=new Map;for(const e of h)d.set(e.name,(d.get(e.name)||0)+1);const u=h.map(e=>{let s;try{s=new URL(e.name)}catch{s={hostname:location.hostname,pathname:e.name}}const a=function(e){const s=n(e);return s===n(location.hostname)||t.some(e=>n(String(e).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===s)}(s.hostname),i=e.encodedBodySize>0&&0===e.transferSize,c=0===e.encodedBodySize&&0===e.transferSize&&!a;return{url:e.name,shortName:(s.pathname||e.name).split("/").filter(Boolean).pop()||s.hostname,host:s.hostname,firstParty:a,type:r(e),transferSize:e.transferSize||0,encodedBodySize:e.encodedBodySize||0,decodedBodySize:e.decodedBodySize||0,isCached:i,isCorsRestricted:c,nextHopProtocol:e.nextHopProtocol||"unknown",duration:e.duration,hasHash:o(e.name),isDuplicate:(d.get(e.name)||1)>1,headers:null,cacheStrategy:null,cdnInfo:null,antiPatterns:[]}}),f=new Set(["script","style","font","image"]),g=u.filter(e=>e.url.startsWith(location.origin+"/")&&f.has(e.type)),m=[...new Set(g.map(e=>e.url))],p=new Map;for(let t=0;t{try{const n=await fetch(e(t),{method:"HEAD",cache:"no-store"});if(!n.ok)return void p.set(t,null);const s={};for(const[e,t]of n.headers)s[e.toLowerCase()]=t;p.set(t,s)}catch{p.set(t,null)}}))}for(const e of u)e.headers=p.get(e.url)||null;for(const e of u)e.cacheStrategy=e.headers?c(e.headers):e.isCorsRestricted?"unknown (CORS)":e.isCached?"cached (inferred)":"unknown",e.cdnInfo=l(e.headers);const y=new Set(["immutable","long","medium","swr","must-revalidate","cached (inferred)"]);for(const e of u){if(!e.headers)continue;const t=i(e.headers["cache-control"]||""),n=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,r=e.cacheStrategy,o=e.type;a(o)&&t["no-store"]&&e.antiPatterns.push({id:"static-no-store",severity:"error",message:`${o} asset with no-store; static assets should be cacheable`}),e.hasHash&&null!==n&&n<86400&&!t["no-store"]&&e.antiPatterns.push({id:"versioned-short-cache",severity:"warning",message:`Versioned URL (hash detected) but max-age=${n}s: consider long cache or immutable`}),e.hasHash&&!t.immutable&&!t["no-store"]&&(null===n||n>=86400)&&e.antiPatterns.push({id:"versioned-not-immutable",severity:"info",message:"Versioned URL (hash detected) without Cache-Control: immutable"}),e.decodedBodySize>102400&&["none","no-store","no-cache"].includes(r)&&e.antiPatterns.push({id:"large-no-cache",severity:"error",message:`Large resource (${s(e.decodedBodySize)}) with no effective cache`}),e.headers.expires&&!e.headers["cache-control"]&&e.antiPatterns.push({id:"expires-without-cc",severity:"warning",message:"Uses Expires header without Cache-Control (outdated pattern)"}),a(o)&&0===n&&e.antiPatterns.push({id:"maxage-zero-static",severity:"warning",message:"Static asset with max-age=0, which forces revalidation on every request"}),a(o)&&null!==n&&n>0&&n<3600&&!t["no-store"]&&e.antiPatterns.push({id:"short-cache-static",severity:"warning",message:`Static asset with max-age=${n}s (< 1h): consider a longer cache duration`})}const w=u.filter(e=>e.cdnInfo&&"none"!==e.cdnInfo.cdn&&"unknown"!==e.cdnInfo.cdn),v=w.filter(e=>e.cdnInfo.status.toUpperCase().startsWith("HIT")).length,S=w.length>0?Math.round(v/w.length*100):null,C=u.filter(e=>null!==e.headers),x=C.filter(e=>y.has(e.cacheStrategy)),b=C.length>0?Math.round(x.length/C.length*100):null,P=u.filter(e=>e.isCached).reduce((e,t)=>e+t.decodedBodySize,0),$={};for(const e of u){const t=e.nextHopProtocol||"unknown";$[t]=($[t]||0)+1}const z=u.filter(e=>e.encodedBodySize>1024&&e.decodedBodySize>0&&e.encodedBodySize/e.decodedBodySize>.9&&["script","style","fetch","xhr"].includes(e.type)),B=[...new Set(u.filter(e=>e.isDuplicate).map(e=>e.url))].map(e=>{const t=u.filter(t=>t.url===e),n=t.filter(e=>!e.isCached).length;return{url:e,shortName:t[0]?.shortName||e,count:d.get(e),type:t[0]?.type||"unknown",uncachedLoads:n,costly:n>0}}),k=B.filter(e=>e.costly),R=B.filter(e=>!e.costly);for(const e of z)e.antiPatterns.push({id:"uncompressed",severity:"warning",message:`${e.type} sent uncompressed (${s(e.encodedBodySize)} on the wire for ${s(e.decodedBodySize)}): enable Brotli or gzip`});for(const e of k)u.find(t=>t.url===e.url).antiPatterns.push({id:"duplicate-uncached",severity:"warning",message:`Loaded ${e.count} times, ${e.uncachedLoads} from the network: load it once or make it cacheable`});if(0===u.length)return{script:"Cache-Strategy-Analysis",status:"error",error:"No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again."};const H=u.flatMap(e=>e.antiPatterns.map(t=>({...t,resource:e.shortName,url:e.url}))),I={};for(const e of u){const t=e.cacheStrategy;I[t]||(I[t]=0),I[t]++}const M=u.filter(e=>["none","no-store","no-cache"].includes(e.cacheStrategy)),T=u.filter(e=>"short"===e.cacheStrategy),L=u.filter(e=>null!==e.headers).length,N=Math.round(L/u.length*100),A=u.filter(e=>e.isCorsRestricted).length,D=u.filter(e=>!(null!==e.headers||e.isCorsRestricted||e.url.startsWith(location.origin+"/")&&f.has(e.type)));let E=null;if(N<50){const e=new Map;for(const t of u.filter(e=>null===e.headers))e.set(t.host,(e.get(t.host)||0)+1);const t=[...e.entries()].sort((e,t)=>t[1]-e[1]).slice(0,3).map(([e])=>e).join(", ");E={severity:"info",id:"header-coverage",message:`Cache headers were read for ${L} of ${u.length} resources (${N}%): 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 ${t}; check those hosts in the Network panel or with curl -I`}}if(D.length>0&&[...new Set(D.map(e=>e.type))].join(", "),H.length>0&&(H.filter(e=>"error"===e.severity),H.filter(e=>"warning"===e.severity),H.filter(e=>"info"===e.severity)),["immutable","long","medium","short","swr","must-revalidate","no-cache","no-store","expires-only","none","cached (inferred)","unknown (CORS)","unknown"].filter(e=>I[e]).map(e=>({Strategy:e,Count:I[e],"% of Total":`${Math.round(I[e]/u.length*100)}%`,Effective:y.has(e)?"✅":"❌"})),H.length>0)for(const e of["error","warning","info"])H.filter(t=>t.severity===e);const j=[];if(M.filter(e=>a(e.type)).length>0&&j.push("🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."),T.filter(e=>e.hasHash).length>0&&j.push("🟡 Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."),H.some(e=>"expires-without-cc"===e.id)&&j.push("🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."),null!==b&&b<50&&j.push(`🔴 Cache efficiency is low (${b}%, measured on the ${C.length} resources whose cache headers could be read). Review caching strategy for those static assets.`),null!==S&&S<70&&j.push(`🟡 CDN hit rate is ${S}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`),$["http/1.1"]>0&&j.push(`🟡 ${$["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`),z.length>0&&j.push(`🟡 ${z.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`),k.length>0?j.push(`🟡 ${k.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`):R.length>0&&j.push(`â„šī¸ ${R.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`),A>0&&0===L&&j.push("â„šī¸ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights."),j.length>0)for(const e of j);u.filter(e=>!e.firstParty);const U=null===b?null:b>=80?"good":b>=50?"needs-improvement":"poor",O=u.filter(e=>e.antiPatterns.length>0||["none","no-store","no-cache","short"].includes(e.cacheStrategy));return{script:"Cache-Strategy-Analysis",status:"ok",count:u.length,corsLimitedAnalysis:A>0,details:{totalResources:u.length,actionableCount:O.length,headersAnalyzed:L,corsRestricted:A,excludedFromHeaders:D.length,headerCoveragePercent:N,cacheEfficiencyPercent:b,cacheEfficiencyRating:U,cacheEfficiencyResources:C.length,bandwidthSavedBytes:P,strategyDistribution:I,cdnDetected:w.length>0,cdnHitRate:S,cdnHitRateRating:null===S?null:S>=70?"good":"poor",antiPatternCount:H.length,duplicatesCostlyCount:k.length,uncompressedCount:z.length,protocolDistribution:$},items:O.sort((e,t)=>t.antiPatterns.length-e.antiPatterns.length||t.decodedBodySize-e.decodedBodySize).slice(0,50).map(e=>({shortName:e.shortName,host:e.host,type:e.type,firstParty:e.firstParty,cacheStrategy:e.cacheStrategy,sizeBytes:e.decodedBodySize,isCached:e.isCached,antiPatterns:e.antiPatterns.map(e=>e.id)})),issues:[...H.map(e=>({severity:e.severity,id:e.id,resource:e.resource,message:e.message})),...E?[E]:[]],recommendations:j}})(); \ No newline at end of file +// snippets/Loading/Cache-Strategy-Analysis.js | sha256:85c1a40556dc2f04 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Cache-Strategy-Analysis.js +(async()=>{function e(e){try{const t=new URL(e,location.href);return/^https?:$/.test(t.protocol)?(t.hash="webperf-snippets",t.href):e}catch{return e}}const t=[];function n(e){const t=e.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(":"))return t;const n=t.split(".");return n.length<=2?t:2===n[n.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(n[n.length-2])?n.slice(-3).join("."):n.slice(-2).join(".")}function r(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}function s(e){const t=(e.name||"").split("?")[0].toLowerCase();return/\.(woff2?|ttf|otf|eot)$/.test(t)?"font":/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/.test(t)?"image":/\.(mp4|webm|ogg|mov)$/.test(t)?"video":/\.(mp3|wav|flac|aac)$/.test(t)?"audio":/\.css$/.test(t)?"style":/\.js$/.test(t)?"script":{script:"script",link:"style",css:"style",img:"image",image:"image",video:"video",audio:"audio",font:"font",fetch:"fetch",xmlhttprequest:"xhr",iframe:"iframe",other:"other"}[e.initiatorType]||e.initiatorType||"other"}function o(e){return[/[.-][a-f0-9]{8,32}\.(js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)(\?|$)/,/[?&](v|version|hash|rev|_v)=[a-zA-Z0-9._-]+/].some(t=>t.test(e))}function a(e){return["script","style","font","image"].includes(e)}function i(e){const t={};if(!e)return t;for(const n of e.split(",")){const[e,r]=n.trim().split("=");t[e.trim().toLowerCase()]=void 0===r||r.trim().replace(/"/g,"")}return t}function c(e){if(!e)return"unknown";const t=i(e["cache-control"]||""),n=!!e.expires;if(t["no-store"])return"no-store";if(t["no-cache"])return"no-cache";if(t.immutable)return"immutable";if(t["stale-while-revalidate"])return"swr";if(t["must-revalidate"]&&(e.etag||e["last-modified"]))return"must-revalidate";const r=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,s=(void 0!==t["s-maxage"]?parseInt(t["s-maxage"],10):null)??r;return null!==s?0===s?"no-cache":s<3600?"short":s<=86400?"medium":"long":n&&!e["cache-control"]?"expires-only":0!==Object.keys(t).length||n?"unknown":"none"}function l(e){if(!e)return{cdn:"unknown",status:"unknown"};if(e["cf-cache-status"])return{cdn:"Cloudflare",status:e["cf-cache-status"]};if(e["x-vercel-cache"])return{cdn:"Vercel",status:e["x-vercel-cache"]};if(e["x-cache"]){const t=e["x-cache"].toUpperCase();return{cdn:e["x-amz-cf-id"]?"CloudFront":"CDN",status:t.includes("HIT")?"HIT":"MISS"}}if(e.age)return{cdn:"Proxy/CDN",status:`HIT (Age: ${e.age}s)`};if(e.via)return{cdn:`Proxy (${e.via})`,status:"proxied"};if(e["server-timing"]){const t=e["server-timing"].toLowerCase();if(t.includes("cdn-cache")||t.includes("cache;"))return{cdn:"CDN (Server-Timing)",status:t.includes("hit")?"HIT":"MISS"}}return{cdn:"none",status:"no-cdn-headers"}}const h=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),d=new Map;for(const e of h)d.set(e.name,(d.get(e.name)||0)+1);const u=h.map(e=>{let r;try{r=new URL(e.name)}catch{r={hostname:location.hostname,pathname:e.name}}const a=function(e){const r=n(e);return r===n(location.hostname)||t.some(e=>n(String(e).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===r)}(r.hostname),i=e.encodedBodySize>0&&0===e.transferSize,c=0===e.encodedBodySize&&0===e.transferSize&&!a;return{url:e.name,shortName:(r.pathname||e.name).split("/").filter(Boolean).pop()||r.hostname,host:r.hostname,firstParty:a,type:s(e),transferSize:e.transferSize||0,encodedBodySize:e.encodedBodySize||0,decodedBodySize:e.decodedBodySize||0,isCached:i,isCorsRestricted:c,nextHopProtocol:e.nextHopProtocol||"unknown",duration:e.duration,hasHash:o(e.name),isDuplicate:(d.get(e.name)||1)>1,headers:null,cacheStrategy:null,cdnInfo:null,antiPatterns:[]}}),f=new Set(["script","style","font","image"]),g=u.filter(e=>e.url.startsWith(location.origin+"/")&&f.has(e.type)),m=[...new Set(g.map(e=>e.url))],p=new Map;for(let t=0;t{try{const n=await fetch(e(t),{method:"HEAD",cache:"no-store"});if(!n.ok)return void p.set(t,null);const r={};for(const[e,t]of n.headers)r[e.toLowerCase()]=t;p.set(t,r)}catch{p.set(t,null)}}))}for(const e of u)e.headers=p.get(e.url)||null;for(const e of u)e.cacheStrategy=e.headers?c(e.headers):e.isCorsRestricted?"unknown (CORS)":e.isCached?"cached (inferred)":"unknown",e.cdnInfo=l(e.headers);const y=new Set(["immutable","long","medium","swr","must-revalidate","cached (inferred)"]);for(const e of u){if(!e.headers)continue;const t=i(e.headers["cache-control"]||""),n=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,s=e.cacheStrategy,o=e.type;a(o)&&t["no-store"]&&e.antiPatterns.push({code:"static-no-store",id:"static-no-store",severity:"error",message:`${o} asset with no-store; static assets should be cacheable`}),e.hasHash&&null!==n&&n<86400&&!t["no-store"]&&e.antiPatterns.push({code:"versioned-short-cache",id:"versioned-short-cache",severity:"warning",message:`Versioned URL (hash detected) but max-age=${n}s: consider long cache or immutable`}),e.hasHash&&!t.immutable&&!t["no-store"]&&(null===n||n>=86400)&&e.antiPatterns.push({code:"versioned-not-immutable",id:"versioned-not-immutable",severity:"info",message:"Versioned URL (hash detected) without Cache-Control: immutable"}),e.decodedBodySize>102400&&["none","no-store","no-cache"].includes(s)&&e.antiPatterns.push({code:"large-no-cache",id:"large-no-cache",severity:"error",message:`Large resource (${r(e.decodedBodySize)}) with no effective cache`}),e.headers.expires&&!e.headers["cache-control"]&&e.antiPatterns.push({code:"expires-without-cc",id:"expires-without-cc",severity:"warning",message:"Uses Expires header without Cache-Control (outdated pattern)"}),a(o)&&0===n&&e.antiPatterns.push({code:"maxage-zero-static",id:"maxage-zero-static",severity:"warning",message:"Static asset with max-age=0, which forces revalidation on every request"}),a(o)&&null!==n&&n>0&&n<3600&&!t["no-store"]&&e.antiPatterns.push({code:"short-cache-static",id:"short-cache-static",severity:"warning",message:`Static asset with max-age=${n}s (< 1h): consider a longer cache duration`})}const w=u.filter(e=>e.cdnInfo&&"none"!==e.cdnInfo.cdn&&"unknown"!==e.cdnInfo.cdn),v=w.filter(e=>e.cdnInfo.status.toUpperCase().startsWith("HIT")).length,S=w.length>0?Math.round(v/w.length*100):null,C=u.filter(e=>null!==e.headers),x=C.filter(e=>y.has(e.cacheStrategy)),b=C.length>0?Math.round(x.length/C.length*100):null,P=u.filter(e=>e.isCached).reduce((e,t)=>e+t.decodedBodySize,0),$={};for(const e of u){const t=e.nextHopProtocol||"unknown";$[t]=($[t]||0)+1}const z=u.filter(e=>e.encodedBodySize>1024&&e.decodedBodySize>0&&e.encodedBodySize/e.decodedBodySize>.9&&["script","style","fetch","xhr"].includes(e.type)),B=[...new Set(u.filter(e=>e.isDuplicate).map(e=>e.url))].map(e=>{const t=u.filter(t=>t.url===e),n=t.filter(e=>!e.isCached).length;return{url:e,shortName:t[0]?.shortName||e,count:d.get(e),type:t[0]?.type||"unknown",uncachedLoads:n,costly:n>0}}),k=B.filter(e=>e.costly),R=B.filter(e=>!e.costly);for(const e of z)e.antiPatterns.push({code:"uncompressed",id:"uncompressed",severity:"warning",message:`${e.type} sent uncompressed (${r(e.encodedBodySize)} on the wire for ${r(e.decodedBodySize)}): enable Brotli or gzip`});for(const e of k)u.find(t=>t.url===e.url).antiPatterns.push({code:"duplicate-uncached",id:"duplicate-uncached",severity:"warning",message:`Loaded ${e.count} times, ${e.uncachedLoads} from the network: load it once or make it cacheable`});if(0===u.length)return{script:"Cache-Strategy-Analysis",status:"error",error:"No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again."};const H=u.flatMap(e=>e.antiPatterns.map(t=>({...t,resource:e.shortName,url:e.url}))),I={};for(const e of u){const t=e.cacheStrategy;I[t]||(I[t]=0),I[t]++}const M=u.filter(e=>["none","no-store","no-cache"].includes(e.cacheStrategy)),T=u.filter(e=>"short"===e.cacheStrategy),L=u.filter(e=>null!==e.headers).length,N=Math.round(L/u.length*100),A=u.filter(e=>e.isCorsRestricted).length,D=u.filter(e=>!(null!==e.headers||e.isCorsRestricted||e.url.startsWith(location.origin+"/")&&f.has(e.type)));let E=null;if(N<50){const e=new Map;for(const t of u.filter(e=>null===e.headers))e.set(t.host,(e.get(t.host)||0)+1);const t=[...e.entries()].sort((e,t)=>t[1]-e[1]).slice(0,3).map(([e])=>e).join(", ");E={severity:"info",code:"header-coverage",id:"header-coverage",message:`Cache headers were read for ${L} of ${u.length} resources (${N}%): 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 ${t}; check those hosts in the Network panel or with curl -I`}}if(D.length>0&&[...new Set(D.map(e=>e.type))].join(", "),H.length>0&&(H.filter(e=>"error"===e.severity),H.filter(e=>"warning"===e.severity),H.filter(e=>"info"===e.severity)),["immutable","long","medium","short","swr","must-revalidate","no-cache","no-store","expires-only","none","cached (inferred)","unknown (CORS)","unknown"].filter(e=>I[e]).map(e=>({Strategy:e,Count:I[e],"% of Total":`${Math.round(I[e]/u.length*100)}%`,Effective:y.has(e)?"✅":"❌"})),H.length>0)for(const e of["error","warning","info"])H.filter(t=>t.severity===e);const j=[];if(M.filter(e=>a(e.type)).length>0&&j.push("🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."),T.filter(e=>e.hasHash).length>0&&j.push("🟡 Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."),H.some(e=>"expires-without-cc"===e.id)&&j.push("🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."),null!==b&&b<50&&j.push(`🔴 Cache efficiency is low (${b}%, measured on the ${C.length} resources whose cache headers could be read). Review caching strategy for those static assets.`),null!==S&&S<70&&j.push(`🟡 CDN hit rate is ${S}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`),$["http/1.1"]>0&&j.push(`🟡 ${$["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`),z.length>0&&j.push(`🟡 ${z.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`),k.length>0?j.push(`🟡 ${k.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`):R.length>0&&j.push(`â„šī¸ ${R.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`),A>0&&0===L&&j.push("â„šī¸ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights."),j.length>0)for(const e of j);u.filter(e=>!e.firstParty);const U=null===b?null:b>=80?"good":b>=50?"needs-improvement":"poor",O=u.filter(e=>e.antiPatterns.length>0||["none","no-store","no-cache","short"].includes(e.cacheStrategy));return{script:"Cache-Strategy-Analysis",status:"ok",count:u.length,corsLimitedAnalysis:A>0,details:{totalResources:u.length,actionableCount:O.length,headersAnalyzed:L,corsRestricted:A,excludedFromHeaders:D.length,headerCoveragePercent:N,cacheEfficiencyPercent:b,cacheEfficiencyRating:U,cacheEfficiencyResources:C.length,bandwidthSavedBytes:P,strategyDistribution:I,cdnDetected:w.length>0,cdnHitRate:S,cdnHitRateRating:null===S?null:S>=70?"good":"poor",antiPatternCount:H.length,duplicatesCostlyCount:k.length,uncompressedCount:z.length,protocolDistribution:$},items:O.sort((e,t)=>t.antiPatterns.length-e.antiPatterns.length||t.decodedBodySize-e.decodedBodySize).slice(0,50).map(e=>({shortName:e.shortName,host:e.host,type:e.type,firstParty:e.firstParty,cacheStrategy:e.cacheStrategy,sizeBytes:e.decodedBodySize,isCached:e.isCached,antiPatterns:e.antiPatterns.map(e=>e.id)})),issues:[...H.map(e=>({code:e.code,severity:e.severity,id:e.id,resource:e.resource,message:e.message,target:e.url})),...E?[E]:[]],recommendations:j}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js b/.claude/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js index 7697bac1..88a0de59 100644 --- a/.claude/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js +++ b/.claude/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js @@ -1,2 +1,2 @@ -// snippets/Loading/Client-Side-Redirect-Detection.js | sha256:14b01c57daf55b1b | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Client-Side-Redirect-Detection.js -(async()=>{const e=performance.getEntriesByType("navigation");if(0===e.length)return{script:"Client-Side-Redirect-Detection",status:"unsupported",error:"Navigation Timing not available"};const t=e[0],r=new URL(window.location.href),i=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")).filter(e=>{if("navigation"!==e.initiatorType)return!1;try{return new URL(e.name).origin===r.origin}catch{return!1}}),n=t.redirectCount||0,a=t.redirectEnd-t.redirectStart,s=!!window.history?.state,o=window.history.length,c=document.referrer?new URL(document.referrer):null,d=c&&c.origin===r.origin,h=c?.pathname||"",g=r.pathname,m=r.searchParams.has("redirect")||r.searchParams.has("from")||r.searchParams.has("origin");let l=!1;const u=[];d&&h!==g&&""!==h&&(l=!0,u.push({type:"Same-origin navigation",from:h,to:g,severity:"warning"})),i.length>0&&(l=!0,i.forEach(e=>{u.push({type:"Document navigation",url:e.name,duration:e.duration.toFixed(1)+"ms",severity:"error"})})),m&&u.push({type:"Redirect parameter in URL",params:Array.from(r.searchParams.entries()).filter(([e])=>e.toLowerCase().includes("redirect")||e.toLowerCase().includes("from")||e.toLowerCase().includes("origin")).map(([e,t])=>`${e}=${t}`).join(", "),severity:"info"}),s&&o>1&&u.push({type:"SPA router detected",historyLength:o,state:JSON.stringify(window.history.state)?.slice(0,100)||"{}",severity:"info"});const p=t.domContentLoadedEventEnd-t.startTime,y=t.transferSize||t.encodedBodySize||0;d&&p<500&&y<1e4&&u.push({type:"Fast minimal-content navigation",duration:p.toFixed(1)+"ms",size:(y/1024).toFixed(1)+" KB",note:"Small page that loads quickly - possible redirect page",severity:"warning"}),0===u.length||u.forEach((e,t)=>{Object.entries(e).forEach(([e,t])=>{})}),l&&i.length>0&&i.reduce((e,t)=>e+t.duration,0);const f=i.length>0;return(f||n>0)&&f&&h&&g&&"/"===h&&g.match(/^\/[a-z]{2}\//),{script:"Client-Side-Redirect-Detection",status:"ok",details:{serverRedirects:n,redirectTimeMs:Math.round(a),clientRedirectIndicators:u.length,documentNavigations:i.length,hasSPARouter:s,historyLength:o,currentPath:g,referrerPath:h},issues:[...n>0?[{severity:"warning",message:`${n} server-side redirect(s) detected (${Math.round(a)}ms)`}]:[],...i.length>0?[{severity:"error",message:`${i.length} same-origin document navigation(s) detected, likely a client-side redirect`}]:[],...u.filter(e=>"warning"===e.severity).map(e=>({severity:"warning",message:e.type}))]}})(); \ No newline at end of file +// snippets/Loading/Client-Side-Redirect-Detection.js | sha256:fd9ba5519aed1976 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Client-Side-Redirect-Detection.js +(async()=>{const e=performance.getEntriesByType("navigation");if(0===e.length)return{script:"Client-Side-Redirect-Detection",status:"unsupported",error:"Navigation Timing not available"};const t=e[0],r=new URL(window.location.href),i=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")).filter(e=>{if("navigation"!==e.initiatorType)return!1;try{return new URL(e.name).origin===r.origin}catch{return!1}}),n=t.redirectCount||0,a=t.redirectEnd-t.redirectStart,o=!!window.history?.state,s=window.history.length,c=document.referrer?new URL(document.referrer):null,d=c&&c.origin===r.origin,g=c?.pathname||"",h=r.pathname,m=r.searchParams.has("redirect")||r.searchParams.has("from")||r.searchParams.has("origin");let l=!1;const u=[];d&&g!==h&&""!==g&&(l=!0,u.push({code:"same-origin-navigation",type:"Same-origin navigation",from:g,to:h,severity:"warning"})),i.length>0&&(l=!0,i.forEach(e=>{u.push({type:"Document navigation",url:e.name,duration:e.duration.toFixed(1)+"ms",severity:"error"})})),m&&u.push({type:"Redirect parameter in URL",params:Array.from(r.searchParams.entries()).filter(([e])=>e.toLowerCase().includes("redirect")||e.toLowerCase().includes("from")||e.toLowerCase().includes("origin")).map(([e,t])=>`${e}=${t}`).join(", "),severity:"info"}),o&&s>1&&u.push({type:"SPA router detected",historyLength:s,state:JSON.stringify(window.history.state)?.slice(0,100)||"{}",severity:"info"});const p=t.domContentLoadedEventEnd-t.startTime,y=t.transferSize||t.encodedBodySize||0;d&&p<500&&y<1e4&&u.push({code:"fast-minimal-navigation",type:"Fast minimal-content navigation",duration:p.toFixed(1)+"ms",size:(y/1024).toFixed(1)+" KB",note:"Small page that loads quickly - possible redirect page",severity:"warning"}),0===u.length||u.forEach((e,t)=>{Object.entries(e).forEach(([e,t])=>{})}),l&&i.length>0&&i.reduce((e,t)=>e+t.duration,0);const f=i.length>0;return(f||n>0)&&f&&g&&h&&"/"===g&&h.match(/^\/[a-z]{2}\//),{script:"Client-Side-Redirect-Detection",status:"ok",details:{serverRedirects:n,redirectTimeMs:Math.round(a),clientRedirectIndicators:u.length,documentNavigations:i.length,hasSPARouter:o,historyLength:s,currentPath:h,referrerPath:g},issues:[...n>0?[{code:"server-redirects",severity:"warning",message:`${n} server-side redirect(s) detected (${Math.round(a)}ms)`}]:[],...i.length>0?[{code:"document-navigation",severity:"error",message:`${i.length} same-origin document navigation(s) detected, likely a client-side redirect`}]:[],...u.filter(e=>"warning"===e.severity).map(e=>({code:e.code,severity:"warning",message:e.type}))]}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Compression-Audit.js b/.claude/skills/webperf-loading/scripts/Compression-Audit.js index 98df3e20..51bfde49 100644 --- a/.claude/skills/webperf-loading/scripts/Compression-Audit.js +++ b/.claude/skills/webperf-loading/scripts/Compression-Audit.js @@ -1,2 +1,2 @@ -// snippets/Loading/Compression-Audit.js | sha256:4e6fbba6a011bbb2 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Compression-Audit.js -(()=>{const e={js:.7,css:.7,html:.7,json:.7,svg:.7,xml:.7,font:.5},t={js:"js",mjs:"js",cjs:"js",css:"css",html:"html",htm:"html",json:"json",webmanifest:"json",svg:"svg",xml:"xml",rss:"xml",atom:"xml",ttf:"font",otf:"font",eot:"font"},n=[...performance.getEntriesByType("navigation"),...performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))],s=n.some(e=>"contentEncoding"in e),o=[];n.forEach(e=>{const n=(e=>{try{return new URL(e)}catch{return null}})(e.name);if(!n||!/^https?:$/.test(n.protocol))return;const s=((e,n)=>{if("navigation"===e.entryType)return"html";const s=n.pathname.split(".").pop().toLowerCase();return t[s]?t[s]:"script"===e.initiatorType?"js":null})(e,n);s&&o.push({entry:e,url:n,type:s})});const i=[],r=[];let c=0,a=0;const d={};o.forEach(({entry:t,url:n,type:s})=>{const o=t.decodedBodySize||0,l=t.encodedBodySize||0;if(0===o&&n.origin!==location.origin)return void c++;if(o<1024)return void a++;const u=(t.contentEncoding||"").toLowerCase();if(u?"identity"!==u:lt.estimatedSavingsBytes-e.estimatedSavingsBytes);const l=r.reduce((e,t)=>e+t.decodedBytes,0),u=r.reduce((e,t)=>e+t.estimatedSavingsBytes,0),m=c>0,p=[];return r.length>0&&p.push({severity:"warning",message:`${r.length} text resource(s) served without compression; enabling gzip or brotli could save about ${function(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}(u)}`}),m&&p.push({severity:"info",message:`${c} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`}),!s&&o.length>0&&p.push({severity:"info",message:"PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize"}),{script:"Compression-Audit",status:"ok",count:r.length,corsLimitedAnalysis:m,details:{totalTextResources:o.length,compressedCount:i.length,uncompressedCount:r.length,sizeUnknownCount:c,skippedSmallCount:a,totalUncompressedBytes:l,estimatedSavingsBytes:u,byEncoding:d,contentEncodingSupported:s},items:r.slice(0,50),issues:p}})(); \ No newline at end of file +// snippets/Loading/Compression-Audit.js | sha256:3ff52ec890aa8366 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Compression-Audit.js +(()=>{const e={js:.7,css:.7,html:.7,json:.7,svg:.7,xml:.7,font:.5},t={js:"js",mjs:"js",cjs:"js",css:"css",html:"html",htm:"html",json:"json",webmanifest:"json",svg:"svg",xml:"xml",rss:"xml",atom:"xml",ttf:"font",otf:"font",eot:"font"},n=[...performance.getEntriesByType("navigation"),...performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))],s=n.some(e=>"contentEncoding"in e),o=[];n.forEach(e=>{const n=(e=>{try{return new URL(e)}catch{return null}})(e.name);if(!n||!/^https?:$/.test(n.protocol))return;const s=((e,n)=>{if("navigation"===e.entryType)return"html";const s=n.pathname.split(".").pop().toLowerCase();return t[s]?t[s]:"script"===e.initiatorType?"js":null})(e,n);s&&o.push({entry:e,url:n,type:s})});const i=[],r=[];let c=0,a=0;const d={};o.forEach(({entry:t,url:n,type:s})=>{const o=t.decodedBodySize||0,l=t.encodedBodySize||0;if(0===o&&n.origin!==location.origin)return void c++;if(o<1024)return void a++;const u=(t.contentEncoding||"").toLowerCase();if(u?"identity"!==u:lt.estimatedSavingsBytes-e.estimatedSavingsBytes);const l=r.reduce((e,t)=>e+t.decodedBytes,0),u=r.reduce((e,t)=>e+t.estimatedSavingsBytes,0),m=c>0,p=[];return r.length>0&&p.push({code:"uncompressed",severity:"warning",message:`${r.length} text resource(s) served without compression; enabling gzip or brotli could save about ${function(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}(u)}`}),m&&p.push({code:"unknown-size",severity:"info",message:`${c} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`}),!s&&o.length>0&&p.push({code:"content-encoding-unavailable",severity:"info",message:"PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize"}),{script:"Compression-Audit",status:"ok",count:r.length,corsLimitedAnalysis:m,details:{totalTextResources:o.length,compressedCount:i.length,uncompressedCount:r.length,sizeUnknownCount:c,skippedSmallCount:a,totalUncompressedBytes:l,estimatedSavingsBytes:u,byEncoding:d,contentEncodingSupported:s},items:r.slice(0,50),issues:p}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js b/.claude/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js index 67c89709..e5aff89a 100644 --- a/.claude/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js +++ b/.claude/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js @@ -1,2 +1,2 @@ -// snippets/Loading/Prefetch-Resource-Validation.js | sha256:a6e3aeeecf4f26ce | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Prefetch-Resource-Validation.js -(()=>{const e=Array.from(document.querySelectorAll('link[rel="prefetch"]'));if(0===e.length)return{script:"Prefetch-Resource-Validation",status:"ok",count:0,items:[],issues:[{severity:"info",message:'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.'}]};const s=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),t=new Map;s.forEach(e=>{try{const s=new URL(e.name,location.origin).href;t.has(s)||t.set(s,[]),t.get(s).push(e)}catch{}});const i=new Set(["script","style","font","image","video","audio","document","fetch","track","worker"]),r=[],n=[],a=new Set;let o=0,l=0;e.forEach(e=>{const s=e.href,d=e.getAttribute("as")||"unknown",h=s.split("/").pop()?.split("?")[0]?.split("#")[0]||s;let p;try{p=new URL(s,location.origin).href}catch{p=s}const c=t.get(p)||[],g=c[0],u={link:e,href:s,shortUrl:h,as:d,size:0,transferSize:0,duration:0,loaded:!1,isCurrentPage:!1,warnings:[]};if("unknown"===d?u.warnings.push({type:"missing-as",severity:"warning",message:"Missing 'as' attribute - browser cannot apply correct MIME type matching"}):i.has(d)||u.warnings.push({type:"invalid-as",severity:"warning",message:`Invalid 'as' value: "${d}" - should be one of: ${Array.from(i).join(", ")}`}),a.has(p)&&u.warnings.push({type:"duplicate-prefetch",severity:"warning",message:"Duplicate prefetch - this URL is prefetched multiple times"}),a.add(p),g){if(u.loaded=!0,u.size=g.decodedBodySize||0,u.transferSize=g.transferSize||0,u.duration=g.duration||0,u.cacheStatus=g.encodedBodySize>0&&0===g.transferSize?"cached":0===g.encodedBodySize&&0===g.transferSize?"unknown (CORS)":"network",o+=u.size,l+=u.transferSize,c.some(e=>"link"!==e.initiatorType&&e.startTime<5e3)&&(u.isCurrentPage=!0,u.warnings.push({type:"wrong-hint",severity:"error",message:"Resource used on current page - should use preload instead"})),u.size>512e3){const e=(u.size/1048576).toFixed(2);u.warnings.push({type:"large-file",severity:"warning",message:`Large file (${e}MB) - consider if prefetch is appropriate`})}let e=!1,s="";"video"===d||"audio"===d?(e=!0,s="Video/audio files are typically too large for prefetch"):"image"===d&&u.size>204800?(e=!0,s=`Image is ${(u.size/1024).toFixed(0)}KB (>200KB threshold)`):"script"===d&&u.size>1048576&&(e=!0,s=`Script is ${(u.size/1024).toFixed(0)}KB (>1MB threshold)`),e&&u.warnings.push({type:"inappropriate-type",severity:"warning",message:`Type "${d}" may not be suitable for prefetch: ${s}`})}else u.warnings.push({type:"not-loaded",severity:"info",message:"Not loaded yet or failed to load"});u.warnings.length>0?r.push(u):n.push(u)});const d=[];e.length>10&&d.push({type:"excessive-count",severity:"warning",message:`${e.length} prefetch hints found (recommended: <10)`,explanation:"Excessive prefetch can waste bandwidth, especially on mobile networks"});const h=(o/1048576).toFixed(2);(l/1048576).toFixed(2),o>5242880?d.push({type:"excessive-size",severity:"error",message:`Total prefetch size: ${h}MB (critical threshold exceeded)`,explanation:"This is very high and will significantly impact mobile users"}):o>2097152&&d.push({type:"high-size",severity:"warning",message:`Total prefetch size: ${h}MB (warning threshold exceeded)`,explanation:"Consider reducing prefetch to improve mobile experience"});const p=[...r,...n].filter(e=>e.loaded).length,c=r.filter(e=>e.warnings.some(e=>"error"===e.severity||"warning"===e.severity)).length+d.length;d.length>0&&d.forEach(e=>{});const g=r.filter(e=>e.warnings.some(e=>"error"===e.severity)),u=r.filter(e=>!e.warnings.some(e=>"error"===e.severity)&&e.warnings.some(e=>"warning"===e.severity)),f=r.filter(e=>e.warnings.every(e=>"info"===e.severity));return g.length>0&&(g.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),g.forEach((e,s)=>{e.warnings.forEach(e=>{})})),u.length>0&&u.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),f.length>0&&f.map(e=>({Resource:e.shortUrl,Type:e.as,Status:e.warnings.map(e=>e.message).join("; ")})),n.length>0&&n.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Status:e.loaded?"✅ Loaded":"Pending",Cache:e.cacheStatus||"N/A"})),{script:"Prefetch-Resource-Validation",status:"ok",count:e.length,details:{loadedCount:p,validCount:n.length,issueCount:c,totalSizeBytes:o,totalTransferBytes:l},items:[...n,...r].map(e=>({href:e.shortUrl,as:e.as,sizeBytes:e.size,loaded:e.loaded,isCurrentPage:e.isCurrentPage||!1})),issues:[...d.map(e=>({severity:"error"===e.severity?"error":"warning",message:e.message})),...r.flatMap(e=>e.warnings.filter(e=>"info"!==e.severity).map(s=>({severity:s.severity,message:`${e.shortUrl}: ${s.message}`})))]}})(); \ No newline at end of file +// snippets/Loading/Prefetch-Resource-Validation.js | sha256:f1c557ac1ad1c6d5 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Prefetch-Resource-Validation.js +(()=>{const e=Array.from(document.querySelectorAll('link[rel="prefetch"]'));if(0===e.length)return{script:"Prefetch-Resource-Validation",status:"ok",count:0,items:[],issues:[{code:"no-prefetch",severity:"info",message:'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.'}]};const s=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),t=new Map;s.forEach(e=>{try{const s=new URL(e.name,location.origin).href;t.has(s)||t.set(s,[]),t.get(s).push(e)}catch{}});const i=new Set(["script","style","font","image","video","audio","document","fetch","track","worker"]),r=[],n=[],a=new Set;let o=0,d=0;e.forEach(e=>{const s=e.href,c=e.getAttribute("as")||"unknown",l=s.split("/").pop()?.split("?")[0]?.split("#")[0]||s;let p;try{p=new URL(s,location.origin).href}catch{p=s}const h=t.get(p)||[],g=h[0],u={link:e,href:s,shortUrl:l,as:c,size:0,transferSize:0,duration:0,loaded:!1,isCurrentPage:!1,warnings:[]};if("unknown"===c?u.warnings.push({code:"missing-as",type:"missing-as",severity:"warning",message:"Missing 'as' attribute - browser cannot apply correct MIME type matching"}):i.has(c)||u.warnings.push({code:"invalid-as",type:"invalid-as",severity:"warning",message:`Invalid 'as' value: "${c}" - should be one of: ${Array.from(i).join(", ")}`}),a.has(p)&&u.warnings.push({code:"duplicate-prefetch",type:"duplicate-prefetch",severity:"warning",message:"Duplicate prefetch - this URL is prefetched multiple times"}),a.add(p),g){if(u.loaded=!0,u.size=g.decodedBodySize||0,u.transferSize=g.transferSize||0,u.duration=g.duration||0,u.cacheStatus=g.encodedBodySize>0&&0===g.transferSize?"cached":0===g.encodedBodySize&&0===g.transferSize?"unknown (CORS)":"network",o+=u.size,d+=u.transferSize,h.some(e=>"link"!==e.initiatorType&&e.startTime<5e3)&&(u.isCurrentPage=!0,u.warnings.push({code:"used-on-current-page",type:"wrong-hint",severity:"error",message:"Resource used on current page - should use preload instead"})),u.size>512e3){const e=(u.size/1048576).toFixed(2);u.warnings.push({code:"large-file",type:"large-file",severity:"warning",message:`Large file (${e}MB) - consider if prefetch is appropriate`})}let e=!1,s="";"video"===c||"audio"===c?(e=!0,s="Video/audio files are typically too large for prefetch"):"image"===c&&u.size>204800?(e=!0,s=`Image is ${(u.size/1024).toFixed(0)}KB (>200KB threshold)`):"script"===c&&u.size>1048576&&(e=!0,s=`Script is ${(u.size/1024).toFixed(0)}KB (>1MB threshold)`),e&&u.warnings.push({code:"inappropriate-type",type:"inappropriate-type",severity:"warning",message:`Type "${c}" may not be suitable for prefetch: ${s}`})}else u.warnings.push({type:"not-loaded",severity:"info",message:"Not loaded yet or failed to load"});u.warnings.length>0?r.push(u):n.push(u)});const c=[];e.length>10&&c.push({code:"too-many-prefetches",type:"excessive-count",severity:"warning",message:`${e.length} prefetch hints found (recommended: <10)`,explanation:"Excessive prefetch can waste bandwidth, especially on mobile networks"});const l=(o/1048576).toFixed(2);(d/1048576).toFixed(2),o>5242880?c.push({code:"large-total-size",type:"excessive-size",severity:"error",message:`Total prefetch size: ${l}MB (critical threshold exceeded)`,explanation:"This is very high and will significantly impact mobile users"}):o>2097152&&c.push({code:"large-total-size",type:"high-size",severity:"warning",message:`Total prefetch size: ${l}MB (warning threshold exceeded)`,explanation:"Consider reducing prefetch to improve mobile experience"});const p=[...r,...n].filter(e=>e.loaded).length,h=r.filter(e=>e.warnings.some(e=>"error"===e.severity||"warning"===e.severity)).length+c.length;c.length>0&&c.forEach(e=>{});const g=r.filter(e=>e.warnings.some(e=>"error"===e.severity)),u=r.filter(e=>!e.warnings.some(e=>"error"===e.severity)&&e.warnings.some(e=>"warning"===e.severity)),f=r.filter(e=>e.warnings.every(e=>"info"===e.severity));return g.length>0&&(g.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),g.forEach((e,s)=>{e.warnings.forEach(e=>{})})),u.length>0&&u.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),f.length>0&&f.map(e=>({Resource:e.shortUrl,Type:e.as,Status:e.warnings.map(e=>e.message).join("; ")})),n.length>0&&n.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Status:e.loaded?"✅ Loaded":"Pending",Cache:e.cacheStatus||"N/A"})),{script:"Prefetch-Resource-Validation",status:"ok",count:e.length,details:{loadedCount:p,validCount:n.length,issueCount:h,totalSizeBytes:o,totalTransferBytes:d},items:[...n,...r].map(e=>({href:e.shortUrl,as:e.as,sizeBytes:e.size,loaded:e.loaded,isCurrentPage:e.isCurrentPage||!1})),issues:[...c.map(e=>({code:e.code,severity:"error"===e.severity?"error":"warning",message:e.message})),...r.flatMap(e=>e.warnings.filter(e=>"info"!==e.severity).map(s=>({code:s.code,severity:s.severity,message:`${e.shortUrl}: ${s.message}`,target:e.href})))]}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Priority-Hints-Audit.js b/.claude/skills/webperf-loading/scripts/Priority-Hints-Audit.js index 55aa6b47..771d1743 100644 --- a/.claude/skills/webperf-loading/scripts/Priority-Hints-Audit.js +++ b/.claude/skills/webperf-loading/scripts/Priority-Hints-Audit.js @@ -1,2 +1,2 @@ -// snippets/Loading/Priority-Hints-Audit.js | sha256:787ce6a286010f32 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Priority-Hints-Audit.js -(()=>{function t(t){const e=t.getBoundingClientRect();return e.top0&&e.left0&&e.width>0&&e.height>0}function e(t){const e=t.src||t.href||t.getAttribute("href")||"";return e.split("/").pop()?.split("?")[0]||e.slice(-50)||"(unknown)"}const r=new Map;function i(t){const e=t.src||t.href||t.getAttribute("href")||"";if(!e)return null;try{return r.get(new URL(e,location.origin).href)||null}catch{return null}}performance.getEntriesByType("resource").filter(t=>!t.name.endsWith("#webperf-snippets")).forEach(t=>{try{r.set(new URL(t.name,location.origin).href,t)}catch{}});const o=Array.from(document.querySelectorAll("[fetchpriority]")),n=o.filter(t=>"IMG"!==t.tagName),s=(o.filter(t=>"IMG"===t.tagName),Array.from(document.querySelectorAll('link[rel="preload"][fetchpriority]'))),c=function(){const e=Array.from(document.querySelectorAll("img")).filter(t);let r=null,i=0;return e.forEach(t=>{const{width:e,height:o}=t.getBoundingClientRect(),n=e*o;n>i&&(i=n,r=t)}),r}(),l=[];if(s.filter(t=>"low"===t.getAttribute("fetchpriority")).forEach(t=>{l.push({severity:"error",element:t,resource:e(t),message:'preload + fetchpriority="low" is contradictory',detail:'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.',fix:'Remove fetchpriority="low", or use fetchpriority="high" if the resource is truly critical'})}),o.filter(t=>"low"===t.getAttribute("fetchpriority")).forEach(t=>{const r=i(t);r&&r.startTime<500&&l.push({severity:"warning",element:t,resource:e(t),message:`fetchpriority="low" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.",fix:'Verify the resource is non-critical, or remove fetchpriority="low"'})}),o.filter(t=>"high"===t.getAttribute("fetchpriority")).forEach(t=>{const r=i(t);r&&r.startTime>3e3&&l.push({severity:"warning",element:t,resource:e(t),message:`fetchpriority="high" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.",fix:"Move the element earlier in the HTML or investigate render-blocking dependencies"})}),c&&"high"!==c.getAttribute("fetchpriority")){const t=c.getAttribute("fetchpriority");l.push({severity:"warning",element:c,resource:(c.currentSrc||c.src).split("/").pop()?.split("?")[0]||"LCP image",message:"LCP candidate image "+(t?`has fetchpriority="${t}"`:"has no fetchpriority set"),detail:'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".',fix:'Add fetchpriority="high" to the LCP image to improve Largest Contentful Paint'})}const h=o.filter(t=>"high"===t.getAttribute("fetchpriority")).length,a=o.filter(t=>"low"===t.getAttribute("fetchpriority")).length,u=o.filter(t=>"auto"===t.getAttribute("fetchpriority")).length;if(0===o.length&&!c)return{script:"Priority-Hints-Audit",status:"ok",count:0,items:[],issues:[]};c&&c.getAttribute("fetchpriority"),n.length>0&&n.forEach((t,e)=>{});const f=l.filter(t=>"error"===t.severity),g=l.filter(t=>"warning"===t.severity);return l.length>0&&[...f,...g].forEach(t=>{}),{script:"Priority-Hints-Audit",status:"ok",count:o.length,details:{highCount:h,lowCount:a,autoCount:u,preloadsWithPriorityCount:s.length,lcpCandidateHasFetchpriority:c?"high"===c.getAttribute("fetchpriority"):null},items:o.map(t=>({tag:t.tagName.toLowerCase(),fetchpriority:t.getAttribute("fetchpriority"),resource:(t.src||t.href||t.getAttribute("href")||"").split("/").pop()?.split("?")[0]||""})),issues:l.map(t=>({severity:t.severity,message:t.fix?`${t.message.replace(/\.$/,"")}. Fix: ${t.fix}`:t.message}))}})(); \ No newline at end of file +// snippets/Loading/Priority-Hints-Audit.js | sha256:e88ca8e26ddc425e | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Priority-Hints-Audit.js +(()=>{function e(e){const t=e.getBoundingClientRect();return t.top0&&t.left0&&t.width>0&&t.height>0}function t(e){const t=e.src||e.href||e.getAttribute("href")||"";return t.split("/").pop()?.split("?")[0]||t.slice(-50)||"(unknown)"}const r=new Map;function i(e){const t=e.src||e.href||e.getAttribute("href")||"";if(!t)return null;try{return r.get(new URL(t,location.origin).href)||null}catch{return null}}performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")).forEach(e=>{try{r.set(new URL(e.name,location.origin).href,e)}catch{}});const o=Array.from(document.querySelectorAll("[fetchpriority]")),n=o.filter(e=>"IMG"!==e.tagName),c=(o.filter(e=>"IMG"===e.tagName),Array.from(document.querySelectorAll('link[rel="preload"][fetchpriority]'))),s=function(){const t=Array.from(document.querySelectorAll("img")).filter(e);let r=null,i=0;return t.forEach(e=>{const{width:t,height:o}=e.getBoundingClientRect(),n=t*o;n>i&&(i=n,r=e)}),r}(),l=[];if(c.filter(e=>"low"===e.getAttribute("fetchpriority")).forEach(e=>{l.push({code:"preload-low-priority",severity:"error",element:e,url:e.href,resource:t(e),message:'preload + fetchpriority="low" is contradictory',detail:'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.',fix:'Remove fetchpriority="low", or use fetchpriority="high" if the resource is truly critical'})}),o.filter(e=>"low"===e.getAttribute("fetchpriority")).forEach(e=>{const r=i(e);r&&r.startTime<500&&l.push({code:"low-priority-loaded-early",severity:"warning",element:e,url:e.src||e.href,resource:t(e),message:`fetchpriority="low" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.",fix:'Verify the resource is non-critical, or remove fetchpriority="low"'})}),o.filter(e=>"high"===e.getAttribute("fetchpriority")).forEach(e=>{const r=i(e);r&&r.startTime>3e3&&l.push({code:"high-priority-loaded-late",severity:"warning",element:e,url:e.src||e.href,resource:t(e),message:`fetchpriority="high" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.",fix:"Move the element earlier in the HTML or investigate render-blocking dependencies"})}),s&&"high"!==s.getAttribute("fetchpriority")){const e=s.getAttribute("fetchpriority");l.push({code:"lcp-no-fetchpriority",severity:"warning",element:s,url:s.currentSrc||s.src,resource:(s.currentSrc||s.src).split("/").pop()?.split("?")[0]||"LCP image",message:"LCP candidate image "+(e?`has fetchpriority="${e}"`:"has no fetchpriority set"),detail:'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".',fix:'Add fetchpriority="high" to the LCP image to improve Largest Contentful Paint'})}const h=o.filter(e=>"high"===e.getAttribute("fetchpriority")).length,a=o.filter(e=>"low"===e.getAttribute("fetchpriority")).length,u=o.filter(e=>"auto"===e.getAttribute("fetchpriority")).length;if(0===o.length&&!s)return{script:"Priority-Hints-Audit",status:"ok",count:0,items:[],issues:[]};s&&s.getAttribute("fetchpriority"),n.length>0&&n.forEach((e,t)=>{});const f=l.filter(e=>"error"===e.severity),g=l.filter(e=>"warning"===e.severity);return l.length>0&&[...f,...g].forEach(e=>{}),{script:"Priority-Hints-Audit",status:"ok",count:o.length,details:{highCount:h,lowCount:a,autoCount:u,preloadsWithPriorityCount:c.length,lcpCandidateHasFetchpriority:s?"high"===s.getAttribute("fetchpriority"):null},items:o.map(e=>({tag:e.tagName.toLowerCase(),fetchpriority:e.getAttribute("fetchpriority"),resource:(e.src||e.href||e.getAttribute("href")||"").split("/").pop()?.split("?")[0]||""})),issues:l.map(e=>({code:e.code,severity:e.severity,message:e.fix?`${e.message.replace(/\.$/,"")}. Fix: ${e.fix}`:e.message,...e.url?{target:e.url}:{}}))}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Resource-Hints-Validation.js b/.claude/skills/webperf-loading/scripts/Resource-Hints-Validation.js index 4af2db6e..f33aa62e 100644 --- a/.claude/skills/webperf-loading/scripts/Resource-Hints-Validation.js +++ b/.claude/skills/webperf-loading/scripts/Resource-Hints-Validation.js @@ -1,2 +1,2 @@ -// snippets/Loading/Resource-Hints-Validation.js | sha256:3153640241cc575d | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints-Validation.js -(()=>{document;const e=Array.from(document.querySelectorAll('link[rel="preload"]')),t=Array.from(document.querySelectorAll('link[rel="preconnect"]')),n=Array.from(document.querySelectorAll('link[rel="dns-prefetch"]')),r=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),a=e=>{try{const t=e.startsWith("http")?e:new URL(e,location.href).href;return new URL(t).origin}catch{return null}},o=e=>{try{const t=new URL(e,location.origin);return t.origin+t.pathname+t.search}catch{return e}},s=new Set(r.map(e=>o(e.name))),i=new Set(r.map(e=>a(e.name)).filter(Boolean));i.add(location.origin);const c=document.querySelectorAll("img[src]");if(0===e.length);else{const t=e.map(e=>{const t=e.href,n=e.getAttribute("as")||"unknown",a=e.getAttribute("media"),i=o(t),c=t.split("/").pop()?.split("?").shift()||t,l=s.has(i),h=r.find(e=>o(e.name)===i);let d=!0;if(a)try{d=window.matchMedia(a).matches}catch{d=!1}return{shortUrl:c,href:t,as:n,media:a,mediaMatches:d,wasUsed:l,size:h?(f=h.decodedBodySize||h.transferSize,f?(f/1024).toFixed(1)+" KB":"N/A"):"N/A",link:e};var f}),n=t.filter(e=>e.wasUsed),a=(t.filter(e=>!e.wasUsed&&(!e.media||e.mediaMatches)),t.filter(e=>!e.wasUsed&&e.media&&!e.mediaMatches)),i=e.length-a.length;i>0&&Math.round(n.length/i*100)}const l=new Set;if(c.forEach(e=>{try{const t=a(e.src);t&&t!==location.origin&&!i.has(t)&&l.add(t)}catch{}}),0===t.length);else{const e=t.map(e=>{const t=e.href,n=a(t),o=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>a(e.name)===n).length;return{domain:n,href:t,wasUsed:o,isLazyLoaded:s,requestCount:c,link:e}}),n=e.filter(e=>e.wasUsed),o=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((n.length+o.length)/t.length*100)}if(0===n.length);else{const e=n.map(e=>{const t=e.href,n=a(t),o=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>a(e.name)===n).length;return{domain:n,href:t,wasUsed:o,isLazyLoaded:s,requestCount:c,link:e}}),t=e.filter(e=>e.wasUsed),o=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((t.length+o.length)/n.length*100)}const h=new Set(t.map(e=>a(e.href)).filter(Boolean)),d=new Set(n.map(e=>a(e.href)).filter(Boolean)),f=Array.from(h).filter(e=>d.has(e));f.length>0&&f.forEach(e=>{});const m=new Set;c.forEach(e=>{try{const t=a(e.src);t&&t!==location.origin&&m.add(t)}catch{}});const u=Array.from(m).filter(e=>!i.has(e));u.length>0&&u.map(e=>{const t=Array.from(c).filter(t=>{try{return a(t.src)===e}catch{return!1}}).length;return{domain:e,imgCount:t}}).filter(e=>e.domain&&"null"!==e.domain).sort((e,t)=>t.imgCount-e.imgCount).forEach(e=>{});const g=new Set([...t.map(e=>a(e.href)),...n.map(e=>a(e.href))]),p={};r.forEach(e=>{const t=a(e.name);t&&t!==location.origin&&(p[t]=(p[t]||0)+1)});const w=Object.entries(p).filter(([e,t])=>t>=2&&!g.has(e)).sort((e,t)=>t[1]-e[1]),y=[...w.filter(([,e])=>e>=5).slice(0,3),...w.filter(([,e])=>e>=2&&e<5).slice(0,5)];w.length>0&&y.forEach(([e,t])=>{});const L=new Set;c.forEach(e=>{try{const t=a(e.src);t&&t!==location.origin&&!i.has(t)&&L.add(t)}catch{}});const U=e=>{const t=a(e.href);return t&&!i.has(t)&&!L.has(t)},A=[...e.filter(e=>!s.has(o(e.href))&&!(e=>{const t=e.getAttribute("media");if(!t)return!1;try{return!window.matchMedia(t).matches}catch{return!0}})(e)).map(e=>({domain:a(e.href),href:e.href,requestCount:0,action:"remove-unused-preload",recommendedHint:null})),...t.filter(U).map(e=>({domain:a(e.href),href:e.href,requestCount:0,action:"remove-unused-preconnect",recommendedHint:null})),...n.filter(U).map(e=>({domain:a(e.href),href:e.href,requestCount:0,action:"remove-unused-dns-prefetch",recommendedHint:null}))],S=A.length;return{script:"Resource-Hints-Validation",status:"ok",count:e.length+t.length+n.length,details:{preloadCount:e.length,preconnectCount:t.length,dnsPrefetchCount:n.length,totalResourcesLoaded:r.length,unusedHints:S,missingPreconnects:w.length,redundantHints:f.length},items:[...A,...w.map(([e,t])=>({domain:e,href:null,requestCount:t,action:t>=5?"add-preconnect":"add-dns-prefetch",recommendedHint:t>=5?"preconnect":"dns-prefetch"})),...f.map(e=>({domain:e,href:null,requestCount:p[e]||0,action:"remove-dns-prefetch",recommendedHint:"preconnect"}))].slice(0,50),issues:[...S>0?[{severity:"warning",message:`${S} unused hint(s) waste bandwidth`}]:[],...w.length>0?[{severity:"info",message:`${w.length} domain(s) could benefit from connection hints`}]:[],...f.length>0?[{severity:"warning",message:`${f.length} domain(s) have both preconnect and dns-prefetch (redundant)`}]:[]]}})(); \ No newline at end of file +// snippets/Loading/Resource-Hints-Validation.js | sha256:dbffcd164a73b907 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints-Validation.js +(()=>{document;const e=Array.from(document.querySelectorAll('link[rel="preload"]')),t=Array.from(document.querySelectorAll('link[rel="preconnect"]')),n=Array.from(document.querySelectorAll('link[rel="dns-prefetch"]')),r=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),o=e=>{try{const t=e.startsWith("http")?e:new URL(e,location.href).href;return new URL(t).origin}catch{return null}},a=e=>{try{const t=new URL(e,location.origin);return t.origin+t.pathname+t.search}catch{return e}},s=new Set(r.map(e=>a(e.name))),i=new Set(r.map(e=>o(e.name)).filter(Boolean));i.add(location.origin);const c=document.querySelectorAll("img[src]");if(0===e.length);else{const t=e.map(e=>{const t=e.href,n=e.getAttribute("as")||"unknown",o=e.getAttribute("media"),i=a(t),c=t.split("/").pop()?.split("?").shift()||t,l=s.has(i),d=r.find(e=>a(e.name)===i);let h=!0;if(o)try{h=window.matchMedia(o).matches}catch{h=!1}return{shortUrl:c,href:t,as:n,media:o,mediaMatches:h,wasUsed:l,size:d?(m=d.decodedBodySize||d.transferSize,m?(m/1024).toFixed(1)+" KB":"N/A"):"N/A",link:e};var m}),n=t.filter(e=>e.wasUsed),o=(t.filter(e=>!e.wasUsed&&(!e.media||e.mediaMatches)),t.filter(e=>!e.wasUsed&&e.media&&!e.mediaMatches)),i=e.length-o.length;i>0&&Math.round(n.length/i*100)}const l=new Set;if(c.forEach(e=>{try{const t=o(e.src);t&&t!==location.origin&&!i.has(t)&&l.add(t)}catch{}}),0===t.length);else{const e=t.map(e=>{const t=e.href,n=o(t),a=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>o(e.name)===n).length;return{domain:n,href:t,wasUsed:a,isLazyLoaded:s,requestCount:c,link:e}}),n=e.filter(e=>e.wasUsed),a=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((n.length+a.length)/t.length*100)}if(0===n.length);else{const e=n.map(e=>{const t=e.href,n=o(t),a=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>o(e.name)===n).length;return{domain:n,href:t,wasUsed:a,isLazyLoaded:s,requestCount:c,link:e}}),t=e.filter(e=>e.wasUsed),a=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((t.length+a.length)/n.length*100)}const d=new Set(t.map(e=>o(e.href)).filter(Boolean)),h=new Set(n.map(e=>o(e.href)).filter(Boolean)),m=Array.from(d).filter(e=>h.has(e));m.length>0&&m.forEach(e=>{});const f=new Set;c.forEach(e=>{try{const t=o(e.src);t&&t!==location.origin&&f.add(t)}catch{}});const u=Array.from(f).filter(e=>!i.has(e));u.length>0&&u.map(e=>{const t=Array.from(c).filter(t=>{try{return o(t.src)===e}catch{return!1}}).length;return{domain:e,imgCount:t}}).filter(e=>e.domain&&"null"!==e.domain).sort((e,t)=>t.imgCount-e.imgCount).forEach(e=>{});const g=new Set([...t.map(e=>o(e.href)),...n.map(e=>o(e.href))]),p={};r.forEach(e=>{const t=o(e.name);t&&t!==location.origin&&(p[t]=(p[t]||0)+1)});const w=Object.entries(p).filter(([e,t])=>t>=2&&!g.has(e)).sort((e,t)=>t[1]-e[1]),y=[...w.filter(([,e])=>e>=5).slice(0,3),...w.filter(([,e])=>e>=2&&e<5).slice(0,5)];w.length>0&&y.forEach(([e,t])=>{});const L=new Set;c.forEach(e=>{try{const t=o(e.src);t&&t!==location.origin&&!i.has(t)&&L.add(t)}catch{}});const U=e=>{const t=o(e.href);return t&&!i.has(t)&&!L.has(t)},A=[...e.filter(e=>!s.has(a(e.href))&&!(e=>{const t=e.getAttribute("media");if(!t)return!1;try{return!window.matchMedia(t).matches}catch{return!0}})(e)).map(e=>({domain:o(e.href),href:e.href,requestCount:0,action:"remove-unused-preload",recommendedHint:null})),...t.filter(U).map(e=>({domain:o(e.href),href:e.href,requestCount:0,action:"remove-unused-preconnect",recommendedHint:null})),...n.filter(U).map(e=>({domain:o(e.href),href:e.href,requestCount:0,action:"remove-unused-dns-prefetch",recommendedHint:null}))],S=A.length;return{script:"Resource-Hints-Validation",status:"ok",count:e.length+t.length+n.length,details:{preloadCount:e.length,preconnectCount:t.length,dnsPrefetchCount:n.length,totalResourcesLoaded:r.length,unusedHints:S,missingPreconnects:w.length,redundantHints:m.length},items:[...A,...w.map(([e,t])=>({domain:e,href:null,requestCount:t,action:t>=5?"add-preconnect":"add-dns-prefetch",recommendedHint:t>=5?"preconnect":"dns-prefetch"})),...m.map(e=>({domain:e,href:null,requestCount:p[e]||0,action:"remove-dns-prefetch",recommendedHint:"preconnect"}))].slice(0,50),issues:[...S>0?[{code:"unused-hints",severity:"warning",message:`${S} unused hint(s) waste bandwidth`}]:[],...w.length>0?[{code:"missing-connection-hints",severity:"info",message:`${w.length} domain(s) could benefit from connection hints`}]:[],...m.length>0?[{code:"redundant-hints",severity:"warning",message:`${m.length} domain(s) have both preconnect and dns-prefetch (redundant)`}]:[]]}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Resource-Hints.js b/.claude/skills/webperf-loading/scripts/Resource-Hints.js index 9d5bcb0e..f4ee8410 100644 --- a/.claude/skills/webperf-loading/scripts/Resource-Hints.js +++ b/.claude/skills/webperf-loading/scripts/Resource-Hints.js @@ -1,2 +1,2 @@ -// snippets/Loading/Resource-Hints.js | sha256:7bb7f76a112a5b76 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints.js -(()=>{function e(e){return e.name.endsWith("#webperf-snippets")}function r(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const r=["B","KB","MB","GB"],t=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),r.length-1));return(e/Math.pow(1024,t)).toFixed(1)+" "+r[t]}const t=[{rel:"preload",icon:"⚡",description:"Critical resource for current page",requiresAs:!0},{rel:"modulepreload",icon:"đŸ“Ļ",description:"ES module for current page",requiresAs:!1},{rel:"preconnect",icon:"🔌",description:"Early connection to origin",requiresAs:!1},{rel:"dns-prefetch",icon:"🔍",description:"DNS lookup for origin",requiresAs:!1},{rel:"prefetch",icon:"đŸ“Ĩ",description:"Resource for future navigation",requiresAs:!1},{rel:"prerender",icon:"đŸ–ŧī¸",description:"Pre-render page",requiresAs:!1}],i=[],o=[];t.forEach(r=>{document.querySelectorAll(`link[rel="${r.rel}"]`).forEach(t=>{const n=t.href||t.getAttribute("href")||"";let s="";try{s=new URL(n,location.origin).hostname}catch{}const c={rel:r.rel,icon:r.icon,href:n,shortHref:n.length>50?"..."+n.slice(-47):n,hostname:s,as:t.getAttribute("as")||"",crossorigin:t.crossOrigin||t.getAttribute("crossorigin")||"",fetchpriority:t.fetchPriority||t.getAttribute("fetchpriority")||"",type:t.type||"",media:t.media||"",element:t};i.push(c),r.requiresAs&&!c.as&&o.push({hint:c,issue:"Missing 'as' attribute (required for preload)",severity:"error",fix:'Add as="script|style|font|image|fetch"'}),"font"!==c.as||c.crossorigin||o.push({hint:c,issue:"Font preload without crossorigin (causes double fetch)",severity:"error",fix:'Add crossorigin="anonymous"'}),"preconnect"!==r.rel||c.crossorigin||performance.getEntriesByType("resource").filter(r=>!e(r)).filter(e=>e.name.includes(s)&&("script"===e.initiatorType||"css"===e.initiatorType||"font"===e.initiatorType)).length>0&&o.push({hint:c,issue:"Preconnect may need crossorigin for CORS resources",severity:"warning",fix:'Consider adding crossorigin="anonymous"'})})});const n=i.filter(e=>"preload"===e.rel),s=performance.getEntriesByType("resource").filter(r=>!e(r));n.forEach(e=>{s.some(r=>r.name===e.href)||o.push({hint:e,issue:"Preloaded resource not used within page load",severity:"warning",fix:"Remove unused preload or verify it's needed"})});const c=i.filter(e=>"preconnect"===e.rel||"dns-prefetch"===e.rel);c.forEach(e=>{!s.some(r=>{try{return new URL(r.name).hostname===e.hostname}catch{return!1}})&&e.hostname&&o.push({hint:e,issue:`No resources loaded from ${e.hostname}`,severity:"warning",fix:"Remove unused preconnect or verify it's needed for future resources"})}),n.length>6&&o.push({hint:{rel:"preload",href:`${n.length} preloads`},issue:`Too many preloads (${n.length}) - they compete for bandwidth`,severity:"warning",fix:"Limit to 5-6 most critical resources"});const a=new Map;let h=0;s.forEach(e=>{try{const r=new URL(e.name);if(r.hostname!==location.hostname){a.has(r.origin)||a.set(r.origin,{count:0,size:0,types:new Set});const t=a.get(r.origin);t.count++,t.size+=e.transferSize||0,0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&e.duration>0&&h++,t.types.add(e.initiatorType)}}catch{}});const l=new Set([...c].map(e=>{try{return new URL(e.href).origin}catch{return""}})),u=[];a.forEach((e,r)=>{!l.has(r)&&e.count>=2&&u.push({origin:r,...e})}),t.map(e=>{const r=i.filter(r=>r.rel===e.rel).length;return{type:e.rel,count:r,icon:e.icon}}).forEach(e=>{}),t.forEach(e=>{const r=i.filter(r=>r.rel===e.rel);0!==r.length&&(r.map(r=>{const t={Resource:r.shortHref};if("preload"===e.rel||"modulepreload"===e.rel){t.As=r.as||"âš ī¸ missing",t.Crossorigin=r.crossorigin||"-",r.fetchpriority&&(t.Priority=r.fetchpriority);const e=s.some(e=>e.name===r.href);t.Used=e?"✅":"âš ī¸ No"}if("preconnect"===e.rel||"dns-prefetch"===e.rel){t.Origin=r.hostname,t.Crossorigin=r.crossorigin||"-";const e=s.filter(e=>{try{return new URL(e.name).hostname===r.hostname}catch{return!1}});t["Resources Loaded"]=e.length}return t}),r.forEach((e,r)=>{}))});const f=o.filter(e=>"error"===e.severity),p=o.filter(e=>"warning"===e.severity);o.length>0&&(f.length>0&&f.forEach(e=>{}),p.length>0&&p.forEach(e=>{})),u.length>0&&(u.sort((e,r)=>r.count-e.count).slice(0,5).map(e=>({Origin:e.origin,Resources:e.count,Size:r(e.size),Types:Array.from(e.types).join(", ")})),u.slice(0,3).forEach(e=>{}));const d=e=>o.some(r=>r.hint===e&&"error"===r.severity)?0:o.some(r=>r.hint===e)?1:2,g=i.map((e,r)=>({h:e,index:r})).sort((e,r)=>d(e.h)-d(r.h)||e.index-r.index).map(({h:e})=>e);return{script:"Resource-Hints",status:"ok",count:i.length,corsLimitedAnalysis:h>0,details:{sizeUnknownCount:h,byType:Object.fromEntries(t.map(e=>[e.rel,i.filter(r=>r.rel===e.rel).length])),missingPreconnectsCount:u.length,missingPreconnects:u.slice(0,20).map(e=>({origin:e.origin,requestCount:e.count,sizeBytes:e.size,resourceTypes:Array.from(e.types),recommendedHint:"preconnect"}))},items:g.slice(0,50).map(e=>({rel:e.rel,href:e.shortHref,as:e.as,crossorigin:e.crossorigin,fetchpriority:e.fetchpriority})),issues:o.map(e=>({severity:e.severity,message:`${e.hint.rel}: ${e.issue}. Fix: ${e.fix}`}))}})(); \ No newline at end of file +// snippets/Loading/Resource-Hints.js | sha256:07b5029ae84b9011 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints.js +(()=>{function e(e){return e.name.endsWith("#webperf-snippets")}function r(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const r=["B","KB","MB","GB"],o=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),r.length-1));return(e/Math.pow(1024,o)).toFixed(1)+" "+r[o]}const o=[{rel:"preload",icon:"⚡",description:"Critical resource for current page",requiresAs:!0},{rel:"modulepreload",icon:"đŸ“Ļ",description:"ES module for current page",requiresAs:!1},{rel:"preconnect",icon:"🔌",description:"Early connection to origin",requiresAs:!1},{rel:"dns-prefetch",icon:"🔍",description:"DNS lookup for origin",requiresAs:!1},{rel:"prefetch",icon:"đŸ“Ĩ",description:"Resource for future navigation",requiresAs:!1},{rel:"prerender",icon:"đŸ–ŧī¸",description:"Pre-render page",requiresAs:!1}],t=[],i=[];o.forEach(r=>{document.querySelectorAll(`link[rel="${r.rel}"]`).forEach(o=>{const n=o.href||o.getAttribute("href")||"";let s="";try{s=new URL(n,location.origin).hostname}catch{}const c={rel:r.rel,icon:r.icon,href:n,shortHref:n.length>50?"..."+n.slice(-47):n,hostname:s,as:o.getAttribute("as")||"",crossorigin:o.crossOrigin||o.getAttribute("crossorigin")||"",fetchpriority:o.fetchPriority||o.getAttribute("fetchpriority")||"",type:o.type||"",media:o.media||"",element:o};t.push(c),r.requiresAs&&!c.as&&i.push({hint:c,code:"preload-missing-as",issue:"Missing 'as' attribute (required for preload)",severity:"error",fix:'Add as="script|style|font|image|fetch"'}),"font"!==c.as||c.crossorigin||i.push({hint:c,code:"font-preload-no-crossorigin",issue:"Font preload without crossorigin (causes double fetch)",severity:"error",fix:'Add crossorigin="anonymous"'}),"preconnect"!==r.rel||c.crossorigin||performance.getEntriesByType("resource").filter(r=>!e(r)).filter(e=>e.name.includes(s)&&("script"===e.initiatorType||"css"===e.initiatorType||"font"===e.initiatorType)).length>0&&i.push({hint:c,code:"preconnect-no-crossorigin",issue:"Preconnect may need crossorigin for CORS resources",severity:"warning",fix:'Consider adding crossorigin="anonymous"'})})});const n=t.filter(e=>"preload"===e.rel),s=performance.getEntriesByType("resource").filter(r=>!e(r));n.forEach(e=>{s.some(r=>r.name===e.href)||i.push({hint:e,code:"unused-preload",issue:"Preloaded resource not used within page load",severity:"warning",fix:"Remove unused preload or verify it's needed"})});const c=t.filter(e=>"preconnect"===e.rel||"dns-prefetch"===e.rel);c.forEach(e=>{!s.some(r=>{try{return new URL(r.name).hostname===e.hostname}catch{return!1}})&&e.hostname&&i.push({hint:e,code:"unused-preconnect",issue:`No resources loaded from ${e.hostname}`,severity:"warning",fix:"Remove unused preconnect or verify it's needed for future resources"})}),n.length>6&&i.push({hint:{rel:"preload",href:`${n.length} preloads`},code:"too-many-preloads",issue:`Too many preloads (${n.length}) - they compete for bandwidth`,severity:"warning",fix:"Limit to 5-6 most critical resources"});const a=new Map;let l=0;s.forEach(e=>{try{const r=new URL(e.name);if(r.hostname!==location.hostname){a.has(r.origin)||a.set(r.origin,{count:0,size:0,types:new Set});const o=a.get(r.origin);o.count++,o.size+=e.transferSize||0,0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&e.duration>0&&l++,o.types.add(e.initiatorType)}}catch{}});const h=new Set([...c].map(e=>{try{return new URL(e.href).origin}catch{return""}})),u=[];a.forEach((e,r)=>{!h.has(r)&&e.count>=2&&u.push({origin:r,...e})}),o.map(e=>{const r=t.filter(r=>r.rel===e.rel).length;return{type:e.rel,count:r,icon:e.icon}}).forEach(e=>{}),o.forEach(e=>{const r=t.filter(r=>r.rel===e.rel);0!==r.length&&(r.map(r=>{const o={Resource:r.shortHref};if("preload"===e.rel||"modulepreload"===e.rel){o.As=r.as||"âš ī¸ missing",o.Crossorigin=r.crossorigin||"-",r.fetchpriority&&(o.Priority=r.fetchpriority);const e=s.some(e=>e.name===r.href);o.Used=e?"✅":"âš ī¸ No"}if("preconnect"===e.rel||"dns-prefetch"===e.rel){o.Origin=r.hostname,o.Crossorigin=r.crossorigin||"-";const e=s.filter(e=>{try{return new URL(e.name).hostname===r.hostname}catch{return!1}});o["Resources Loaded"]=e.length}return o}),r.forEach((e,r)=>{}))});const p=i.filter(e=>"error"===e.severity),d=i.filter(e=>"warning"===e.severity);i.length>0&&(p.length>0&&p.forEach(e=>{}),d.length>0&&d.forEach(e=>{})),u.length>0&&(u.sort((e,r)=>r.count-e.count).slice(0,5).map(e=>({Origin:e.origin,Resources:e.count,Size:r(e.size),Types:Array.from(e.types).join(", ")})),u.slice(0,3).forEach(e=>{}));const f=e=>i.some(r=>r.hint===e&&"error"===r.severity)?0:i.some(r=>r.hint===e)?1:2,g=t.map((e,r)=>({h:e,index:r})).sort((e,r)=>f(e.h)-f(r.h)||e.index-r.index).map(({h:e})=>e);return{script:"Resource-Hints",status:"ok",count:t.length,corsLimitedAnalysis:l>0,details:{sizeUnknownCount:l,byType:Object.fromEntries(o.map(e=>[e.rel,t.filter(r=>r.rel===e.rel).length])),missingPreconnectsCount:u.length,missingPreconnects:u.slice(0,20).map(e=>({origin:e.origin,requestCount:e.count,sizeBytes:e.size,resourceTypes:Array.from(e.types),recommendedHint:"preconnect"}))},items:g.slice(0,50).map(e=>({rel:e.rel,href:e.shortHref,as:e.as,crossorigin:e.crossorigin,fetchpriority:e.fetchpriority})),issues:i.map(e=>({code:e.code,severity:e.severity,message:`${e.hint.rel}: ${e.issue}. Fix: ${e.fix}`,...e.hint.element?{target:e.hint.href}:{}}))}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js b/.claude/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js index 9b83a0f1..fadf9b8b 100644 --- a/.claude/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js +++ b/.claude/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js @@ -1,2 +1,2 @@ -// snippets/Loading/Server-Timing-Early-Hints.js | sha256:196197df69458a61 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Server-Timing-Early-Hints.js -(()=>{const e="Server-Timing-Early-Hints",[r]=performance.getEntriesByType("navigation");if(!r)return{script:e,status:"error",error:"No navigation entry available"};if(!Array.isArray(r.serverTiming))return{script:e,status:"unsupported",error:"Server-Timing entries are not supported in this browser"};const s=e=>Math.round(100*e)/100,i=Math.max(0,r.activationStart??0),t=e=>e>0?s(Math.max(0,e-i)):0,n=(e,r,i)=>({source:r,url:i,name:e.name,durationMs:s(e.duration||0),description:e.description||""}),o=r.serverTiming.map(e=>n(e,"navigation",location.href));let a=0,c=0;for(const e of performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))){if(e.serverTiming.length>0){a++;for(const r of e.serverTiming)o.push(n(r,"resource",e.name));continue}let r=!1;try{r=new URL(e.name).origin!==location.origin}catch{}r&&0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&c++}o.sort((e,r)=>r.durationMs-e.durationMs);const d=o.slice(0,50),m=t(r.firstInterimResponseStart??0),p=t(r.finalResponseHeadersStart??r.responseStart),g=m>0,l={responseStartMs:t(r.responseStart),firstInterimResponseStartMs:m,finalResponseHeadersStartMs:p,earlyHintsReceived:g,earlyHintsLeadMs:g?s(Math.max(0,p-m)):0,serverTimingCount:r.serverTiming.length},u=[];return 0===r.serverTiming.length&&u.push({severity:"info",message:"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."}),c>0&&u.push({severity:"info",message:`${c} 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.`}),g||u.push({severity:"info",message:"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."}),{script:e,status:"ok",count:o.length,corsLimitedAnalysis:c>0,details:{navigation:l,resourcesWithServerTiming:a,corsRestrictedCount:c},items:d,issues:u}})(); \ No newline at end of file +// snippets/Loading/Server-Timing-Early-Hints.js | sha256:5d59b89aac1ec7b3 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Server-Timing-Early-Hints.js +(()=>{const e="Server-Timing-Early-Hints",[r]=performance.getEntriesByType("navigation");if(!r)return{script:e,status:"error",error:"No navigation entry available"};if(!Array.isArray(r.serverTiming))return{script:e,status:"unsupported",error:"Server-Timing entries are not supported in this browser"};const i=e=>Math.round(100*e)/100,s=Math.max(0,r.activationStart??0),n=e=>e>0?i(Math.max(0,e-s)):0,t=(e,r,s)=>({source:r,url:s,name:e.name,durationMs:i(e.duration||0),description:e.description||""}),o=r.serverTiming.map(e=>t(e,"navigation",location.href));let a=0,d=0;for(const e of performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))){if(e.serverTiming.length>0){a++;for(const r of e.serverTiming)o.push(t(r,"resource",e.name));continue}let r=!1;try{r=new URL(e.name).origin!==location.origin}catch{}r&&0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&d++}o.sort((e,r)=>r.durationMs-e.durationMs);const c=o.slice(0,50),m=n(r.firstInterimResponseStart??0),g=n(r.finalResponseHeadersStart??r.responseStart),p=m>0,l={responseStartMs:n(r.responseStart),firstInterimResponseStartMs:m,finalResponseHeadersStartMs:g,earlyHintsReceived:p,earlyHintsLeadMs:p?i(Math.max(0,g-m)):0,serverTimingCount:r.serverTiming.length},h=[];return 0===r.serverTiming.length&&h.push({code:"no-server-timing",severity:"info",message:"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."}),d>0&&h.push({code:"timing-hidden",severity:"info",message:`${d} 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.`}),p||h.push({code:"no-early-hints",severity:"info",message:"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."}),{script:e,status:"ok",count:o.length,corsLimitedAnalysis:d>0,details:{navigation:l,resourcesWithServerTiming:a,corsRestrictedCount:d},items:c,issues:h}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Service-Worker-Analysis.js b/.claude/skills/webperf-loading/scripts/Service-Worker-Analysis.js index de6bea82..af7803eb 100644 --- a/.claude/skills/webperf-loading/scripts/Service-Worker-Analysis.js +++ b/.claude/skills/webperf-loading/scripts/Service-Worker-Analysis.js @@ -1,2 +1,2 @@ -// snippets/Loading/Service-Worker-Analysis.js | sha256:c9a1ae09edc5f977 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Service-Worker-Analysis.js -(async()=>{if(!("serviceWorker"in navigator))return{script:"Service-Worker-Analysis",status:"unsupported",error:"Service Workers not supported in this browser",count:0,details:{},items:[],issues:[{severity:"error",message:"Service Workers not supported in this browser"}]};const e=await navigator.serviceWorker.getRegistrations(),t=navigator.serviceWorker.controller,r=performance.getEntriesByType("navigation")[0],n=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),s=n.filter(e=>e.workerStart>0),i=s.filter(e=>0===e.transferSize&&e.encodedBodySize>0),a=s.filter(e=>e.transferSize>0),o=s.filter(e=>0===e.transferSize&&0===e.encodedBodySize),l=i.length+a.length,c=n.filter(e=>0===e.workerStart);let g=null;r&&r.workerStart>0&&(g=Math.round(10*Math.max(r.fetchStart-r.startTime-(r.workerStart-r.startTime),0))/10);const h=l>0?parseFloat((i.length/l*100).toFixed(1)):null,u=i.reduce((e,t)=>e+(t.encodedBodySize||0),0),d=new Map;for(const t of e)if(t.navigationPreload)try{const e=await t.navigationPreload.getState();d.set(t,e)}catch{}const p=[];if("caches"in window)try{const e=await caches.keys();for(const t of e){const e=await caches.open(t),r=await e.keys();p.push({name:t,entries:r.length})}}catch{}const v=[...p].sort((e,t)=>t.entries-e.entries).slice(0,20),f=e.map(e=>{const t=d.get(e)||null;let r="none",n=null;return e.active?(r="active",n=e.active.scriptURL):e.waiting?(r="waiting",n=e.waiting.scriptURL):e.installing&&(r="installing",n=e.installing.scriptURL),{scope:e.scope,scriptURL:n,state:r,hasWaiting:!!e.waiting,hasInstalling:!!e.installing,navigationPreloadEnabled:t?t.enabled:null,navigationPreloadHeaderValue:t?.headerValue||null}}),w=[];0===e.length&&w.push({severity:"info",message:"No Service Workers registered"}),e.length>0&&!t&&w.push({severity:"warning",message:"Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload."});for(const e of f)e.hasWaiting&&w.push({severity:"warning",message:`Service Worker update pending for ${e.scope}. Call skipWaiting() to activate the new version.`}),!1===e.navigationPreloadEnabled&&w.push({severity:"info",message:`Navigation Preload is disabled for ${e.scope}. Consider enabling it with registration.navigationPreload.enable().`});null!==g&&g>100?w.push({severity:"error",message:`High SW startup time (${g}ms). Enable Navigation Preload to reduce overhead.`}):null!==g&&g>50&&w.push({severity:"warning",message:`Moderate SW startup time (${g}ms)`}),null!==h&&h<50?w.push({severity:"error",message:`Low SW cache hit rate (${h}%). Review the caching strategy.`}):null!==h&&h<80&&w.push({severity:"warning",message:`Moderate SW cache hit rate (${h}%). Review the caching strategy for more resources.`}),o.length>0&&w.push({severity:"info",message:`${o.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`});const m=null===h?null:h>=80?"good":h>=50?"needs-improvement":"poor";if(0===e.length)return{script:"Service-Worker-Analysis",status:"ok",count:0,details:{controlled:!1,controllerState:null,swOverheadMs:null,totalResources:n.length,swIntercepted:0,notIntercepted:n.length,fromCache:0,fromNetwork:0,fromUnknown:0,cacheHitRate:null,savedBytes:0,cacheStorageCount:p.length,cacheStorage:v},items:[],issues:w};for(const t of e)d.get(t);if(p.length>0){p.reduce((e,t)=>e+t.entries,0);for(const{}of p);}return{script:"Service-Worker-Analysis",status:"ok",count:e.length,...null!==m&&{rating:m},details:{controlled:!!t,controllerState:t?.state||null,swOverheadMs:g,totalResources:n.length,swIntercepted:s.length,notIntercepted:c.length,fromCache:i.length,fromNetwork:a.length,fromUnknown:o.length,cacheHitRate:h,savedBytes:u,cacheStorageCount:p.length,cacheStorage:v},items:f,issues:w}})(); \ No newline at end of file +// snippets/Loading/Service-Worker-Analysis.js | sha256:1e4ed59b35983d19 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Service-Worker-Analysis.js +(async()=>{if(!("serviceWorker"in navigator))return{script:"Service-Worker-Analysis",status:"unsupported",error:"Service Workers not supported in this browser",count:0,details:{},items:[],issues:[{code:"unsupported",severity:"error",message:"Service Workers not supported in this browser"}]};const e=await navigator.serviceWorker.getRegistrations(),t=navigator.serviceWorker.controller,r=performance.getEntriesByType("navigation")[0],n=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),o=n.filter(e=>e.workerStart>0),s=o.filter(e=>0===e.transferSize&&e.encodedBodySize>0),a=o.filter(e=>e.transferSize>0),i=o.filter(e=>0===e.transferSize&&0===e.encodedBodySize),l=s.length+a.length,c=n.filter(e=>0===e.workerStart);let g=null;r&&r.workerStart>0&&(g=Math.round(10*Math.max(r.fetchStart-r.startTime-(r.workerStart-r.startTime),0))/10);const d=l>0?parseFloat((s.length/l*100).toFixed(1)):null,h=s.reduce((e,t)=>e+(t.encodedBodySize||0),0),u=new Map;for(const t of e)if(t.navigationPreload)try{const e=await t.navigationPreload.getState();u.set(t,e)}catch{}const p=[];if("caches"in window)try{const e=await caches.keys();for(const t of e){const e=await caches.open(t),r=await e.keys();p.push({name:t,entries:r.length})}}catch{}const v=[...p].sort((e,t)=>t.entries-e.entries).slice(0,20),w=e.map(e=>{const t=u.get(e)||null;let r="none",n=null;return e.active?(r="active",n=e.active.scriptURL):e.waiting?(r="waiting",n=e.waiting.scriptURL):e.installing&&(r="installing",n=e.installing.scriptURL),{scope:e.scope,scriptURL:n,state:r,hasWaiting:!!e.waiting,hasInstalling:!!e.installing,navigationPreloadEnabled:t?t.enabled:null,navigationPreloadHeaderValue:t?.headerValue||null}}),f=[];0===e.length&&f.push({code:"no-service-worker",severity:"info",message:"No Service Workers registered"}),e.length>0&&!t&&f.push({code:"not-controlled",severity:"warning",message:"Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload."});for(const e of w)e.hasWaiting&&f.push({code:"update-pending",severity:"warning",message:`Service Worker update pending for ${e.scope}. Call skipWaiting() to activate the new version.`,target:e.scope}),!1===e.navigationPreloadEnabled&&f.push({code:"navigation-preload-disabled",severity:"info",message:`Navigation Preload is disabled for ${e.scope}. Consider enabling it with registration.navigationPreload.enable().`,target:e.scope});null!==g&&g>100?f.push({code:"slow-startup",severity:"error",message:`High SW startup time (${g}ms). Enable Navigation Preload to reduce overhead.`}):null!==g&&g>50&&f.push({code:"slow-startup",severity:"warning",message:`Moderate SW startup time (${g}ms)`}),null!==d&&d<50?f.push({code:"low-cache-hit-rate",severity:"error",message:`Low SW cache hit rate (${d}%). Review the caching strategy.`}):null!==d&&d<80&&f.push({code:"low-cache-hit-rate",severity:"warning",message:`Moderate SW cache hit rate (${d}%). Review the caching strategy for more resources.`}),i.length>0&&f.push({code:"unknown-source",severity:"info",message:`${i.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`});const m=null===d?null:d>=80?"good":d>=50?"needs-improvement":"poor";if(0===e.length)return{script:"Service-Worker-Analysis",status:"ok",count:0,details:{controlled:!1,controllerState:null,swOverheadMs:null,totalResources:n.length,swIntercepted:0,notIntercepted:n.length,fromCache:0,fromNetwork:0,fromUnknown:0,cacheHitRate:null,savedBytes:0,cacheStorageCount:p.length,cacheStorage:v},items:[],issues:f};for(const t of e)u.get(t);if(p.length>0){p.reduce((e,t)=>e+t.entries,0);for(const{}of p);}return{script:"Service-Worker-Analysis",status:"ok",count:e.length,...null!==m&&{rating:m},details:{controlled:!!t,controllerState:t?.state||null,swOverheadMs:g,totalResources:n.length,swIntercepted:o.length,notIntercepted:c.length,fromCache:s.length,fromNetwork:a.length,fromUnknown:i.length,cacheHitRate:d,savedBytes:h,cacheStorageCount:p.length,cacheStorage:v},items:w,issues:f}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js b/.claude/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js index 7d19a1ae..67046032 100644 --- a/.claude/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js +++ b/.claude/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js @@ -1,2 +1,2 @@ -// snippets/Loading/Speculation-Rules-Inspector.js | sha256:f4a2b4176f88e5c0 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Speculation-Rules-Inspector.js -(()=>{const e={activated:!document.prerendering,activatedAt:null};document.prerendering&&document.addEventListener("prerenderingchange",()=>{e.activated=!0,e.activatedAt=Math.round(performance.now())},{once:!0});const t=()=>{const t=[],r=[],s={},n="function"==typeof window.HTMLScriptElement.supports&&window.HTMLScriptElement.supports("speculationrules"),[i]=performance.getEntriesByType("navigation"),a=Math.round(i?.activationStart??0),o=i?.deliveryType??"",c=[...document.querySelectorAll('script[type="speculationrules"]')];return c.forEach((e,n)=>{let i;try{i=JSON.parse(e.textContent)}catch(e){return void t.push({severity:"error",message:`Speculation rules script #${n+1} is not valid JSON (${e.message}). The browser ignores the whole script.`})}for(const[e,a]of Object.entries(i??{}))if(Array.isArray(a)){s[e]=(s[e]??0)+a.length;for(const s of a){const i=s.source??(s.where?"document":"list"),a=Array.isArray(s.urls)?s.urls:[],o=s.eagerness??("document"===i?"conservative":"immediate");r.push({action:e,source:i,eagerness:o,urls:a.slice(0,20),urlCount:a.length,where:s.where??null,requires:s.requires??[],referrerPolicy:s.referrer_policy??null,scriptIndex:n});const c="prerender"===e||"prerender_until_script"===e;c&&"list"===i&&("immediate"===o||"eager"===o)&&a.length>10&&t.push({severity:"warning",message:`A ${e} rule lists ${a.length} URLs with eagerness "${o}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`}),!c||"document"!==i||"immediate"!==o&&"eager"!==o||t.push({severity:"warning",message:`A document ${e} rule uses eagerness "${o}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`})}}}),0===c.length?t.push({severity:"info",message:"No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript."}):0!==r.length||t.some(e=>"error"===e.severity)||t.push({severity:"info",message:"Speculation rules scripts were found but contain no prefetch or prerender rules."}),!n&&c.length>0&&t.push({severity:"info",message:"This browser does not support speculation rules, so the rules are ignored."}),a>0&&t.push({severity:"info",message:`This page was prerendered and activated at ${a}ms. Navigation timings are relative to activationStart.`}),{script:"Speculation-Rules-Inspector",status:"ok",count:r.length,details:{supported:n,prerendering:!0===document.prerendering,activated:e.activated,activatedAt:e.activatedAt,activationStart:a,deliveryType:o,scriptCount:c.length,actions:s},items:r.slice(0,50),issues:t}};window.getSpeculationRulesInspection=t;const r=t();return r.issues.forEach(e=>{}),document.prerendering?{script:"Speculation-Rules-Inspector",status:"tracking",message:"This page is prerendering. Call getSpeculationRulesInspection() after activation to see the final timings.",getDataFn:"getSpeculationRulesInspection"}:r})(); \ No newline at end of file +// snippets/Loading/Speculation-Rules-Inspector.js | sha256:6bd00e36c4f0b97c | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Speculation-Rules-Inspector.js +(()=>{const e={activated:!document.prerendering,activatedAt:null};document.prerendering&&document.addEventListener("prerenderingchange",()=>{e.activated=!0,e.activatedAt=Math.round(performance.now())},{once:!0});const r=()=>{const r=[],t=[],s={},n="function"==typeof window.HTMLScriptElement.supports&&window.HTMLScriptElement.supports("speculationrules"),[i]=performance.getEntriesByType("navigation"),a=Math.round(i?.activationStart??0),o=i?.deliveryType??"",c=[...document.querySelectorAll('script[type="speculationrules"]')];return c.forEach((e,n)=>{let i;try{i=JSON.parse(e.textContent)}catch(e){return void r.push({code:"invalid-json",severity:"error",message:`Speculation rules script #${n+1} is not valid JSON (${e.message}). The browser ignores the whole script.`})}for(const[e,a]of Object.entries(i??{}))if(Array.isArray(a)){s[e]=(s[e]??0)+a.length;for(const s of a){const i=s.source??(s.where?"document":"list"),a=Array.isArray(s.urls)?s.urls:[],o=s.eagerness??("document"===i?"conservative":"immediate");t.push({action:e,source:i,eagerness:o,urls:a.slice(0,20),urlCount:a.length,where:s.where??null,requires:s.requires??[],referrerPolicy:s.referrer_policy??null,scriptIndex:n});const c="prerender"===e||"prerender_until_script"===e;c&&"list"===i&&("immediate"===o||"eager"===o)&&a.length>10&&r.push({code:"eager-list-capped",severity:"warning",message:`A ${e} rule lists ${a.length} URLs with eagerness "${o}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`}),!c||"document"!==i||"immediate"!==o&&"eager"!==o||r.push({code:"eager-document-rule",severity:"warning",message:`A document ${e} rule uses eagerness "${o}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`})}}}),0===c.length?r.push({code:"no-rules",severity:"info",message:"No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript."}):0!==t.length||r.some(e=>"error"===e.severity)||r.push({code:"no-prefetch-or-prerender",severity:"info",message:"Speculation rules scripts were found but contain no prefetch or prerender rules."}),!n&&c.length>0&&r.push({code:"unsupported",severity:"info",message:"This browser does not support speculation rules, so the rules are ignored."}),a>0&&r.push({code:"prerendered",severity:"info",message:`This page was prerendered and activated at ${a}ms. Navigation timings are relative to activationStart.`}),{script:"Speculation-Rules-Inspector",status:"ok",count:t.length,details:{supported:n,prerendering:!0===document.prerendering,activated:e.activated,activatedAt:e.activatedAt,activationStart:a,deliveryType:o,scriptCount:c.length,actions:s},items:t.slice(0,50),issues:r}};window.getSpeculationRulesInspection=r;const t=r();return t.issues.forEach(e=>{}),document.prerendering?{script:"Speculation-Rules-Inspector",status:"tracking",message:"This page is prerendering. Call getSpeculationRulesInspection() after activation to see the final timings.",getDataFn:"getSpeculationRulesInspection"}:t})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/TTFB-Resources.js b/.claude/skills/webperf-loading/scripts/TTFB-Resources.js index a9338a9e..fb00c474 100644 --- a/.claude/skills/webperf-loading/scripts/TTFB-Resources.js +++ b/.claude/skills/webperf-loading/scripts/TTFB-Resources.js @@ -1,2 +1,2 @@ -// snippets/Loading/TTFB-Resources.js | sha256:784e36c4612e294b | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Resources.js -(()=>{function t(t){return t.name.endsWith("#webperf-snippets")}const e=[],r=t=>t.responseStart-t.requestStart,n=t=>t.responseStart>0;function s(t){const e=t.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(e)||e.includes(":"))return e;const r=e.split(".");return r.length<=2?e:2===r[r.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(r[r.length-2])?r.slice(-3).join("."):r.slice(-2).join(".")}const i=t=>{try{return!function(t){const r=s(t);return r===s(location.hostname)||e.some(t=>s(String(t).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===r)}(new URL(t).hostname)}catch{return!1}};new PerformanceObserver(e=>{const s=e.getEntries().filter(e=>!t(e)),o=(s.filter(t=>!n(t)),s.filter(n).map(t=>{const e=i(t.name);return{ttfb:r(t),duration:t.duration,type:t.initiatorType,thirdParty:e,resource:t.name.length>70?"..."+t.name.slice(-67):t.name,fullUrl:t.name}}).sort((t,e)=>e.ttfb-t.ttfb));if(0===o.length)return;const a=o.map(t=>t.ttfb),l=(a.reduce((t,e)=>t+e,0),Math.max(...a),Math.min(...a),o.filter(t=>t.thirdParty),o.filter(t=>t.ttfb>500),o.slice(0,25).map(t=>({"TTFB (ms)":t.ttfb.toFixed(0),"Duration (ms)":t.duration.toFixed(0),Type:t.type,"3rd Party":t.thirdParty?"Yes":"",Resource:t.resource})),o.slice(0,5));l[0].ttfb>500&&l.forEach((t,e)=>{})}).observe({type:"resource",buffered:!0});const o=performance.getEntriesByType("resource").filter(e=>!t(e)),a=o.filter(t=>!n(t)).length,l=o.filter(n).map(t=>({url:t.name,ttfbMs:Math.round(r(t)),durationMs:Math.round(t.duration),type:t.initiatorType,isThirdParty:i(t.name)})).sort((t,e)=>e.ttfbMs-t.ttfbMs);if(0===l.length)return{script:"TTFB-Resources",status:"ok",count:0,corsLimitedAnalysis:a>0,details:{avgTtfbMs:null,maxTtfbMs:null,minTtfbMs:null,thirdPartyCount:0,slowCount:0,corsRestrictedCount:a},items:[],issues:[{severity:"info",message:"No resources with TTFB data available"}]};const u=l.map(t=>t.ttfbMs);return{script:"TTFB-Resources",status:"ok",count:l.length,corsLimitedAnalysis:a>0,details:{avgTtfbMs:Math.round(u.reduce((t,e)=>t+e,0)/u.length),maxTtfbMs:Math.max(...u),minTtfbMs:Math.min(...u),thirdPartyCount:l.filter(t=>t.isThirdParty).length,slowCount:l.filter(t=>t.ttfbMs>500).length,corsRestrictedCount:a},items:l.slice(0,50)}})(); \ No newline at end of file +// snippets/Loading/TTFB-Resources.js | sha256:358f34a389183974 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Resources.js +(()=>{function t(t){return t.name.endsWith("#webperf-snippets")}const e=[],r=t=>t.responseStart-t.requestStart,s=t=>t.responseStart>0;function n(t){const e=t.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(e)||e.includes(":"))return e;const r=e.split(".");return r.length<=2?e:2===r[r.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(r[r.length-2])?r.slice(-3).join("."):r.slice(-2).join(".")}const i=t=>{try{return!function(t){const r=n(t);return r===n(location.hostname)||e.some(t=>n(String(t).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===r)}(new URL(t).hostname)}catch{return!1}};new PerformanceObserver(e=>{const n=e.getEntries().filter(e=>!t(e)),o=(n.filter(t=>!s(t)),n.filter(s).map(t=>{const e=i(t.name);return{ttfb:r(t),duration:t.duration,type:t.initiatorType,thirdParty:e,resource:t.name.length>70?"..."+t.name.slice(-67):t.name,fullUrl:t.name}}).sort((t,e)=>e.ttfb-t.ttfb));if(0===o.length)return;const a=o.map(t=>t.ttfb),c=(a.reduce((t,e)=>t+e,0),Math.max(...a),Math.min(...a),o.filter(t=>t.thirdParty),o.filter(t=>t.ttfb>500),o.slice(0,25).map(t=>({"TTFB (ms)":t.ttfb.toFixed(0),"Duration (ms)":t.duration.toFixed(0),Type:t.type,"3rd Party":t.thirdParty?"Yes":"",Resource:t.resource})),o.slice(0,5));c[0].ttfb>500&&c.forEach((t,e)=>{})}).observe({type:"resource",buffered:!0});const o=performance.getEntriesByType("resource").filter(e=>!t(e)),a=o.filter(t=>!s(t)).length,c=o.filter(s).map(t=>({url:t.name,ttfbMs:Math.round(r(t)),durationMs:Math.round(t.duration),type:t.initiatorType,isThirdParty:i(t.name)})).sort((t,e)=>e.ttfbMs-t.ttfbMs);if(0===c.length)return{script:"TTFB-Resources",status:"ok",count:0,corsLimitedAnalysis:a>0,details:{avgTtfbMs:null,maxTtfbMs:null,minTtfbMs:null,thirdPartyCount:0,slowCount:0,corsRestrictedCount:a},items:[],issues:[{code:"no-resources",severity:"info",message:"No resources with TTFB data available"}]};const u=c.map(t=>t.ttfbMs);return{script:"TTFB-Resources",status:"ok",count:c.length,corsLimitedAnalysis:a>0,details:{avgTtfbMs:Math.round(u.reduce((t,e)=>t+e,0)/u.length),maxTtfbMs:Math.max(...u),minTtfbMs:Math.min(...u),thirdPartyCount:c.filter(t=>t.isThirdParty).length,slowCount:c.filter(t=>t.ttfbMs>500).length,corsRestrictedCount:a},items:c.slice(0,50)}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-loading/scripts/TTFB-Sub-Parts.js b/.claude/skills/webperf-loading/scripts/TTFB-Sub-Parts.js index c19f9dc9..11e4c2d9 100644 --- a/.claude/skills/webperf-loading/scripts/TTFB-Sub-Parts.js +++ b/.claude/skills/webperf-loading/scripts/TTFB-Sub-Parts.js @@ -1,2 +1,2 @@ -// snippets/Loading/TTFB-Sub-Parts.js | sha256:ca68d328bd7e2938 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Sub-Parts.js -(()=>{new PerformanceObserver(e=>{const[t]=e.getEntriesByType("navigation"),r=t.activationStart||0,n=Math.max((t.workerStart||t.fetchStart)-r,0),o=Math.max(t.domainLookupStart-r,0),a=Math.max(t.connectStart-r,0),i=Math.max(t.connectEnd-r,0),s=t.secureConnectionStart>0?Math.max(t.secureConnectionStart-r,0):i,c=Math.max(t.responseStart-r,0);let d,u;c<=800?(d="Good",u="#22c55e"):c<=1800?(d="Needs Improvement",u="#f59e0b"):(d="Poor",u="#ef4444");const m=[{name:"Redirect/Wait",duration:n,icon:"🔄"},{name:"Service Worker/Cache",duration:o-n,icon:"âš™ī¸"},{name:"DNS Lookup",duration:a-o,icon:"🔍"},{name:"TCP Connection",duration:s-a,icon:"🔌"},{name:"SSL/TLS",duration:i-s,icon:"🔒"},{name:"Server Response",duration:c-i,icon:"đŸ“Ĩ"}];m.forEach(({})=>{}),m.reduce((e,t)=>e.duration>t.duration?e:t)}).observe({type:"navigation",buffered:!0});const[e]=performance.getEntriesByType("navigation");if(!e)return{script:"TTFB-Sub-Parts",status:"error",error:"No navigation entry"};const t=e.activationStart||0,r=Math.max(0,e.responseStart-t),n=Math.round(r),o=n<=800?"good":n<=1800?"needs-improvement":"poor",a=Math.max((e.workerStart||e.fetchStart)-t,0),i=Math.max(e.domainLookupStart-t,0),s=Math.max(e.connectStart-t,0),c=Math.max(e.connectEnd-t,0),d=e.secureConnectionStart>0?Math.max(e.secureConnectionStart-t,0):c,u=[{key:"redirectWait",duration:a},{key:"serviceWorkerCache",duration:i-a},{key:"dnsLookup",duration:s-i},{key:"tcpConnection",duration:d-s},{key:"sslTls",duration:c-d},{key:"serverResponse",duration:r-c}],m=u.reduce((e,t)=>e.duration>t.duration?e:t),h={redirectWait:"redirect and wait",serviceWorkerCache:"service worker and cache",dnsLookup:"DNS lookup",tcpConnection:"TCP connection",sslTls:"SSL/TLS",serverResponse:"server response"},p={redirectWait:"minimize redirects and link to the final URL",serviceWorkerCache:"review the service worker startup and consider a cache-first strategy",dnsLookup:"use dns-prefetch or a faster DNS provider",tcpConnection:"enable HTTP/2 or HTTP/3 and keep connections alive",sslTls:"enable TLS 1.3 and session resumption, and check the certificate chain",serverResponse:"optimize server processing, cache the HTML and check slow queries"},k=[];if("good"!==o){const e=Math.round(m.duration),t=r>0?Math.round(m.duration/r*100):0;k.push({severity:"poor"===o?"error":"warning",message:`TTFB is ${n} ms (${o}); ${h[m.key]} takes ${e} ms of it (${t}%). Fix: ${p[m.key]}.`})}return{script:"TTFB-Sub-Parts",status:"ok",metric:"TTFB",value:n,unit:"ms",rating:o,thresholds:{good:800,needsImprovement:1800},details:{subParts:Object.fromEntries(u.map(e=>[e.key,{value:Math.round(e.duration),unit:"ms"}])),slowestPhase:m.key},issues:k}})(); \ No newline at end of file +// snippets/Loading/TTFB-Sub-Parts.js | sha256:bd057af385152ba3 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Sub-Parts.js +(()=>{new PerformanceObserver(e=>{const[t]=e.getEntriesByType("navigation"),r=t.activationStart||0,n=Math.max((t.workerStart||t.fetchStart)-r,0),o=Math.max(t.domainLookupStart-r,0),a=Math.max(t.connectStart-r,0),i=Math.max(t.connectEnd-r,0),s=t.secureConnectionStart>0?Math.max(t.secureConnectionStart-r,0):i,c=Math.max(t.responseStart-r,0);let d,u;c<=800?(d="Good",u="#22c55e"):c<=1800?(d="Needs Improvement",u="#f59e0b"):(d="Poor",u="#ef4444");const m=[{name:"Redirect/Wait",duration:n,icon:"🔄"},{name:"Service Worker/Cache",duration:o-n,icon:"âš™ī¸"},{name:"DNS Lookup",duration:a-o,icon:"🔍"},{name:"TCP Connection",duration:s-a,icon:"🔌"},{name:"SSL/TLS",duration:i-s,icon:"🔒"},{name:"Server Response",duration:c-i,icon:"đŸ“Ĩ"}];m.forEach(({})=>{}),m.reduce((e,t)=>e.duration>t.duration?e:t)}).observe({type:"navigation",buffered:!0});const[e]=performance.getEntriesByType("navigation");if(!e)return{script:"TTFB-Sub-Parts",status:"error",error:"No navigation entry"};const t=e.activationStart||0,r=Math.max(0,e.responseStart-t),n=Math.round(r),o=n<=800?"good":n<=1800?"needs-improvement":"poor",a=Math.max((e.workerStart||e.fetchStart)-t,0),i=Math.max(e.domainLookupStart-t,0),s=Math.max(e.connectStart-t,0),c=Math.max(e.connectEnd-t,0),d=e.secureConnectionStart>0?Math.max(e.secureConnectionStart-t,0):c,u=[{key:"redirectWait",duration:a},{key:"serviceWorkerCache",duration:i-a},{key:"dnsLookup",duration:s-i},{key:"tcpConnection",duration:d-s},{key:"sslTls",duration:c-d},{key:"serverResponse",duration:r-c}],m=u.reduce((e,t)=>e.duration>t.duration?e:t),h={redirectWait:"redirect and wait",serviceWorkerCache:"service worker and cache",dnsLookup:"DNS lookup",tcpConnection:"TCP connection",sslTls:"SSL/TLS",serverResponse:"server response"},p={redirectWait:"minimize redirects and link to the final URL",serviceWorkerCache:"review the service worker startup and consider a cache-first strategy",dnsLookup:"use dns-prefetch or a faster DNS provider",tcpConnection:"enable HTTP/2 or HTTP/3 and keep connections alive",sslTls:"enable TLS 1.3 and session resumption, and check the certificate chain",serverResponse:"optimize server processing, cache the HTML and check slow queries"},k=[];if("good"!==o){const e=Math.round(m.duration),t=r>0?Math.round(m.duration/r*100):0;k.push({code:"slow-ttfb",severity:"poor"===o?"error":"warning",message:`TTFB is ${n} ms (${o}); ${h[m.key]} takes ${e} ms of it (${t}%). Fix: ${p[m.key]}.`})}return{script:"TTFB-Sub-Parts",status:"ok",metric:"TTFB",value:n,unit:"ms",rating:o,thresholds:{good:800,needsImprovement:1800},details:{subParts:Object.fromEntries(u.map(e=>[e.key,{value:Math.round(e.duration),unit:"ms"}])),slowestPhase:m.key},issues:k}})(); \ No newline at end of file diff --git a/.claude/skills/webperf-media/references/schema.md b/.claude/skills/webperf-media/references/schema.md index 988a08ea..975142c7 100644 --- a/.claude/skills/webperf-media/references/schema.md +++ b/.claude/skills/webperf-media/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/.claude/skills/webperf-resources/references/schema.md b/.claude/skills/webperf-resources/references/schema.md index 988a08ea..975142c7 100644 --- a/.claude/skills/webperf-resources/references/schema.md +++ b/.claude/skills/webperf-resources/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a4807c..8a5f1f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- Issues carry a `code`, kebab-case and stable across runs and versions, so a comparison pairs the issues of two runs without reading their message, and a `target` with the URL, selector or name of what the issue is about when there is one issue per thing, such as an image or a CSS property. `snippets/SCHEMA.md` lists the codes of each snippet under Issue codes, and `cli/tests/unit/issue-codes.test.js` fails when the source and the table differ. The snippets of Core Web Vitals, Interaction and Media, and the snippets of Loading about scripts, carry their codes; the rest of Loading follows. +- Issues carry a `code`, kebab-case and stable across runs and versions, so a comparison pairs the issues of two runs without reading their message, and a `target` with the URL, selector or name of what the issue is about when there is one issue per thing, such as an image or a CSS property. `snippets/SCHEMA.md` lists the codes of each snippet under Issue codes, and `cli/tests/unit/issue-codes.test.js` fails when the source and the table differ. The snippets of Core Web Vitals, Interaction and Media, and the snippets of Loading about scripts, the network, hints and caching, carry their codes; the snippets of Loading about CSS, fonts and images follow. - `npm run eval:mcp`: the same question about a local page with a known answer, asked to an agent with the webperf MCP server, with Chrome DevTools MCP and the webperf skills, and with Chrome DevTools MCP alone, graded on the diagnosis and measured in tokens, turns and time. It runs by hand, not in CI. - The CLI reaches version 1.4.0 (see [`cli/CHANGELOG.md`](cli/CHANGELOG.md)): `--baseline` compares a page, or a matrix combination by combination, with the `--json` output of an earlier run, with a threshold per metric, the new and fixed errors, warnings when the conditions differ, and `--fail-on-regression` to exit with `1` on a regression. The JSON output carries the conditions of each measurement in `conditions`. - The CLI reaches version 1.3.0 (see [`cli/CHANGELOG.md`](cli/CHANGELOG.md)): `--runs` measures a page several times and reports the median run, and `--matrix` measures several pages on several devices and in several variants, such as with and without the consent cookie, from one JSON file, with a table of the medians. diff --git a/cli/tests/e2e/audit.test.js b/cli/tests/e2e/audit.test.js index 4c0b3dee..21141a6f 100644 --- a/cli/tests/e2e/audit.test.js +++ b/cli/tests/e2e/audit.test.js @@ -213,7 +213,7 @@ describe("Audit snippets — green fixture (no violations)", () => { expect(r.status).toBe("ok"); expect(r.count).toBe(0); expect(r.items).toEqual([]); - expect(r.issues).toEqual([{ severity: "info", message: expect.stringMatching(/No prefetch hints on this page/) }]); + expect(r.issues).toEqual([{ code: "no-prefetch", severity: "info", message: expect.stringMatching(/No prefetch hints on this page/) }]); }, 30000, ); diff --git a/cli/tests/e2e/bfcache.test.js b/cli/tests/e2e/bfcache.test.js index 8caa1ace..0a85d3c6 100644 --- a/cli/tests/e2e/bfcache.test.js +++ b/cli/tests/e2e/bfcache.test.js @@ -66,6 +66,7 @@ describe("Back-Forward-Cache detection", () => { const r = await runFresh("/unload-prop"); expect(r.details.eligibility).toBe("likely-blocked"); expect(r.issues.some((i) => i.severity === "error" && /unload/i.test(i.message))).toBe(true); + expect(r.issues).toContainEqual(expect.objectContaining({ code: "unload-handler", severity: "error" })); }, 30000); it("detects Cache-Control: no-store from the response headers", async () => { @@ -108,5 +109,7 @@ describe("Back-Forward-Cache detection", () => { expect(nrr.children[0].reasons).toContain("unload-listener"); expect(r.details.eligibility).toBe("blocked"); expect(r.issues.some((i) => i.severity === "error" && /unload-listener/.test(i.message) && /frame-blocked/.test(i.message))).toBe(true); + // Each reason the browser gives is its own issue, named in target + expect(r.issues.filter((i) => i.code === "not-restored").every((i) => i.target === i.message)).toBe(true); }, 30000); }); diff --git a/cli/tests/e2e/cache-strategy.test.js b/cli/tests/e2e/cache-strategy.test.js index dc0fafd1..e082f289 100644 --- a/cli/tests/e2e/cache-strategy.test.js +++ b/cli/tests/e2e/cache-strategy.test.js @@ -111,6 +111,8 @@ describe("Cache-Strategy-Analysis, findings that need no cache headers", () => { const issue = result.issues.find((i) => i.id === "duplicate-uncached"); expect(issue.severity).toBe("warning"); expect(issue.resource).toBe("data.json"); + // The code repeats the id, and target is the whole URL + expect(issue).toEqual(expect.objectContaining({ code: "duplicate-uncached", target: expect.stringMatching(/^http.*\/data\.json$/) })); }, 30000); it("reports a duplicated resource once, not once per request", async () => { diff --git a/cli/tests/e2e/loading-returns.test.js b/cli/tests/e2e/loading-returns.test.js index 393658a1..b93944a7 100644 --- a/cli/tests/e2e/loading-returns.test.js +++ b/cli/tests/e2e/loading-returns.test.js @@ -145,7 +145,8 @@ describe("Priority-Hints-Audit and Validate-Preload-Async-Defer-Scripts", () => it("Priority-Hints-Audit puts the fix inside the message", async () => { const r = await runOn("/priority", "Loading/Priority-Hints-Audit"); expect(r.issues.length).toBeGreaterThan(0); - for (const i of r.issues) expect(keysOf(i)).toBe("message,severity"); + // Each issue names its resource, so a later run pairs it with the same one + for (const i of r.issues) expect(keysOf(i)).toBe("code,message,severity,target"); expect(r.issues.some((i) => i.message.includes("Fix: "))).toBe(true); expect(r.issues.every((i) => !i.message.includes(" — "))).toBe(true); }, 30000); @@ -225,7 +226,7 @@ describe("TTFB-Resources, a page without measurable resources", () => { slowCount: 0, corsRestrictedCount: 0, }); - expect(r.issues).toEqual([{ severity: "info", message: "No resources with TTFB data available" }]); + expect(r.issues).toEqual([{ code: "no-resources", severity: "info", message: "No resources with TTFB data available" }]); }, 60000); }); diff --git a/cli/tests/e2e/speculation-rules.test.js b/cli/tests/e2e/speculation-rules.test.js index 9706fe7b..94e28ea5 100644 --- a/cli/tests/e2e/speculation-rules.test.js +++ b/cli/tests/e2e/speculation-rules.test.js @@ -82,7 +82,7 @@ describe("Speculation-Rules-Inspector", () => { expect(r.status).toBe("ok"); expect(r.count).toBe(0); expect(r.details.scriptCount).toBe(0); - expect(r.issues).toEqual([{ severity: "info", message: expect.stringMatching(/no speculation rules/i) }]); + expect(r.issues).toEqual([{ code: "no-rules", severity: "info", message: expect.stringMatching(/no speculation rules/i) }]); }, 60000); it("tracks a prerendering page and reports the activation through the getter", async () => { diff --git a/cli/tests/helpers/issue-codes.js b/cli/tests/helpers/issue-codes.js index 912e41cd..16539e79 100644 --- a/cli/tests/helpers/issue-codes.js +++ b/cli/tests/helpers/issue-codes.js @@ -12,11 +12,7 @@ export const ISSUE_CODE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; // The snippets that emit issues without a code yet. It can only shrink: a snippet that gets its codes // leaves the list, and the contract and the catalog then hold it to them. export const PENDING_ISSUE_CODES = [ - "Loading/Back-Forward-Cache", "Loading/CSS-Media-Queries-Analysis", - "Loading/Cache-Strategy-Analysis", - "Loading/Client-Side-Redirect-Detection", - "Loading/Compression-Audit", "Loading/Content-Visibility", "Loading/Critical-CSS-Detection", "Loading/Find-Above-The-Fold-Lazy-Loaded-Images", @@ -25,27 +21,18 @@ export const PENDING_ISSUE_CODES = [ "Loading/Find-render-blocking-resources", "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold", "Loading/Inline-CSS-Info-and-Size", - "Loading/Prefetch-Resource-Validation", - "Loading/Priority-Hints-Audit", - "Loading/Resource-Hints", - "Loading/Resource-Hints-Validation", - "Loading/Server-Timing-Early-Hints", - "Loading/Service-Worker-Analysis", - "Loading/Speculation-Rules-Inspector", - "Loading/TTFB-Resources", - "Loading/TTFB-Sub-Parts", "Loading/Webfont-Usage-Analyzer", ]; -// The codes a snippet emits, read from its source: the literals of `code: "..."` in an object. A copy -// of a code already set (`code: issue.code`) is fine; `nonLiteral` counts the codes built at run time, -// which the catalog cannot check. +// The codes a snippet emits, read from its source: the literals of `code: "..."`, or in single quotes, +// in an object. A copy of a code already set (`code: issue.code`) is fine; `nonLiteral` counts the codes +// built at run time, which the catalog cannot check. export function codesInSource(source) { const codes = new Set(); let nonLiteral = 0; for (const [, value] of source.matchAll(/(?<=[{,]\s*)code:\s*([^,}\n]+)/g)) { - const literal = value.match(/^"([^"]*)"\s*$/); - if (literal) codes.add(literal[1]); + const literal = value.match(/^(["'])([^"']*)\1\s*$/); + if (literal) codes.add(literal[2]); else if (!/^[\w$]+(?:\?)?\.code\s*$/.test(value)) nonLiteral++; } return { codes, nonLiteral }; diff --git a/cli/tests/unit/issue-codes.test.js b/cli/tests/unit/issue-codes.test.js index 900d7f78..147cbf2a 100644 --- a/cli/tests/unit/issue-codes.test.js +++ b/cli/tests/unit/issue-codes.test.js @@ -16,6 +16,10 @@ describe("codesInSource", () => { expect(codesInSource(source)).toEqual({ codes: new Set(["blocking-in-head", "large-blocking"]), nonLiteral: 0 }); }); + it("reads a code in single quotes, as the snippets written with them have", () => { + expect(codesInSource("issues.push({ code: 'invalid-json', severity: 'error' })")).toEqual({ codes: new Set(["invalid-json"]), nonLiteral: 0 }); + }); + it("leaves out the word code inside a string, and accepts a copy of a code already set", () => { const source = `console.log("%cSuggested code:", "bold"); issues.map((i) => ({ code: i.code, severity: i.severity }))`; expect(codesInSource(source)).toEqual({ codes: new Set(), nonLiteral: 0 }); diff --git a/dist/antigravity/webperf-core-web-vitals/references/schema.md b/dist/antigravity/webperf-core-web-vitals/references/schema.md index 988a08ea..975142c7 100644 --- a/dist/antigravity/webperf-core-web-vitals/references/schema.md +++ b/dist/antigravity/webperf-core-web-vitals/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/dist/antigravity/webperf-interaction/references/schema.md b/dist/antigravity/webperf-interaction/references/schema.md index 988a08ea..975142c7 100644 --- a/dist/antigravity/webperf-interaction/references/schema.md +++ b/dist/antigravity/webperf-interaction/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/dist/antigravity/webperf-loading/references/schema.md b/dist/antigravity/webperf-loading/references/schema.md index 988a08ea..975142c7 100644 --- a/dist/antigravity/webperf-loading/references/schema.md +++ b/dist/antigravity/webperf-loading/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/dist/antigravity/webperf-loading/scripts/Back-Forward-Cache.js b/dist/antigravity/webperf-loading/scripts/Back-Forward-Cache.js index 6d82d0f1..10704bf2 100644 --- a/dist/antigravity/webperf-loading/scripts/Back-Forward-Cache.js +++ b/dist/antigravity/webperf-loading/scripts/Back-Forward-Cache.js @@ -49,6 +49,7 @@ const confirmed = results.notRestoredReasons ? flattenReasons(results.notRestoredReasons, results.notRestoredReasons.url || "main frame") : []; confirmed.forEach(({reason: reason, frame: frame}) => { issues.push({ + code: "not-restored", reason: `${reason} (${frame})`, severity: "high", source: "browser", @@ -56,6 +57,7 @@ }); }); if (window.onunload !== null && !confirmed.some(({reason: reason}) => reason === "unload-listener")) issues.push({ + code: "unload-handler", reason: "window.onunload handler set", severity: "high", source: "detected", @@ -68,6 +70,7 @@ }); const cacheControl = res.headers.get("cache-control") || ""; if (/no-store/i.test(cacheControl) && !confirmed.some(({reason: reason}) => reason === "response-cache-control-no-store")) issues.push({ + code: "cache-control-no-store", reason: "Cache-Control: no-store on the page response", severity: "medium", source: "detected", @@ -76,12 +79,14 @@ } catch {} const iframeCount = document.querySelectorAll("iframe").length; if (iframeCount > 0) issues.push({ + code: "iframes", reason: `${iframeCount} iframe(s) on the page`, severity: "info", source: "note", description: "A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation." }); if ("serviceWorker" in navigator && navigator.serviceWorker.controller) issues.push({ + code: "service-worker", reason: "Service Worker controls the page", severity: "info", source: "note", @@ -128,8 +133,12 @@ notRestoredReasons: results.notRestoredReasons }, issues: results.blockingReasons.map(i => ({ + code: i.code, severity: i.severity === "high" ? "error" : i.severity === "medium" ? "warning" : "info", - message: i.reason + message: i.reason, + ...i.code === "not-restored" ? { + target: i.reason + } : {} })) }); window.checkBfcache = async () => { diff --git a/dist/antigravity/webperf-loading/scripts/Cache-Strategy-Analysis.js b/dist/antigravity/webperf-loading/scripts/Cache-Strategy-Analysis.js index 8508f373..47cd5aad 100644 --- a/dist/antigravity/webperf-loading/scripts/Cache-Strategy-Analysis.js +++ b/dist/antigravity/webperf-loading/scripts/Cache-Strategy-Analysis.js @@ -221,36 +221,43 @@ const strategy = entry.cacheStrategy; const type = entry.type; if (isStaticAsset(type) && cc["no-store"]) entry.antiPatterns.push({ + code: "static-no-store", id: "static-no-store", severity: "error", message: `${type} asset with no-store; static assets should be cacheable` }); if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) entry.antiPatterns.push({ + code: "versioned-short-cache", id: "versioned-short-cache", severity: "warning", message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable` }); if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) entry.antiPatterns.push({ + code: "versioned-not-immutable", id: "versioned-not-immutable", severity: "info", message: `Versioned URL (hash detected) without Cache-Control: immutable` }); if (entry.decodedBodySize > 100 * 1024 && [ "none", "no-store", "no-cache" ].includes(strategy)) entry.antiPatterns.push({ + code: "large-no-cache", id: "large-no-cache", severity: "error", message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache` }); if (entry.headers["expires"] && !entry.headers["cache-control"]) entry.antiPatterns.push({ + code: "expires-without-cc", id: "expires-without-cc", severity: "warning", message: `Uses Expires header without Cache-Control (outdated pattern)` }); if (isStaticAsset(type) && maxAge === 0) entry.antiPatterns.push({ + code: "maxage-zero-static", id: "maxage-zero-static", severity: "warning", message: `Static asset with max-age=0, which forces revalidation on every request` }); if (isStaticAsset(type) && maxAge !== null && maxAge > 0 && maxAge < 3600 && !cc["no-store"]) entry.antiPatterns.push({ + code: "short-cache-static", id: "short-cache-static", severity: "warning", message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration` @@ -284,11 +291,13 @@ const costlyDuplicates = uniqueDuplicates.filter(d => d.costly); const cachedDuplicates = uniqueDuplicates.filter(d => !d.costly); for (const entry of uncompressedResources) entry.antiPatterns.push({ + code: "uncompressed", id: "uncompressed", severity: "warning", message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip` }); for (const duplicate of costlyDuplicates) entries.find(e => e.url === duplicate.url).antiPatterns.push({ + code: "duplicate-uncached", id: "duplicate-uncached", severity: "warning", message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable` @@ -324,6 +333,7 @@ const topHosts = [ ...hostCounts.entries() ].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([host]) => host).join(", "); coverageIssue = { severity: "info", + code: "header-coverage", id: "header-coverage", 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` }; @@ -421,10 +431,12 @@ antiPatterns: e.antiPatterns.map(p => p.id) })), issues: [ ...allAntiPatterns.map(p => ({ + code: p.code, severity: p.severity, id: p.id, resource: p.resource, - message: p.message + message: p.message, + target: p.url })), ...coverageIssue ? [ coverageIssue ] : [] ], recommendations: recommendations }; diff --git a/dist/antigravity/webperf-loading/scripts/Client-Side-Redirect-Detection.js b/dist/antigravity/webperf-loading/scripts/Client-Side-Redirect-Detection.js index c75cf3c8..eef9e3e4 100644 --- a/dist/antigravity/webperf-loading/scripts/Client-Side-Redirect-Detection.js +++ b/dist/antigravity/webperf-loading/scripts/Client-Side-Redirect-Detection.js @@ -40,6 +40,7 @@ if (sameOrigin && referrerPath !== currentPath && referrerPath !== "") { hasClientRedirect = true; indicators.push({ + code: "same-origin-navigation", type: "Same-origin navigation", from: referrerPath, to: currentPath, @@ -72,6 +73,7 @@ const domContentLoaded = navEntry.domContentLoadedEventEnd - navEntry.startTime; const responseSize = navEntry.transferSize || navEntry.encodedBodySize || 0; if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) indicators.push({ + code: "fast-minimal-navigation", type: "Fast minimal-content navigation", duration: domContentLoaded.toFixed(1) + "ms", size: (responseSize / 1024).toFixed(1) + " KB", @@ -122,12 +124,15 @@ referrerPath: referrerPath }, issues: [ ...serverRedirects > 0 ? [ { + code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` } ] : [], ...documentNavigations.length > 0 ? [ { + code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` } ] : [], ...indicators.filter(i => i.severity === "warning").map(i => ({ + code: i.code, severity: "warning", message: i.type })) ] diff --git a/dist/antigravity/webperf-loading/scripts/Compression-Audit.js b/dist/antigravity/webperf-loading/scripts/Compression-Audit.js index ae02dbef..8548481f 100644 --- a/dist/antigravity/webperf-loading/scripts/Compression-Audit.js +++ b/dist/antigravity/webperf-loading/scripts/Compression-Audit.js @@ -107,14 +107,17 @@ const corsLimitedAnalysis = sizeUnknownCount > 0; const issues = []; if (uncompressed.length > 0) issues.push({ + code: "uncompressed", severity: "warning", message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}` }); if (corsLimitedAnalysis) issues.push({ + code: "unknown-size", severity: "info", message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown` }); if (!contentEncodingSupported && candidates.length > 0) issues.push({ + code: "content-encoding-unavailable", severity: "info", message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize" }); diff --git a/dist/antigravity/webperf-loading/scripts/Prefetch-Resource-Validation.js b/dist/antigravity/webperf-loading/scripts/Prefetch-Resource-Validation.js index 1ed8c0d1..1a808af6 100644 --- a/dist/antigravity/webperf-loading/scripts/Prefetch-Resource-Validation.js +++ b/dist/antigravity/webperf-loading/scripts/Prefetch-Resource-Validation.js @@ -10,6 +10,7 @@ count: 0, items: [], issues: [ { + code: "no-prefetch", severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' } ] @@ -62,15 +63,18 @@ warnings: [] }; if (as === "unknown") analysis.warnings.push({ + code: "missing-as", type: "missing-as", severity: "warning", message: "Missing 'as' attribute - browser cannot apply correct MIME type matching" }); else if (!validAsValues.has(as)) analysis.warnings.push({ + code: "invalid-as", type: "invalid-as", severity: "warning", message: `Invalid 'as' value: "${as}" - should be one of: ${Array.from(validAsValues).join(", ")}` }); if (seenUrls.has(normalizedUrl)) analysis.warnings.push({ + code: "duplicate-prefetch", type: "duplicate-prefetch", severity: "warning", message: "Duplicate prefetch - this URL is prefetched multiple times" @@ -88,6 +92,7 @@ if (isCurrentPageResource) { analysis.isCurrentPage = true; analysis.warnings.push({ + code: "used-on-current-page", type: "wrong-hint", severity: "error", message: "Resource used on current page - should use preload instead" @@ -96,6 +101,7 @@ if (analysis.size > THRESHOLDS.largeFileSize) { const sizeMB = (analysis.size / (1024 * 1024)).toFixed(2); analysis.warnings.push({ + code: "large-file", type: "large-file", severity: "warning", message: `Large file (${sizeMB}MB) - consider if prefetch is appropriate` @@ -114,6 +120,7 @@ inappropriateReason = `Script is ${(analysis.size / 1024).toFixed(0)}KB (>1MB threshold)`; } if (isInappropriate) analysis.warnings.push({ + code: "inappropriate-type", type: "inappropriate-type", severity: "warning", message: `Type "${as}" may not be suitable for prefetch: ${inappropriateReason}` @@ -127,6 +134,7 @@ }); const countIssues = []; if (prefetchLinks.length > THRESHOLDS.maxCount) countIssues.push({ + code: "too-many-prefetches", type: "excessive-count", severity: "warning", message: `${prefetchLinks.length} prefetch hints found (recommended: <${THRESHOLDS.maxCount})`, @@ -135,11 +143,13 @@ const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2); (totalTransferSize / (1024 * 1024)).toFixed(2); if (totalSize > THRESHOLDS.totalSizeCritical) countIssues.push({ + code: "large-total-size", type: "excessive-size", severity: "error", message: `Total prefetch size: ${totalSizeMB}MB (critical threshold exceeded)`, explanation: "This is very high and will significantly impact mobile users" }); else if (totalSize > THRESHOLDS.totalSizeWarning) countIssues.push({ + code: "large-total-size", type: "high-size", severity: "warning", message: `Total prefetch size: ${totalSizeMB}MB (warning threshold exceeded)`, @@ -219,11 +229,14 @@ isCurrentPage: item.isCurrentPage || false })), issues: [ ...countIssues.map(i => ({ + code: i.code, severity: i.severity === "error" ? "error" : "warning", message: i.message })), ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ + code: w.code, severity: w.severity, - message: `${item.shortUrl}: ${w.message}` + message: `${item.shortUrl}: ${w.message}`, + target: item.href }))) ] }; })(); diff --git a/dist/antigravity/webperf-loading/scripts/Priority-Hints-Audit.js b/dist/antigravity/webperf-loading/scripts/Priority-Hints-Audit.js index 2fb8d176..79bd2e33 100644 --- a/dist/antigravity/webperf-loading/scripts/Priority-Hints-Audit.js +++ b/dist/antigravity/webperf-loading/scripts/Priority-Hints-Audit.js @@ -47,8 +47,10 @@ const issues = []; preloadsWithPriority.filter(link => link.getAttribute("fetchpriority") === "low").forEach(link => { issues.push({ + code: "preload-low-priority", severity: "error", element: link, + url: link.href, resource: shortUrl(link), message: 'preload + fetchpriority="low" is contradictory', detail: 'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.', @@ -58,8 +60,10 @@ allElements.filter(el => el.getAttribute("fetchpriority") === "low").forEach(el => { const entry = getEntry(el); if (entry && entry.startTime < 500) issues.push({ + code: "low-priority-loaded-early", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="low" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: "This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.", @@ -69,8 +73,10 @@ allElements.filter(el => el.getAttribute("fetchpriority") === "high").forEach(el => { const entry = getEntry(el); if (entry && entry.startTime > 3000) issues.push({ + code: "high-priority-loaded-late", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="high" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: "High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.", @@ -80,8 +86,10 @@ if (lcpCandidate && lcpCandidate.getAttribute("fetchpriority") !== "high") { const fp = lcpCandidate.getAttribute("fetchpriority"); issues.push({ + code: "lcp-no-fetchpriority", severity: "warning", element: lcpCandidate, + url: lcpCandidate.currentSrc || lcpCandidate.src, resource: (lcpCandidate.currentSrc || lcpCandidate.src).split("/").pop()?.split("?")[0] || "LCP image", message: `LCP candidate image ${fp ? `has fetchpriority="${fp}"` : "has no fetchpriority set"}`, detail: 'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".', @@ -134,8 +142,12 @@ resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "" })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, - message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message + message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message, + ...i.url ? { + target: i.url + } : {} })) }; })(); diff --git a/dist/antigravity/webperf-loading/scripts/Resource-Hints-Validation.js b/dist/antigravity/webperf-loading/scripts/Resource-Hints-Validation.js index 3e615245..00e58b09 100644 --- a/dist/antigravity/webperf-loading/scripts/Resource-Hints-Validation.js +++ b/dist/antigravity/webperf-loading/scripts/Resource-Hints-Validation.js @@ -253,12 +253,15 @@ recommendedHint: "preconnect" })) ].slice(0, 50), issues: [ ...totalIssues > 0 ? [ { + code: "unused-hints", severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` } ] : [], ...missingHints.length > 0 ? [ { + code: "missing-connection-hints", severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` } ] : [], ...redundantDomains.length > 0 ? [ { + code: "redundant-hints", severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` } ] : [] ] diff --git a/dist/antigravity/webperf-loading/scripts/Resource-Hints.js b/dist/antigravity/webperf-loading/scripts/Resource-Hints.js index fd3cc55c..9f231817 100644 --- a/dist/antigravity/webperf-loading/scripts/Resource-Hints.js +++ b/dist/antigravity/webperf-loading/scripts/Resource-Hints.js @@ -66,12 +66,14 @@ allHints.push(hint); if (type.requiresAs && !hint.as) issues.push({ hint: hint, + code: "preload-missing-as", issue: "Missing 'as' attribute (required for preload)", severity: "error", fix: `Add as="script|style|font|image|fetch"` }); if (hint.as === "font" && !hint.crossorigin) issues.push({ hint: hint, + code: "font-preload-no-crossorigin", issue: "Font preload without crossorigin (causes double fetch)", severity: "error", fix: 'Add crossorigin="anonymous"' @@ -80,6 +82,7 @@ const corsResources = performance.getEntriesByType("resource").filter(e => !isOwnRequest(e)).filter(r => r.name.includes(hostname) && (r.initiatorType === "script" || r.initiatorType === "css" || r.initiatorType === "font")); if (corsResources.length > 0) issues.push({ hint: hint, + code: "preconnect-no-crossorigin", issue: "Preconnect may need crossorigin for CORS resources", severity: "warning", fix: 'Consider adding crossorigin="anonymous"' @@ -93,6 +96,7 @@ const wasUsed = loadedResources.some(r => r.name === preload.href); if (!wasUsed) issues.push({ hint: preload, + code: "unused-preload", issue: "Preloaded resource not used within page load", severity: "warning", fix: "Remove unused preload or verify it's needed" @@ -109,6 +113,7 @@ }); if (!hasResources && pc.hostname) issues.push({ hint: pc, + code: "unused-preconnect", issue: `No resources loaded from ${pc.hostname}`, severity: "warning", fix: "Remove unused preconnect or verify it's needed for future resources" @@ -119,6 +124,7 @@ rel: "preload", href: `${preloads.length} preloads` }, + code: "too-many-preloads", issue: `Too many preloads (${preloads.length}) - they compete for bandwidth`, severity: "warning", fix: "Limit to 5-6 most critical resources" @@ -253,8 +259,12 @@ fetchpriority: h.fetchpriority })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, - message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` + message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}`, + ...i.hint.element ? { + target: i.hint.href + } : {} })) }; })(); diff --git a/dist/antigravity/webperf-loading/scripts/Server-Timing-Early-Hints.js b/dist/antigravity/webperf-loading/scripts/Server-Timing-Early-Hints.js index 0709af21..bd270c0a 100644 --- a/dist/antigravity/webperf-loading/scripts/Server-Timing-Early-Hints.js +++ b/dist/antigravity/webperf-loading/scripts/Server-Timing-Early-Hints.js @@ -55,14 +55,17 @@ }; const issues = []; if (nav.serverTiming.length === 0) issues.push({ + code: "no-server-timing", severity: "info", message: "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." }); if (corsRestrictedCount > 0) issues.push({ + code: "timing-hidden", severity: "info", 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.` }); if (!earlyHintsReceived) issues.push({ + code: "no-early-hints", severity: "info", message: "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." }); diff --git a/dist/antigravity/webperf-loading/scripts/Service-Worker-Analysis.js b/dist/antigravity/webperf-loading/scripts/Service-Worker-Analysis.js index 48ea3a7b..746a7921 100644 --- a/dist/antigravity/webperf-loading/scripts/Service-Worker-Analysis.js +++ b/dist/antigravity/webperf-loading/scripts/Service-Worker-Analysis.js @@ -11,6 +11,7 @@ details: {}, items: [], issues: [ { + code: "unsupported", severity: "error", message: "Service Workers not supported in this browser" } ] @@ -78,38 +79,49 @@ }); const issues = []; if (registrations.length === 0) issues.push({ + code: "no-service-worker", severity: "info", message: "No Service Workers registered" }); if (registrations.length > 0 && !controller) issues.push({ + code: "not-controlled", severity: "warning", message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload." }); for (const item of items) { if (item.hasWaiting) issues.push({ + code: "update-pending", severity: "warning", - message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.` + message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`, + target: item.scope }); if (item.navigationPreloadEnabled === false) issues.push({ + code: "navigation-preload-disabled", severity: "info", - message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().` + message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`, + target: item.scope }); } if (swOverheadMs !== null && swOverheadMs > 100) issues.push({ + code: "slow-startup", severity: "error", message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.` }); else if (swOverheadMs !== null && swOverheadMs > 50) issues.push({ + code: "slow-startup", severity: "warning", message: `Moderate SW startup time (${swOverheadMs}ms)` }); if (cacheHitRate !== null && cacheHitRate < 50) issues.push({ + code: "low-cache-hit-rate", severity: "error", message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.` }); else if (cacheHitRate !== null && cacheHitRate < 80) issues.push({ + code: "low-cache-hit-rate", severity: "warning", message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.` }); if (unknownSource.length > 0) issues.push({ + code: "unknown-source", severity: "info", message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.` }); diff --git a/dist/antigravity/webperf-loading/scripts/Speculation-Rules-Inspector.js b/dist/antigravity/webperf-loading/scripts/Speculation-Rules-Inspector.js index d96824bd..54b4c6d2 100644 --- a/dist/antigravity/webperf-loading/scripts/Speculation-Rules-Inspector.js +++ b/dist/antigravity/webperf-loading/scripts/Speculation-Rules-Inspector.js @@ -26,6 +26,7 @@ parsed = JSON.parse(script.textContent); } catch (e) { issues.push({ + code: "invalid-json", severity: "error", message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.` }); @@ -51,10 +52,12 @@ }); const isPrerender = action === "prerender" || action === "prerender_until_script"; if (isPrerender && source === "list" && (eagerness === "immediate" || eagerness === "eager") && urls.length > EAGER_PRERENDER_LIMIT) issues.push({ + code: "eager-list-capped", severity: "warning", message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".` }); if (isPrerender && source === "document" && (eagerness === "immediate" || eagerness === "eager")) issues.push({ + code: "eager-document-rule", severity: "warning", message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".` }); @@ -62,17 +65,21 @@ } }); if (scripts.length === 0) issues.push({ + code: "no-rules", severity: "info", message: "No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript." }); else if (items.length === 0 && !issues.some(i => i.severity === "error")) issues.push({ + code: "no-prefetch-or-prerender", severity: "info", message: "Speculation rules scripts were found but contain no prefetch or prerender rules." }); if (!supported && scripts.length > 0) issues.push({ + code: "unsupported", severity: "info", message: "This browser does not support speculation rules, so the rules are ignored." }); if (activationStart > 0) issues.push({ + code: "prerendered", severity: "info", message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); diff --git a/dist/antigravity/webperf-loading/scripts/TTFB-Resources.js b/dist/antigravity/webperf-loading/scripts/TTFB-Resources.js index fb9dcc05..6f35e9d0 100644 --- a/dist/antigravity/webperf-loading/scripts/TTFB-Resources.js +++ b/dist/antigravity/webperf-loading/scripts/TTFB-Resources.js @@ -99,6 +99,7 @@ }, items: [], issues: [ { + code: "no-resources", severity: "info", message: "No resources with TTFB data available" } ] diff --git a/dist/antigravity/webperf-loading/scripts/TTFB-Sub-Parts.js b/dist/antigravity/webperf-loading/scripts/TTFB-Sub-Parts.js index fed045c6..dcd3b53f 100644 --- a/dist/antigravity/webperf-loading/scripts/TTFB-Sub-Parts.js +++ b/dist/antigravity/webperf-loading/scripts/TTFB-Sub-Parts.js @@ -110,6 +110,7 @@ const slowestMs = Math.round(slowestSync.duration); const share = ttfbValueSync > 0 ? Math.round(slowestSync.duration / ttfbValueSync * 100) : 0; issues.push({ + code: "slow-ttfb", severity: rating === "poor" ? "error" : "warning", message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.` }); diff --git a/dist/antigravity/webperf-media/references/schema.md b/dist/antigravity/webperf-media/references/schema.md index 988a08ea..975142c7 100644 --- a/dist/antigravity/webperf-media/references/schema.md +++ b/dist/antigravity/webperf-media/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/dist/antigravity/webperf-resources/references/schema.md b/dist/antigravity/webperf-resources/references/schema.md index 988a08ea..975142c7 100644 --- a/dist/antigravity/webperf-resources/references/schema.md +++ b/dist/antigravity/webperf-resources/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/dist/gemini/webperf-loading/scripts/Back-Forward-Cache.js b/dist/gemini/webperf-loading/scripts/Back-Forward-Cache.js index 6d82d0f1..10704bf2 100644 --- a/dist/gemini/webperf-loading/scripts/Back-Forward-Cache.js +++ b/dist/gemini/webperf-loading/scripts/Back-Forward-Cache.js @@ -49,6 +49,7 @@ const confirmed = results.notRestoredReasons ? flattenReasons(results.notRestoredReasons, results.notRestoredReasons.url || "main frame") : []; confirmed.forEach(({reason: reason, frame: frame}) => { issues.push({ + code: "not-restored", reason: `${reason} (${frame})`, severity: "high", source: "browser", @@ -56,6 +57,7 @@ }); }); if (window.onunload !== null && !confirmed.some(({reason: reason}) => reason === "unload-listener")) issues.push({ + code: "unload-handler", reason: "window.onunload handler set", severity: "high", source: "detected", @@ -68,6 +70,7 @@ }); const cacheControl = res.headers.get("cache-control") || ""; if (/no-store/i.test(cacheControl) && !confirmed.some(({reason: reason}) => reason === "response-cache-control-no-store")) issues.push({ + code: "cache-control-no-store", reason: "Cache-Control: no-store on the page response", severity: "medium", source: "detected", @@ -76,12 +79,14 @@ } catch {} const iframeCount = document.querySelectorAll("iframe").length; if (iframeCount > 0) issues.push({ + code: "iframes", reason: `${iframeCount} iframe(s) on the page`, severity: "info", source: "note", description: "A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation." }); if ("serviceWorker" in navigator && navigator.serviceWorker.controller) issues.push({ + code: "service-worker", reason: "Service Worker controls the page", severity: "info", source: "note", @@ -128,8 +133,12 @@ notRestoredReasons: results.notRestoredReasons }, issues: results.blockingReasons.map(i => ({ + code: i.code, severity: i.severity === "high" ? "error" : i.severity === "medium" ? "warning" : "info", - message: i.reason + message: i.reason, + ...i.code === "not-restored" ? { + target: i.reason + } : {} })) }); window.checkBfcache = async () => { diff --git a/dist/gemini/webperf-loading/scripts/Cache-Strategy-Analysis.js b/dist/gemini/webperf-loading/scripts/Cache-Strategy-Analysis.js index 8508f373..47cd5aad 100644 --- a/dist/gemini/webperf-loading/scripts/Cache-Strategy-Analysis.js +++ b/dist/gemini/webperf-loading/scripts/Cache-Strategy-Analysis.js @@ -221,36 +221,43 @@ const strategy = entry.cacheStrategy; const type = entry.type; if (isStaticAsset(type) && cc["no-store"]) entry.antiPatterns.push({ + code: "static-no-store", id: "static-no-store", severity: "error", message: `${type} asset with no-store; static assets should be cacheable` }); if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) entry.antiPatterns.push({ + code: "versioned-short-cache", id: "versioned-short-cache", severity: "warning", message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable` }); if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) entry.antiPatterns.push({ + code: "versioned-not-immutable", id: "versioned-not-immutable", severity: "info", message: `Versioned URL (hash detected) without Cache-Control: immutable` }); if (entry.decodedBodySize > 100 * 1024 && [ "none", "no-store", "no-cache" ].includes(strategy)) entry.antiPatterns.push({ + code: "large-no-cache", id: "large-no-cache", severity: "error", message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache` }); if (entry.headers["expires"] && !entry.headers["cache-control"]) entry.antiPatterns.push({ + code: "expires-without-cc", id: "expires-without-cc", severity: "warning", message: `Uses Expires header without Cache-Control (outdated pattern)` }); if (isStaticAsset(type) && maxAge === 0) entry.antiPatterns.push({ + code: "maxage-zero-static", id: "maxage-zero-static", severity: "warning", message: `Static asset with max-age=0, which forces revalidation on every request` }); if (isStaticAsset(type) && maxAge !== null && maxAge > 0 && maxAge < 3600 && !cc["no-store"]) entry.antiPatterns.push({ + code: "short-cache-static", id: "short-cache-static", severity: "warning", message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration` @@ -284,11 +291,13 @@ const costlyDuplicates = uniqueDuplicates.filter(d => d.costly); const cachedDuplicates = uniqueDuplicates.filter(d => !d.costly); for (const entry of uncompressedResources) entry.antiPatterns.push({ + code: "uncompressed", id: "uncompressed", severity: "warning", message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip` }); for (const duplicate of costlyDuplicates) entries.find(e => e.url === duplicate.url).antiPatterns.push({ + code: "duplicate-uncached", id: "duplicate-uncached", severity: "warning", message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable` @@ -324,6 +333,7 @@ const topHosts = [ ...hostCounts.entries() ].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([host]) => host).join(", "); coverageIssue = { severity: "info", + code: "header-coverage", id: "header-coverage", 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` }; @@ -421,10 +431,12 @@ antiPatterns: e.antiPatterns.map(p => p.id) })), issues: [ ...allAntiPatterns.map(p => ({ + code: p.code, severity: p.severity, id: p.id, resource: p.resource, - message: p.message + message: p.message, + target: p.url })), ...coverageIssue ? [ coverageIssue ] : [] ], recommendations: recommendations }; diff --git a/dist/gemini/webperf-loading/scripts/Client-Side-Redirect-Detection.js b/dist/gemini/webperf-loading/scripts/Client-Side-Redirect-Detection.js index c75cf3c8..eef9e3e4 100644 --- a/dist/gemini/webperf-loading/scripts/Client-Side-Redirect-Detection.js +++ b/dist/gemini/webperf-loading/scripts/Client-Side-Redirect-Detection.js @@ -40,6 +40,7 @@ if (sameOrigin && referrerPath !== currentPath && referrerPath !== "") { hasClientRedirect = true; indicators.push({ + code: "same-origin-navigation", type: "Same-origin navigation", from: referrerPath, to: currentPath, @@ -72,6 +73,7 @@ const domContentLoaded = navEntry.domContentLoadedEventEnd - navEntry.startTime; const responseSize = navEntry.transferSize || navEntry.encodedBodySize || 0; if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) indicators.push({ + code: "fast-minimal-navigation", type: "Fast minimal-content navigation", duration: domContentLoaded.toFixed(1) + "ms", size: (responseSize / 1024).toFixed(1) + " KB", @@ -122,12 +124,15 @@ referrerPath: referrerPath }, issues: [ ...serverRedirects > 0 ? [ { + code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` } ] : [], ...documentNavigations.length > 0 ? [ { + code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` } ] : [], ...indicators.filter(i => i.severity === "warning").map(i => ({ + code: i.code, severity: "warning", message: i.type })) ] diff --git a/dist/gemini/webperf-loading/scripts/Compression-Audit.js b/dist/gemini/webperf-loading/scripts/Compression-Audit.js index ae02dbef..8548481f 100644 --- a/dist/gemini/webperf-loading/scripts/Compression-Audit.js +++ b/dist/gemini/webperf-loading/scripts/Compression-Audit.js @@ -107,14 +107,17 @@ const corsLimitedAnalysis = sizeUnknownCount > 0; const issues = []; if (uncompressed.length > 0) issues.push({ + code: "uncompressed", severity: "warning", message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}` }); if (corsLimitedAnalysis) issues.push({ + code: "unknown-size", severity: "info", message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown` }); if (!contentEncodingSupported && candidates.length > 0) issues.push({ + code: "content-encoding-unavailable", severity: "info", message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize" }); diff --git a/dist/gemini/webperf-loading/scripts/Prefetch-Resource-Validation.js b/dist/gemini/webperf-loading/scripts/Prefetch-Resource-Validation.js index 1ed8c0d1..1a808af6 100644 --- a/dist/gemini/webperf-loading/scripts/Prefetch-Resource-Validation.js +++ b/dist/gemini/webperf-loading/scripts/Prefetch-Resource-Validation.js @@ -10,6 +10,7 @@ count: 0, items: [], issues: [ { + code: "no-prefetch", severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' } ] @@ -62,15 +63,18 @@ warnings: [] }; if (as === "unknown") analysis.warnings.push({ + code: "missing-as", type: "missing-as", severity: "warning", message: "Missing 'as' attribute - browser cannot apply correct MIME type matching" }); else if (!validAsValues.has(as)) analysis.warnings.push({ + code: "invalid-as", type: "invalid-as", severity: "warning", message: `Invalid 'as' value: "${as}" - should be one of: ${Array.from(validAsValues).join(", ")}` }); if (seenUrls.has(normalizedUrl)) analysis.warnings.push({ + code: "duplicate-prefetch", type: "duplicate-prefetch", severity: "warning", message: "Duplicate prefetch - this URL is prefetched multiple times" @@ -88,6 +92,7 @@ if (isCurrentPageResource) { analysis.isCurrentPage = true; analysis.warnings.push({ + code: "used-on-current-page", type: "wrong-hint", severity: "error", message: "Resource used on current page - should use preload instead" @@ -96,6 +101,7 @@ if (analysis.size > THRESHOLDS.largeFileSize) { const sizeMB = (analysis.size / (1024 * 1024)).toFixed(2); analysis.warnings.push({ + code: "large-file", type: "large-file", severity: "warning", message: `Large file (${sizeMB}MB) - consider if prefetch is appropriate` @@ -114,6 +120,7 @@ inappropriateReason = `Script is ${(analysis.size / 1024).toFixed(0)}KB (>1MB threshold)`; } if (isInappropriate) analysis.warnings.push({ + code: "inappropriate-type", type: "inappropriate-type", severity: "warning", message: `Type "${as}" may not be suitable for prefetch: ${inappropriateReason}` @@ -127,6 +134,7 @@ }); const countIssues = []; if (prefetchLinks.length > THRESHOLDS.maxCount) countIssues.push({ + code: "too-many-prefetches", type: "excessive-count", severity: "warning", message: `${prefetchLinks.length} prefetch hints found (recommended: <${THRESHOLDS.maxCount})`, @@ -135,11 +143,13 @@ const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2); (totalTransferSize / (1024 * 1024)).toFixed(2); if (totalSize > THRESHOLDS.totalSizeCritical) countIssues.push({ + code: "large-total-size", type: "excessive-size", severity: "error", message: `Total prefetch size: ${totalSizeMB}MB (critical threshold exceeded)`, explanation: "This is very high and will significantly impact mobile users" }); else if (totalSize > THRESHOLDS.totalSizeWarning) countIssues.push({ + code: "large-total-size", type: "high-size", severity: "warning", message: `Total prefetch size: ${totalSizeMB}MB (warning threshold exceeded)`, @@ -219,11 +229,14 @@ isCurrentPage: item.isCurrentPage || false })), issues: [ ...countIssues.map(i => ({ + code: i.code, severity: i.severity === "error" ? "error" : "warning", message: i.message })), ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ + code: w.code, severity: w.severity, - message: `${item.shortUrl}: ${w.message}` + message: `${item.shortUrl}: ${w.message}`, + target: item.href }))) ] }; })(); diff --git a/dist/gemini/webperf-loading/scripts/Priority-Hints-Audit.js b/dist/gemini/webperf-loading/scripts/Priority-Hints-Audit.js index 2fb8d176..79bd2e33 100644 --- a/dist/gemini/webperf-loading/scripts/Priority-Hints-Audit.js +++ b/dist/gemini/webperf-loading/scripts/Priority-Hints-Audit.js @@ -47,8 +47,10 @@ const issues = []; preloadsWithPriority.filter(link => link.getAttribute("fetchpriority") === "low").forEach(link => { issues.push({ + code: "preload-low-priority", severity: "error", element: link, + url: link.href, resource: shortUrl(link), message: 'preload + fetchpriority="low" is contradictory', detail: 'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.', @@ -58,8 +60,10 @@ allElements.filter(el => el.getAttribute("fetchpriority") === "low").forEach(el => { const entry = getEntry(el); if (entry && entry.startTime < 500) issues.push({ + code: "low-priority-loaded-early", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="low" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: "This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.", @@ -69,8 +73,10 @@ allElements.filter(el => el.getAttribute("fetchpriority") === "high").forEach(el => { const entry = getEntry(el); if (entry && entry.startTime > 3000) issues.push({ + code: "high-priority-loaded-late", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="high" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: "High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.", @@ -80,8 +86,10 @@ if (lcpCandidate && lcpCandidate.getAttribute("fetchpriority") !== "high") { const fp = lcpCandidate.getAttribute("fetchpriority"); issues.push({ + code: "lcp-no-fetchpriority", severity: "warning", element: lcpCandidate, + url: lcpCandidate.currentSrc || lcpCandidate.src, resource: (lcpCandidate.currentSrc || lcpCandidate.src).split("/").pop()?.split("?")[0] || "LCP image", message: `LCP candidate image ${fp ? `has fetchpriority="${fp}"` : "has no fetchpriority set"}`, detail: 'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".', @@ -134,8 +142,12 @@ resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "" })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, - message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message + message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message, + ...i.url ? { + target: i.url + } : {} })) }; })(); diff --git a/dist/gemini/webperf-loading/scripts/Resource-Hints-Validation.js b/dist/gemini/webperf-loading/scripts/Resource-Hints-Validation.js index 3e615245..00e58b09 100644 --- a/dist/gemini/webperf-loading/scripts/Resource-Hints-Validation.js +++ b/dist/gemini/webperf-loading/scripts/Resource-Hints-Validation.js @@ -253,12 +253,15 @@ recommendedHint: "preconnect" })) ].slice(0, 50), issues: [ ...totalIssues > 0 ? [ { + code: "unused-hints", severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` } ] : [], ...missingHints.length > 0 ? [ { + code: "missing-connection-hints", severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` } ] : [], ...redundantDomains.length > 0 ? [ { + code: "redundant-hints", severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` } ] : [] ] diff --git a/dist/gemini/webperf-loading/scripts/Resource-Hints.js b/dist/gemini/webperf-loading/scripts/Resource-Hints.js index fd3cc55c..9f231817 100644 --- a/dist/gemini/webperf-loading/scripts/Resource-Hints.js +++ b/dist/gemini/webperf-loading/scripts/Resource-Hints.js @@ -66,12 +66,14 @@ allHints.push(hint); if (type.requiresAs && !hint.as) issues.push({ hint: hint, + code: "preload-missing-as", issue: "Missing 'as' attribute (required for preload)", severity: "error", fix: `Add as="script|style|font|image|fetch"` }); if (hint.as === "font" && !hint.crossorigin) issues.push({ hint: hint, + code: "font-preload-no-crossorigin", issue: "Font preload without crossorigin (causes double fetch)", severity: "error", fix: 'Add crossorigin="anonymous"' @@ -80,6 +82,7 @@ const corsResources = performance.getEntriesByType("resource").filter(e => !isOwnRequest(e)).filter(r => r.name.includes(hostname) && (r.initiatorType === "script" || r.initiatorType === "css" || r.initiatorType === "font")); if (corsResources.length > 0) issues.push({ hint: hint, + code: "preconnect-no-crossorigin", issue: "Preconnect may need crossorigin for CORS resources", severity: "warning", fix: 'Consider adding crossorigin="anonymous"' @@ -93,6 +96,7 @@ const wasUsed = loadedResources.some(r => r.name === preload.href); if (!wasUsed) issues.push({ hint: preload, + code: "unused-preload", issue: "Preloaded resource not used within page load", severity: "warning", fix: "Remove unused preload or verify it's needed" @@ -109,6 +113,7 @@ }); if (!hasResources && pc.hostname) issues.push({ hint: pc, + code: "unused-preconnect", issue: `No resources loaded from ${pc.hostname}`, severity: "warning", fix: "Remove unused preconnect or verify it's needed for future resources" @@ -119,6 +124,7 @@ rel: "preload", href: `${preloads.length} preloads` }, + code: "too-many-preloads", issue: `Too many preloads (${preloads.length}) - they compete for bandwidth`, severity: "warning", fix: "Limit to 5-6 most critical resources" @@ -253,8 +259,12 @@ fetchpriority: h.fetchpriority })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, - message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` + message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}`, + ...i.hint.element ? { + target: i.hint.href + } : {} })) }; })(); diff --git a/dist/gemini/webperf-loading/scripts/Server-Timing-Early-Hints.js b/dist/gemini/webperf-loading/scripts/Server-Timing-Early-Hints.js index 0709af21..bd270c0a 100644 --- a/dist/gemini/webperf-loading/scripts/Server-Timing-Early-Hints.js +++ b/dist/gemini/webperf-loading/scripts/Server-Timing-Early-Hints.js @@ -55,14 +55,17 @@ }; const issues = []; if (nav.serverTiming.length === 0) issues.push({ + code: "no-server-timing", severity: "info", message: "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." }); if (corsRestrictedCount > 0) issues.push({ + code: "timing-hidden", severity: "info", 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.` }); if (!earlyHintsReceived) issues.push({ + code: "no-early-hints", severity: "info", message: "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." }); diff --git a/dist/gemini/webperf-loading/scripts/Service-Worker-Analysis.js b/dist/gemini/webperf-loading/scripts/Service-Worker-Analysis.js index 48ea3a7b..746a7921 100644 --- a/dist/gemini/webperf-loading/scripts/Service-Worker-Analysis.js +++ b/dist/gemini/webperf-loading/scripts/Service-Worker-Analysis.js @@ -11,6 +11,7 @@ details: {}, items: [], issues: [ { + code: "unsupported", severity: "error", message: "Service Workers not supported in this browser" } ] @@ -78,38 +79,49 @@ }); const issues = []; if (registrations.length === 0) issues.push({ + code: "no-service-worker", severity: "info", message: "No Service Workers registered" }); if (registrations.length > 0 && !controller) issues.push({ + code: "not-controlled", severity: "warning", message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload." }); for (const item of items) { if (item.hasWaiting) issues.push({ + code: "update-pending", severity: "warning", - message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.` + message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`, + target: item.scope }); if (item.navigationPreloadEnabled === false) issues.push({ + code: "navigation-preload-disabled", severity: "info", - message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().` + message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`, + target: item.scope }); } if (swOverheadMs !== null && swOverheadMs > 100) issues.push({ + code: "slow-startup", severity: "error", message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.` }); else if (swOverheadMs !== null && swOverheadMs > 50) issues.push({ + code: "slow-startup", severity: "warning", message: `Moderate SW startup time (${swOverheadMs}ms)` }); if (cacheHitRate !== null && cacheHitRate < 50) issues.push({ + code: "low-cache-hit-rate", severity: "error", message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.` }); else if (cacheHitRate !== null && cacheHitRate < 80) issues.push({ + code: "low-cache-hit-rate", severity: "warning", message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.` }); if (unknownSource.length > 0) issues.push({ + code: "unknown-source", severity: "info", message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.` }); diff --git a/dist/gemini/webperf-loading/scripts/Speculation-Rules-Inspector.js b/dist/gemini/webperf-loading/scripts/Speculation-Rules-Inspector.js index d96824bd..54b4c6d2 100644 --- a/dist/gemini/webperf-loading/scripts/Speculation-Rules-Inspector.js +++ b/dist/gemini/webperf-loading/scripts/Speculation-Rules-Inspector.js @@ -26,6 +26,7 @@ parsed = JSON.parse(script.textContent); } catch (e) { issues.push({ + code: "invalid-json", severity: "error", message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.` }); @@ -51,10 +52,12 @@ }); const isPrerender = action === "prerender" || action === "prerender_until_script"; if (isPrerender && source === "list" && (eagerness === "immediate" || eagerness === "eager") && urls.length > EAGER_PRERENDER_LIMIT) issues.push({ + code: "eager-list-capped", severity: "warning", message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".` }); if (isPrerender && source === "document" && (eagerness === "immediate" || eagerness === "eager")) issues.push({ + code: "eager-document-rule", severity: "warning", message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".` }); @@ -62,17 +65,21 @@ } }); if (scripts.length === 0) issues.push({ + code: "no-rules", severity: "info", message: "No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript." }); else if (items.length === 0 && !issues.some(i => i.severity === "error")) issues.push({ + code: "no-prefetch-or-prerender", severity: "info", message: "Speculation rules scripts were found but contain no prefetch or prerender rules." }); if (!supported && scripts.length > 0) issues.push({ + code: "unsupported", severity: "info", message: "This browser does not support speculation rules, so the rules are ignored." }); if (activationStart > 0) issues.push({ + code: "prerendered", severity: "info", message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); diff --git a/dist/gemini/webperf-loading/scripts/TTFB-Resources.js b/dist/gemini/webperf-loading/scripts/TTFB-Resources.js index fb9dcc05..6f35e9d0 100644 --- a/dist/gemini/webperf-loading/scripts/TTFB-Resources.js +++ b/dist/gemini/webperf-loading/scripts/TTFB-Resources.js @@ -99,6 +99,7 @@ }, items: [], issues: [ { + code: "no-resources", severity: "info", message: "No resources with TTFB data available" } ] diff --git a/dist/gemini/webperf-loading/scripts/TTFB-Sub-Parts.js b/dist/gemini/webperf-loading/scripts/TTFB-Sub-Parts.js index fed045c6..dcd3b53f 100644 --- a/dist/gemini/webperf-loading/scripts/TTFB-Sub-Parts.js +++ b/dist/gemini/webperf-loading/scripts/TTFB-Sub-Parts.js @@ -110,6 +110,7 @@ const slowestMs = Math.round(slowestSync.duration); const share = ttfbValueSync > 0 ? Math.round(slowestSync.duration / ttfbValueSync * 100) : 0; issues.push({ + code: "slow-ttfb", severity: rating === "poor" ? "error" : "warning", message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.` }); diff --git a/dist/webperf-loading/Back-Forward-Cache.js b/dist/webperf-loading/Back-Forward-Cache.js index 6d82d0f1..10704bf2 100644 --- a/dist/webperf-loading/Back-Forward-Cache.js +++ b/dist/webperf-loading/Back-Forward-Cache.js @@ -49,6 +49,7 @@ const confirmed = results.notRestoredReasons ? flattenReasons(results.notRestoredReasons, results.notRestoredReasons.url || "main frame") : []; confirmed.forEach(({reason: reason, frame: frame}) => { issues.push({ + code: "not-restored", reason: `${reason} (${frame})`, severity: "high", source: "browser", @@ -56,6 +57,7 @@ }); }); if (window.onunload !== null && !confirmed.some(({reason: reason}) => reason === "unload-listener")) issues.push({ + code: "unload-handler", reason: "window.onunload handler set", severity: "high", source: "detected", @@ -68,6 +70,7 @@ }); const cacheControl = res.headers.get("cache-control") || ""; if (/no-store/i.test(cacheControl) && !confirmed.some(({reason: reason}) => reason === "response-cache-control-no-store")) issues.push({ + code: "cache-control-no-store", reason: "Cache-Control: no-store on the page response", severity: "medium", source: "detected", @@ -76,12 +79,14 @@ } catch {} const iframeCount = document.querySelectorAll("iframe").length; if (iframeCount > 0) issues.push({ + code: "iframes", reason: `${iframeCount} iframe(s) on the page`, severity: "info", source: "note", description: "A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation." }); if ("serviceWorker" in navigator && navigator.serviceWorker.controller) issues.push({ + code: "service-worker", reason: "Service Worker controls the page", severity: "info", source: "note", @@ -128,8 +133,12 @@ notRestoredReasons: results.notRestoredReasons }, issues: results.blockingReasons.map(i => ({ + code: i.code, severity: i.severity === "high" ? "error" : i.severity === "medium" ? "warning" : "info", - message: i.reason + message: i.reason, + ...i.code === "not-restored" ? { + target: i.reason + } : {} })) }); window.checkBfcache = async () => { diff --git a/dist/webperf-loading/Cache-Strategy-Analysis.js b/dist/webperf-loading/Cache-Strategy-Analysis.js index 8508f373..47cd5aad 100644 --- a/dist/webperf-loading/Cache-Strategy-Analysis.js +++ b/dist/webperf-loading/Cache-Strategy-Analysis.js @@ -221,36 +221,43 @@ const strategy = entry.cacheStrategy; const type = entry.type; if (isStaticAsset(type) && cc["no-store"]) entry.antiPatterns.push({ + code: "static-no-store", id: "static-no-store", severity: "error", message: `${type} asset with no-store; static assets should be cacheable` }); if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) entry.antiPatterns.push({ + code: "versioned-short-cache", id: "versioned-short-cache", severity: "warning", message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable` }); if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) entry.antiPatterns.push({ + code: "versioned-not-immutable", id: "versioned-not-immutable", severity: "info", message: `Versioned URL (hash detected) without Cache-Control: immutable` }); if (entry.decodedBodySize > 100 * 1024 && [ "none", "no-store", "no-cache" ].includes(strategy)) entry.antiPatterns.push({ + code: "large-no-cache", id: "large-no-cache", severity: "error", message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache` }); if (entry.headers["expires"] && !entry.headers["cache-control"]) entry.antiPatterns.push({ + code: "expires-without-cc", id: "expires-without-cc", severity: "warning", message: `Uses Expires header without Cache-Control (outdated pattern)` }); if (isStaticAsset(type) && maxAge === 0) entry.antiPatterns.push({ + code: "maxage-zero-static", id: "maxage-zero-static", severity: "warning", message: `Static asset with max-age=0, which forces revalidation on every request` }); if (isStaticAsset(type) && maxAge !== null && maxAge > 0 && maxAge < 3600 && !cc["no-store"]) entry.antiPatterns.push({ + code: "short-cache-static", id: "short-cache-static", severity: "warning", message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration` @@ -284,11 +291,13 @@ const costlyDuplicates = uniqueDuplicates.filter(d => d.costly); const cachedDuplicates = uniqueDuplicates.filter(d => !d.costly); for (const entry of uncompressedResources) entry.antiPatterns.push({ + code: "uncompressed", id: "uncompressed", severity: "warning", message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip` }); for (const duplicate of costlyDuplicates) entries.find(e => e.url === duplicate.url).antiPatterns.push({ + code: "duplicate-uncached", id: "duplicate-uncached", severity: "warning", message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable` @@ -324,6 +333,7 @@ const topHosts = [ ...hostCounts.entries() ].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([host]) => host).join(", "); coverageIssue = { severity: "info", + code: "header-coverage", id: "header-coverage", 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` }; @@ -421,10 +431,12 @@ antiPatterns: e.antiPatterns.map(p => p.id) })), issues: [ ...allAntiPatterns.map(p => ({ + code: p.code, severity: p.severity, id: p.id, resource: p.resource, - message: p.message + message: p.message, + target: p.url })), ...coverageIssue ? [ coverageIssue ] : [] ], recommendations: recommendations }; diff --git a/dist/webperf-loading/Client-Side-Redirect-Detection.js b/dist/webperf-loading/Client-Side-Redirect-Detection.js index c75cf3c8..eef9e3e4 100644 --- a/dist/webperf-loading/Client-Side-Redirect-Detection.js +++ b/dist/webperf-loading/Client-Side-Redirect-Detection.js @@ -40,6 +40,7 @@ if (sameOrigin && referrerPath !== currentPath && referrerPath !== "") { hasClientRedirect = true; indicators.push({ + code: "same-origin-navigation", type: "Same-origin navigation", from: referrerPath, to: currentPath, @@ -72,6 +73,7 @@ const domContentLoaded = navEntry.domContentLoadedEventEnd - navEntry.startTime; const responseSize = navEntry.transferSize || navEntry.encodedBodySize || 0; if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) indicators.push({ + code: "fast-minimal-navigation", type: "Fast minimal-content navigation", duration: domContentLoaded.toFixed(1) + "ms", size: (responseSize / 1024).toFixed(1) + " KB", @@ -122,12 +124,15 @@ referrerPath: referrerPath }, issues: [ ...serverRedirects > 0 ? [ { + code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` } ] : [], ...documentNavigations.length > 0 ? [ { + code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` } ] : [], ...indicators.filter(i => i.severity === "warning").map(i => ({ + code: i.code, severity: "warning", message: i.type })) ] diff --git a/dist/webperf-loading/Compression-Audit.js b/dist/webperf-loading/Compression-Audit.js index ae02dbef..8548481f 100644 --- a/dist/webperf-loading/Compression-Audit.js +++ b/dist/webperf-loading/Compression-Audit.js @@ -107,14 +107,17 @@ const corsLimitedAnalysis = sizeUnknownCount > 0; const issues = []; if (uncompressed.length > 0) issues.push({ + code: "uncompressed", severity: "warning", message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}` }); if (corsLimitedAnalysis) issues.push({ + code: "unknown-size", severity: "info", message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown` }); if (!contentEncodingSupported && candidates.length > 0) issues.push({ + code: "content-encoding-unavailable", severity: "info", message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize" }); diff --git a/dist/webperf-loading/Prefetch-Resource-Validation.js b/dist/webperf-loading/Prefetch-Resource-Validation.js index 1ed8c0d1..1a808af6 100644 --- a/dist/webperf-loading/Prefetch-Resource-Validation.js +++ b/dist/webperf-loading/Prefetch-Resource-Validation.js @@ -10,6 +10,7 @@ count: 0, items: [], issues: [ { + code: "no-prefetch", severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' } ] @@ -62,15 +63,18 @@ warnings: [] }; if (as === "unknown") analysis.warnings.push({ + code: "missing-as", type: "missing-as", severity: "warning", message: "Missing 'as' attribute - browser cannot apply correct MIME type matching" }); else if (!validAsValues.has(as)) analysis.warnings.push({ + code: "invalid-as", type: "invalid-as", severity: "warning", message: `Invalid 'as' value: "${as}" - should be one of: ${Array.from(validAsValues).join(", ")}` }); if (seenUrls.has(normalizedUrl)) analysis.warnings.push({ + code: "duplicate-prefetch", type: "duplicate-prefetch", severity: "warning", message: "Duplicate prefetch - this URL is prefetched multiple times" @@ -88,6 +92,7 @@ if (isCurrentPageResource) { analysis.isCurrentPage = true; analysis.warnings.push({ + code: "used-on-current-page", type: "wrong-hint", severity: "error", message: "Resource used on current page - should use preload instead" @@ -96,6 +101,7 @@ if (analysis.size > THRESHOLDS.largeFileSize) { const sizeMB = (analysis.size / (1024 * 1024)).toFixed(2); analysis.warnings.push({ + code: "large-file", type: "large-file", severity: "warning", message: `Large file (${sizeMB}MB) - consider if prefetch is appropriate` @@ -114,6 +120,7 @@ inappropriateReason = `Script is ${(analysis.size / 1024).toFixed(0)}KB (>1MB threshold)`; } if (isInappropriate) analysis.warnings.push({ + code: "inappropriate-type", type: "inappropriate-type", severity: "warning", message: `Type "${as}" may not be suitable for prefetch: ${inappropriateReason}` @@ -127,6 +134,7 @@ }); const countIssues = []; if (prefetchLinks.length > THRESHOLDS.maxCount) countIssues.push({ + code: "too-many-prefetches", type: "excessive-count", severity: "warning", message: `${prefetchLinks.length} prefetch hints found (recommended: <${THRESHOLDS.maxCount})`, @@ -135,11 +143,13 @@ const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2); (totalTransferSize / (1024 * 1024)).toFixed(2); if (totalSize > THRESHOLDS.totalSizeCritical) countIssues.push({ + code: "large-total-size", type: "excessive-size", severity: "error", message: `Total prefetch size: ${totalSizeMB}MB (critical threshold exceeded)`, explanation: "This is very high and will significantly impact mobile users" }); else if (totalSize > THRESHOLDS.totalSizeWarning) countIssues.push({ + code: "large-total-size", type: "high-size", severity: "warning", message: `Total prefetch size: ${totalSizeMB}MB (warning threshold exceeded)`, @@ -219,11 +229,14 @@ isCurrentPage: item.isCurrentPage || false })), issues: [ ...countIssues.map(i => ({ + code: i.code, severity: i.severity === "error" ? "error" : "warning", message: i.message })), ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ + code: w.code, severity: w.severity, - message: `${item.shortUrl}: ${w.message}` + message: `${item.shortUrl}: ${w.message}`, + target: item.href }))) ] }; })(); diff --git a/dist/webperf-loading/Priority-Hints-Audit.js b/dist/webperf-loading/Priority-Hints-Audit.js index 2fb8d176..79bd2e33 100644 --- a/dist/webperf-loading/Priority-Hints-Audit.js +++ b/dist/webperf-loading/Priority-Hints-Audit.js @@ -47,8 +47,10 @@ const issues = []; preloadsWithPriority.filter(link => link.getAttribute("fetchpriority") === "low").forEach(link => { issues.push({ + code: "preload-low-priority", severity: "error", element: link, + url: link.href, resource: shortUrl(link), message: 'preload + fetchpriority="low" is contradictory', detail: 'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.', @@ -58,8 +60,10 @@ allElements.filter(el => el.getAttribute("fetchpriority") === "low").forEach(el => { const entry = getEntry(el); if (entry && entry.startTime < 500) issues.push({ + code: "low-priority-loaded-early", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="low" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: "This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.", @@ -69,8 +73,10 @@ allElements.filter(el => el.getAttribute("fetchpriority") === "high").forEach(el => { const entry = getEntry(el); if (entry && entry.startTime > 3000) issues.push({ + code: "high-priority-loaded-late", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="high" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: "High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.", @@ -80,8 +86,10 @@ if (lcpCandidate && lcpCandidate.getAttribute("fetchpriority") !== "high") { const fp = lcpCandidate.getAttribute("fetchpriority"); issues.push({ + code: "lcp-no-fetchpriority", severity: "warning", element: lcpCandidate, + url: lcpCandidate.currentSrc || lcpCandidate.src, resource: (lcpCandidate.currentSrc || lcpCandidate.src).split("/").pop()?.split("?")[0] || "LCP image", message: `LCP candidate image ${fp ? `has fetchpriority="${fp}"` : "has no fetchpriority set"}`, detail: 'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".', @@ -134,8 +142,12 @@ resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "" })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, - message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message + message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message, + ...i.url ? { + target: i.url + } : {} })) }; })(); diff --git a/dist/webperf-loading/Resource-Hints-Validation.js b/dist/webperf-loading/Resource-Hints-Validation.js index 3e615245..00e58b09 100644 --- a/dist/webperf-loading/Resource-Hints-Validation.js +++ b/dist/webperf-loading/Resource-Hints-Validation.js @@ -253,12 +253,15 @@ recommendedHint: "preconnect" })) ].slice(0, 50), issues: [ ...totalIssues > 0 ? [ { + code: "unused-hints", severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` } ] : [], ...missingHints.length > 0 ? [ { + code: "missing-connection-hints", severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` } ] : [], ...redundantDomains.length > 0 ? [ { + code: "redundant-hints", severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` } ] : [] ] diff --git a/dist/webperf-loading/Resource-Hints.js b/dist/webperf-loading/Resource-Hints.js index fd3cc55c..9f231817 100644 --- a/dist/webperf-loading/Resource-Hints.js +++ b/dist/webperf-loading/Resource-Hints.js @@ -66,12 +66,14 @@ allHints.push(hint); if (type.requiresAs && !hint.as) issues.push({ hint: hint, + code: "preload-missing-as", issue: "Missing 'as' attribute (required for preload)", severity: "error", fix: `Add as="script|style|font|image|fetch"` }); if (hint.as === "font" && !hint.crossorigin) issues.push({ hint: hint, + code: "font-preload-no-crossorigin", issue: "Font preload without crossorigin (causes double fetch)", severity: "error", fix: 'Add crossorigin="anonymous"' @@ -80,6 +82,7 @@ const corsResources = performance.getEntriesByType("resource").filter(e => !isOwnRequest(e)).filter(r => r.name.includes(hostname) && (r.initiatorType === "script" || r.initiatorType === "css" || r.initiatorType === "font")); if (corsResources.length > 0) issues.push({ hint: hint, + code: "preconnect-no-crossorigin", issue: "Preconnect may need crossorigin for CORS resources", severity: "warning", fix: 'Consider adding crossorigin="anonymous"' @@ -93,6 +96,7 @@ const wasUsed = loadedResources.some(r => r.name === preload.href); if (!wasUsed) issues.push({ hint: preload, + code: "unused-preload", issue: "Preloaded resource not used within page load", severity: "warning", fix: "Remove unused preload or verify it's needed" @@ -109,6 +113,7 @@ }); if (!hasResources && pc.hostname) issues.push({ hint: pc, + code: "unused-preconnect", issue: `No resources loaded from ${pc.hostname}`, severity: "warning", fix: "Remove unused preconnect or verify it's needed for future resources" @@ -119,6 +124,7 @@ rel: "preload", href: `${preloads.length} preloads` }, + code: "too-many-preloads", issue: `Too many preloads (${preloads.length}) - they compete for bandwidth`, severity: "warning", fix: "Limit to 5-6 most critical resources" @@ -253,8 +259,12 @@ fetchpriority: h.fetchpriority })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, - message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` + message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}`, + ...i.hint.element ? { + target: i.hint.href + } : {} })) }; })(); diff --git a/dist/webperf-loading/Server-Timing-Early-Hints.js b/dist/webperf-loading/Server-Timing-Early-Hints.js index 0709af21..bd270c0a 100644 --- a/dist/webperf-loading/Server-Timing-Early-Hints.js +++ b/dist/webperf-loading/Server-Timing-Early-Hints.js @@ -55,14 +55,17 @@ }; const issues = []; if (nav.serverTiming.length === 0) issues.push({ + code: "no-server-timing", severity: "info", message: "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." }); if (corsRestrictedCount > 0) issues.push({ + code: "timing-hidden", severity: "info", 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.` }); if (!earlyHintsReceived) issues.push({ + code: "no-early-hints", severity: "info", message: "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." }); diff --git a/dist/webperf-loading/Service-Worker-Analysis.js b/dist/webperf-loading/Service-Worker-Analysis.js index 48ea3a7b..746a7921 100644 --- a/dist/webperf-loading/Service-Worker-Analysis.js +++ b/dist/webperf-loading/Service-Worker-Analysis.js @@ -11,6 +11,7 @@ details: {}, items: [], issues: [ { + code: "unsupported", severity: "error", message: "Service Workers not supported in this browser" } ] @@ -78,38 +79,49 @@ }); const issues = []; if (registrations.length === 0) issues.push({ + code: "no-service-worker", severity: "info", message: "No Service Workers registered" }); if (registrations.length > 0 && !controller) issues.push({ + code: "not-controlled", severity: "warning", message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload." }); for (const item of items) { if (item.hasWaiting) issues.push({ + code: "update-pending", severity: "warning", - message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.` + message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`, + target: item.scope }); if (item.navigationPreloadEnabled === false) issues.push({ + code: "navigation-preload-disabled", severity: "info", - message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().` + message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`, + target: item.scope }); } if (swOverheadMs !== null && swOverheadMs > 100) issues.push({ + code: "slow-startup", severity: "error", message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.` }); else if (swOverheadMs !== null && swOverheadMs > 50) issues.push({ + code: "slow-startup", severity: "warning", message: `Moderate SW startup time (${swOverheadMs}ms)` }); if (cacheHitRate !== null && cacheHitRate < 50) issues.push({ + code: "low-cache-hit-rate", severity: "error", message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.` }); else if (cacheHitRate !== null && cacheHitRate < 80) issues.push({ + code: "low-cache-hit-rate", severity: "warning", message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.` }); if (unknownSource.length > 0) issues.push({ + code: "unknown-source", severity: "info", message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.` }); diff --git a/dist/webperf-loading/Speculation-Rules-Inspector.js b/dist/webperf-loading/Speculation-Rules-Inspector.js index d96824bd..54b4c6d2 100644 --- a/dist/webperf-loading/Speculation-Rules-Inspector.js +++ b/dist/webperf-loading/Speculation-Rules-Inspector.js @@ -26,6 +26,7 @@ parsed = JSON.parse(script.textContent); } catch (e) { issues.push({ + code: "invalid-json", severity: "error", message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.` }); @@ -51,10 +52,12 @@ }); const isPrerender = action === "prerender" || action === "prerender_until_script"; if (isPrerender && source === "list" && (eagerness === "immediate" || eagerness === "eager") && urls.length > EAGER_PRERENDER_LIMIT) issues.push({ + code: "eager-list-capped", severity: "warning", message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".` }); if (isPrerender && source === "document" && (eagerness === "immediate" || eagerness === "eager")) issues.push({ + code: "eager-document-rule", severity: "warning", message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".` }); @@ -62,17 +65,21 @@ } }); if (scripts.length === 0) issues.push({ + code: "no-rules", severity: "info", message: "No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript." }); else if (items.length === 0 && !issues.some(i => i.severity === "error")) issues.push({ + code: "no-prefetch-or-prerender", severity: "info", message: "Speculation rules scripts were found but contain no prefetch or prerender rules." }); if (!supported && scripts.length > 0) issues.push({ + code: "unsupported", severity: "info", message: "This browser does not support speculation rules, so the rules are ignored." }); if (activationStart > 0) issues.push({ + code: "prerendered", severity: "info", message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); diff --git a/dist/webperf-loading/TTFB-Resources.js b/dist/webperf-loading/TTFB-Resources.js index fb9dcc05..6f35e9d0 100644 --- a/dist/webperf-loading/TTFB-Resources.js +++ b/dist/webperf-loading/TTFB-Resources.js @@ -99,6 +99,7 @@ }, items: [], issues: [ { + code: "no-resources", severity: "info", message: "No resources with TTFB data available" } ] diff --git a/dist/webperf-loading/TTFB-Sub-Parts.js b/dist/webperf-loading/TTFB-Sub-Parts.js index fed045c6..dcd3b53f 100644 --- a/dist/webperf-loading/TTFB-Sub-Parts.js +++ b/dist/webperf-loading/TTFB-Sub-Parts.js @@ -110,6 +110,7 @@ const slowestMs = Math.round(slowestSync.duration); const share = ttfbValueSync > 0 ? Math.round(slowestSync.duration / ttfbValueSync * 100) : 0; issues.push({ + code: "slow-ttfb", severity: rating === "poor" ? "error" : "warning", message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.` }); diff --git a/public/llms-full.txt b/public/llms-full.txt index 066a97bb..d6419cb7 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -2029,6 +2029,7 @@ Analyzes Back/Forward Cache (bfcache) to determine if your page is eligible for : []; confirmed.forEach(({ reason, frame }) => { issues.push({ + code: 'not-restored', reason: `${reason} (${frame})`, severity: 'high', source: 'browser', @@ -2040,6 +2041,7 @@ Analyzes Back/Forward Cache (bfcache) to determine if your page is eligible for // cannot be detected from JavaScript. if (window.onunload !== null && !confirmed.some(({ reason }) => reason === 'unload-listener')) { issues.push({ + code: 'unload-handler', reason: 'window.onunload handler set', severity: 'high', source: 'detected', @@ -2055,6 +2057,7 @@ Analyzes Back/Forward Cache (bfcache) to determine if your page is eligible for const cacheControl = res.headers.get('cache-control') || ''; if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) { issues.push({ + code: 'cache-control-no-store', reason: 'Cache-Control: no-store on the page response', severity: 'medium', source: 'detected', @@ -2070,6 +2073,7 @@ Analyzes Back/Forward Cache (bfcache) to determine if your page is eligible for const iframeCount = document.querySelectorAll('iframe').length; if (iframeCount > 0) { issues.push({ + code: 'iframes', reason: `${iframeCount} iframe(s) on the page`, severity: 'info', source: 'note', @@ -2079,6 +2083,7 @@ Analyzes Back/Forward Cache (bfcache) to determine if your page is eligible for if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { issues.push({ + code: 'service-worker', reason: 'Service Worker controls the page', severity: 'info', source: 'note', @@ -2202,8 +2207,10 @@ Analyzes Back/Forward Cache (bfcache) to determine if your page is eligible for notRestoredReasons: results.notRestoredReasons, }, issues: results.blockingReasons.map((i) => ({ + code: i.code, severity: i.severity === 'high' ? 'error' : i.severity === 'medium' ? 'warning' : 'info', message: i.reason, + ...(i.code === 'not-restored' ? { target: i.reason } : {}), })), }); @@ -2796,6 +2803,7 @@ Audits HTTP caching strategies across all page resources, identifying resources if (isStaticAsset(type) && cc["no-store"]) { entry.antiPatterns.push({ + code: "static-no-store", id: "static-no-store", severity: "error", message: `${type} asset with no-store; static assets should be cacheable`, @@ -2804,6 +2812,7 @@ Audits HTTP caching strategies across all page resources, identifying resources if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) { entry.antiPatterns.push({ + code: "versioned-short-cache", id: "versioned-short-cache", severity: "warning", message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable`, @@ -2812,6 +2821,7 @@ Audits HTTP caching strategies across all page resources, identifying resources if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) { entry.antiPatterns.push({ + code: "versioned-not-immutable", id: "versioned-not-immutable", severity: "info", message: `Versioned URL (hash detected) without Cache-Control: immutable`, @@ -2823,6 +2833,7 @@ Audits HTTP caching strategies across all page resources, identifying resources ["none", "no-store", "no-cache"].includes(strategy) ) { entry.antiPatterns.push({ + code: "large-no-cache", id: "large-no-cache", severity: "error", message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`, @@ -2831,6 +2842,7 @@ Audits HTTP caching strategies across all page resources, identifying resources if (entry.headers["expires"] && !entry.headers["cache-control"]) { entry.antiPatterns.push({ + code: "expires-without-cc", id: "expires-without-cc", severity: "warning", message: `Uses Expires header without Cache-Control (outdated pattern)`, @@ -2839,6 +2851,7 @@ Audits HTTP caching strategies across all page resources, identifying resources if (isStaticAsset(type) && maxAge === 0) { entry.antiPatterns.push({ + code: "maxage-zero-static", id: "maxage-zero-static", severity: "warning", message: `Static asset with max-age=0, which forces revalidation on every request`, @@ -2853,6 +2866,7 @@ Audits HTTP caching strategies across all page resources, identifying resources !cc["no-store"] ) { entry.antiPatterns.push({ + code: "short-cache-static", id: "short-cache-static", severity: "warning", message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration`, @@ -2931,6 +2945,7 @@ Audits HTTP caching strategies across all page resources, identifying resources // are anti-patterns like the header-based ones: they reach `items` and `issues`. for (const entry of uncompressedResources) { entry.antiPatterns.push({ + code: "uncompressed", id: "uncompressed", severity: "warning", message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip`, @@ -2939,6 +2954,7 @@ Audits HTTP caching strategies across all page resources, identifying resources for (const duplicate of costlyDuplicates) { // One finding per URL, on the first request, not one per request entries.find((e) => e.url === duplicate.url).antiPatterns.push({ + code: "duplicate-uncached", id: "duplicate-uncached", severity: "warning", message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable`, @@ -3010,6 +3026,7 @@ Audits HTTP caching strategies across all page resources, identifying resources .join(", "); coverageIssue = { severity: "info", + code: "header-coverage", id: "header-coverage", 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`, }; @@ -3375,10 +3392,12 @@ Audits HTTP caching strategies across all page resources, identifying resources })), issues: [ ...allAntiPatterns.map((p) => ({ + code: p.code, severity: p.severity, id: p.id, resource: p.resource, message: p.message, + target: p.url, })), ...(coverageIssue ? [coverageIssue] : []), ], @@ -3496,6 +3515,7 @@ Detects client-side redirects that add unnecessary latency to page load and impa if (sameOrigin && referrerPath !== currentPath && referrerPath !== '') { hasClientRedirect = true; indicators.push({ + code: 'same-origin-navigation', type: 'Same-origin navigation', from: referrerPath, to: currentPath, @@ -3550,6 +3570,7 @@ Detects client-side redirects that add unnecessary latency to page load and impa // Only flag if it's BOTH fast AND small (likely a redirect page) if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) { indicators.push({ + code: 'fast-minimal-navigation', type: 'Fast minimal-content navigation', duration: domContentLoaded.toFixed(1) + 'ms', size: (responseSize / 1024).toFixed(1) + ' KB', @@ -3734,9 +3755,9 @@ Detects client-side redirects that add unnecessary latency to page load and impa referrerPath, }, issues: [ - ...(serverRedirects > 0 ? [{ severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []), - ...(documentNavigations.length > 0 ? [{ severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []), - ...indicators.filter(i => i.severity === "warning").map(i => ({ severity: "warning", message: i.type })), + ...(serverRedirects > 0 ? [{ code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []), + ...(documentNavigations.length > 0 ? [{ code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []), + ...indicators.filter(i => i.severity === "warning").map(i => ({ code: i.code, severity: "warning", message: i.type })), ], }; })(); @@ -3871,18 +3892,21 @@ Finds text-based resources (JavaScript, CSS, HTML, JSON, SVG, XML and TTF/OTF fo const issues = []; if (uncompressed.length > 0) { issues.push({ + code: "uncompressed", severity: "warning", message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`, }); } if (corsLimitedAnalysis) { issues.push({ + code: "unknown-size", severity: "info", message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`, }); } if (!contentEncodingSupported && candidates.length > 0) { issues.push({ + code: "content-encoding-unavailable", severity: "info", message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize", }); @@ -8088,7 +8112,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy status: "ok", count: 0, items: [], - issues: [{ severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' }], + issues: [{ code: "no-prefetch", severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' }], }; } @@ -8176,12 +8200,14 @@ Detects potential performance issues with rel="prefetch" resource hints by analy // Check for missing or invalid 'as' attribute if (as === "unknown") { analysis.warnings.push({ + code: "missing-as", type: "missing-as", severity: "warning", message: "Missing 'as' attribute - browser cannot apply correct MIME type matching", }); } else if (!validAsValues.has(as)) { analysis.warnings.push({ + code: "invalid-as", type: "invalid-as", severity: "warning", message: `Invalid 'as' value: "${as}" - should be one of: ${Array.from(validAsValues).join(", ")}`, @@ -8191,6 +8217,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy // Check for duplicate prefetch if (seenUrls.has(normalizedUrl)) { analysis.warnings.push({ + code: "duplicate-prefetch", type: "duplicate-prefetch", severity: "warning", message: "Duplicate prefetch - this URL is prefetched multiple times", @@ -8229,6 +8256,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy if (isCurrentPageResource) { analysis.isCurrentPage = true; analysis.warnings.push({ + code: "used-on-current-page", type: "wrong-hint", severity: "error", message: "Resource used on current page - should use preload instead", @@ -8239,6 +8267,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy if (analysis.size > THRESHOLDS.largeFileSize) { const sizeMB = (analysis.size / (1024 * 1024)).toFixed(2); analysis.warnings.push({ + code: "large-file", type: "large-file", severity: "warning", message: `Large file (${sizeMB}MB) - consider if prefetch is appropriate`, @@ -8263,6 +8292,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy if (isInappropriate) { analysis.warnings.push({ + code: "inappropriate-type", type: "inappropriate-type", severity: "warning", message: `Type "${as}" may not be suitable for prefetch: ${inappropriateReason}`, @@ -8289,6 +8319,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy const countIssues = []; if (prefetchLinks.length > THRESHOLDS.maxCount) { countIssues.push({ + code: "too-many-prefetches", type: "excessive-count", severity: "warning", message: `${prefetchLinks.length} prefetch hints found (recommended: <${THRESHOLDS.maxCount})`, @@ -8302,6 +8333,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy if (totalSize > THRESHOLDS.totalSizeCritical) { countIssues.push({ + code: "large-total-size", type: "excessive-size", severity: "error", message: `Total prefetch size: ${totalSizeMB}MB (critical threshold exceeded)`, @@ -8309,6 +8341,7 @@ Detects potential performance issues with rel="prefetch" resource hints by analy }); } else if (totalSize > THRESHOLDS.totalSizeWarning) { countIssues.push({ + code: "large-total-size", type: "high-size", severity: "warning", message: `Total prefetch size: ${totalSizeMB}MB (warning threshold exceeded)`, @@ -8523,8 +8556,8 @@ Detects potential performance issues with rel="prefetch" resource hints by analy }, items: [...validPrefetch, ...issues].map(item => ({ href: item.shortUrl, as: item.as, sizeBytes: item.size, loaded: item.loaded, isCurrentPage: item.isCurrentPage || false })), issues: [ - ...countIssues.map(i => ({ severity: i.severity === "error" ? "error" : "warning", message: i.message })), - ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ severity: w.severity, message: `${item.shortUrl}: ${w.message}` }))), + ...countIssues.map(i => ({ code: i.code, severity: i.severity === "error" ? "error" : "warning", message: i.message })), + ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ code: w.code, severity: w.severity, message: `${item.shortUrl}: ${w.message}`, target: item.href }))), ], }; })(); @@ -8617,8 +8650,10 @@ Audits all fetchpriority attribute usage across the page, covering non-image res .filter((link) => link.getAttribute("fetchpriority") === "low") .forEach((link) => { issues.push({ + code: "preload-low-priority", severity: "error", element: link, + url: link.href, resource: shortUrl(link), message: 'preload + fetchpriority="low" is contradictory', detail: @@ -8634,8 +8669,10 @@ Audits all fetchpriority attribute usage across the page, covering non-image res const entry = getEntry(el); if (entry && entry.startTime < 500) { issues.push({ + code: "low-priority-loaded-early", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="low" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: @@ -8652,8 +8689,10 @@ Audits all fetchpriority attribute usage across the page, covering non-image res const entry = getEntry(el); if (entry && entry.startTime > 3000) { issues.push({ + code: "high-priority-loaded-late", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="high" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: @@ -8667,8 +8706,10 @@ Audits all fetchpriority attribute usage across the page, covering non-image res if (lcpCandidate && lcpCandidate.getAttribute("fetchpriority") !== "high") { const fp = lcpCandidate.getAttribute("fetchpriority"); issues.push({ + code: "lcp-no-fetchpriority", severity: "warning", element: lcpCandidate, + url: lcpCandidate.currentSrc || lcpCandidate.src, resource: (lcpCandidate.currentSrc || lcpCandidate.src).split("/").pop()?.split("?")[0] || "LCP image", @@ -8880,8 +8921,10 @@ Audits all fetchpriority attribute usage across the page, covering non-image res resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "", })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message, + ...(i.url ? { target: i.url } : {}), })), }; })(); @@ -9435,9 +9478,9 @@ Validates resource hints (preload, preconnect, dns-prefetch) by analyzing which })), ].slice(0, 50), issues: [ - ...(totalIssues > 0 ? [{ severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []), - ...(missingHints.length > 0 ? [{ severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []), - ...(redundantDomains.length > 0 ? [{ severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` }] : []), + ...(totalIssues > 0 ? [{ code: "unused-hints", severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []), + ...(missingHints.length > 0 ? [{ code: "missing-connection-hints", severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []), + ...(redundantDomains.length > 0 ? [{ code: "redundant-hints", severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` }] : []), ], }; })(); @@ -9546,6 +9589,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o if (type.requiresAs && !hint.as) { issues.push({ hint, + code: "preload-missing-as", issue: "Missing 'as' attribute (required for preload)", severity: "error", fix: `Add as="script|style|font|image|fetch"`, @@ -9555,6 +9599,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o if (hint.as === "font" && !hint.crossorigin) { issues.push({ hint, + code: "font-preload-no-crossorigin", issue: "Font preload without crossorigin (causes double fetch)", severity: "error", fix: 'Add crossorigin="anonymous"', @@ -9575,6 +9620,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o if (corsResources.length > 0) { issues.push({ hint, + code: "preconnect-no-crossorigin", issue: "Preconnect may need crossorigin for CORS resources", severity: "warning", fix: 'Consider adding crossorigin="anonymous"', @@ -9593,6 +9639,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o if (!wasUsed) { issues.push({ hint: preload, + code: "unused-preload", issue: "Preloaded resource not used within page load", severity: "warning", fix: "Remove unused preload or verify it's needed", @@ -9615,6 +9662,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o if (!hasResources && pc.hostname) { issues.push({ hint: pc, + code: "unused-preconnect", issue: `No resources loaded from ${pc.hostname}`, severity: "warning", fix: "Remove unused preconnect or verify it's needed for future resources", @@ -9626,6 +9674,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o if (preloads.length > 6) { issues.push({ hint: { rel: "preload", href: `${preloads.length} preloads` }, + code: "too-many-preloads", issue: `Too many preloads (${preloads.length}) - they compete for bandwidth`, severity: "warning", fix: "Limit to 5-6 most critical resources", @@ -9877,7 +9926,7 @@ Analyzes resource hints on the page, checking for proper usage and identifying o })), }, items: rankedHints.slice(0, MAX_ITEMS).map(h => ({ rel: h.rel, href: h.shortHref, as: h.as, crossorigin: h.crossorigin, fetchpriority: h.fetchpriority })), - issues: issues.map(i => ({ severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` })), + issues: issues.map(i => ({ code: i.code, severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}`, ...(i.hint.element ? { target: i.hint.href } : {}) })), }; })(); ``` @@ -10835,6 +10884,7 @@ Lists the Server-Timing metrics that the server sends with the document and with const issues = []; if (nav.serverTiming.length === 0) { issues.push({ + code: "no-server-timing", severity: "info", message: "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.", @@ -10842,12 +10892,14 @@ Lists the Server-Timing metrics that the server sends with the document and with } if (corsRestrictedCount > 0) { issues.push({ + code: "timing-hidden", severity: "info", 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.`, }); } if (!earlyHintsReceived) { issues.push({ + code: "no-early-hints", severity: "info", message: "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.", @@ -10918,7 +10970,7 @@ Analyzes the Service Worker lifecycle, cache behavior, and its performance impac count: 0, details: {}, items: [], - issues: [{ severity: "error", message: "Service Workers not supported in this browser" }], + issues: [{ code: "unsupported", severity: "error", message: "Service Workers not supported in this browser" }], }; } @@ -11012,11 +11064,12 @@ Analyzes the Service Worker lifecycle, cache behavior, and its performance impac const issues = []; if (registrations.length === 0) { - issues.push({ severity: "info", message: "No Service Workers registered" }); + issues.push({ code: "no-service-worker", severity: "info", message: "No Service Workers registered" }); } if (registrations.length > 0 && !controller) { issues.push({ + code: "not-controlled", severity: "warning", message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload.", }); @@ -11025,25 +11078,31 @@ Analyzes the Service Worker lifecycle, cache behavior, and its performance impac for (const item of items) { if (item.hasWaiting) { issues.push({ + code: "update-pending", severity: "warning", message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`, + target: item.scope, }); } if (item.navigationPreloadEnabled === false) { issues.push({ + code: "navigation-preload-disabled", severity: "info", message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`, + target: item.scope, }); } } if (swOverheadMs !== null && swOverheadMs > 100) { issues.push({ + code: "slow-startup", severity: "error", message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.`, }); } else if (swOverheadMs !== null && swOverheadMs > 50) { issues.push({ + code: "slow-startup", severity: "warning", message: `Moderate SW startup time (${swOverheadMs}ms)`, }); @@ -11051,11 +11110,13 @@ Analyzes the Service Worker lifecycle, cache behavior, and its performance impac if (cacheHitRate !== null && cacheHitRate < 50) { issues.push({ + code: "low-cache-hit-rate", severity: "error", message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.`, }); } else if (cacheHitRate !== null && cacheHitRate < 80) { issues.push({ + code: "low-cache-hit-rate", severity: "warning", message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.`, }); @@ -11063,6 +11124,7 @@ Analyzes the Service Worker lifecycle, cache behavior, and its performance impac if (unknownSource.length > 0) { issues.push({ + code: "unknown-source", severity: "info", message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`, }); @@ -11321,6 +11383,7 @@ Inspects how a page uses the Speculation Rules API to prefetch and prerender fut parsed = JSON.parse(script.textContent); } catch (e) { issues.push({ + code: 'invalid-json', severity: 'error', message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.`, }); @@ -11350,12 +11413,14 @@ Inspects how a page uses the Speculation Rules API to prefetch and prerender fut const isPrerender = action === 'prerender' || action === 'prerender_until_script'; if (isPrerender && source === 'list' && (eagerness === 'immediate' || eagerness === 'eager') && urls.length > EAGER_PRERENDER_LIMIT) { issues.push({ + code: 'eager-list-capped', severity: 'warning', message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`, }); } if (isPrerender && source === 'document' && (eagerness === 'immediate' || eagerness === 'eager')) { issues.push({ + code: 'eager-document-rule', severity: 'warning', message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`, }); @@ -11365,15 +11430,15 @@ Inspects how a page uses the Speculation Rules API to prefetch and prerender fut }); if (scripts.length === 0) { - issues.push({ severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' }); + issues.push({ code: 'no-rules', severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' }); } else if (items.length === 0 && !issues.some((i) => i.severity === 'error')) { - issues.push({ severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' }); + issues.push({ code: 'no-prefetch-or-prerender', severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' }); } if (!supported && scripts.length > 0) { - issues.push({ severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' }); + issues.push({ code: 'unsupported', severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' }); } if (activationStart > 0) { - issues.push({ severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); + issues.push({ code: 'prerendered', severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); } return { @@ -11596,6 +11661,7 @@ Time to First Byte (TTFB) measures the time from when the user starts navigating const slowestMs = Math.round(slowestSync.duration); const share = ttfbValueSync > 0 ? Math.round((slowestSync.duration / ttfbValueSync) * 100) : 0; issues.push({ + code: "slow-ttfb", severity: rating === "poor" ? "error" : "warning", message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.`, }); @@ -11788,7 +11854,7 @@ Time to First Byte (TTFB) measures the time from when the user starts navigating corsLimitedAnalysis: corsRestrictedCount > 0, details: { avgTtfbMs: null, maxTtfbMs: null, minTtfbMs: null, thirdPartyCount: 0, slowCount: 0, corsRestrictedCount }, items: [], - issues: [{ severity: "info", message: "No resources with TTFB data available" }], + issues: [{ code: "no-resources", severity: "info", message: "No resources with TTFB data available" }], }; } const ttfbVals = resourcesSync.map((r) => r.ttfbMs); diff --git a/skills/webperf-core-web-vitals/references/schema.md b/skills/webperf-core-web-vitals/references/schema.md index 988a08ea..975142c7 100644 --- a/skills/webperf-core-web-vitals/references/schema.md +++ b/skills/webperf-core-web-vitals/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/skills/webperf-interaction/references/schema.md b/skills/webperf-interaction/references/schema.md index 988a08ea..975142c7 100644 --- a/skills/webperf-interaction/references/schema.md +++ b/skills/webperf-interaction/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/skills/webperf-loading/references/schema.md b/skills/webperf-loading/references/schema.md index 988a08ea..975142c7 100644 --- a/skills/webperf-loading/references/schema.md +++ b/skills/webperf-loading/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/skills/webperf-loading/scripts/Back-Forward-Cache.js b/skills/webperf-loading/scripts/Back-Forward-Cache.js index 5fc63e68..9325a3b3 100644 --- a/skills/webperf-loading/scripts/Back-Forward-Cache.js +++ b/skills/webperf-loading/scripts/Back-Forward-Cache.js @@ -1,2 +1,2 @@ -// snippets/Loading/Back-Forward-Cache.js | sha256:95f8a72cfe7b5f88 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Back-Forward-Cache.js -(async()=>{const e={supported:"PerformanceNavigationTiming"in window,wasRestored:!1,eligibility:null,blockingReasons:[],notRestoredReasons:null,recommendations:[]};window.addEventListener("pageshow",o=>{o.persisted&&(e.wasRestored=!0)});const o={"unload-listener":"unload event listeners block bfcache. Use pagehide or visibilitychange instead.","response-cache-control-no-store":"Cache-Control: no-store on the page response prevents caching. Use no-cache instead.",websocket:"Open WebSocket connections prevent bfcache. Close them on pagehide.",broadcastchannel:"Open BroadcastChannel instances prevent bfcache. Close them on pagehide.","indexeddb-connection":"Open IndexedDB connections prevent bfcache. Close them on pagehide.",masked:"The browser does not disclose the exact reason (for example, a cross-origin frame)."},n=e=>e&&{url:e.url??null,src:e.src??null,id:e.id??null,name:e.name??null,reasons:(e.reasons||[]).map(e=>"string"==typeof e?e:e?.reason).filter(Boolean),children:(e.children||[]).map(n)},s=(e,o)=>[...e.reasons.map(e=>({reason:e,frame:o})),...e.children.flatMap((e,o)=>s(e,e.src||e.url||e.name||e.id||`iframe ${o+1}`))],r=async()=>{const r=[],t=performance.getEntriesByType("navigation")[0];e.notRestoredReasons=n(t?.notRestoredReasons);const a=e.notRestoredReasons?s(e.notRestoredReasons,e.notRestoredReasons.url||"main frame"):[];if(a.forEach(({reason:e,frame:n})=>{r.push({reason:`${e} (${n})`,severity:"high",source:"browser",description:o[e.toLowerCase()]||"Reported by the browser as a bfcache blocker"})}),null===window.onunload||a.some(({reason:e})=>"unload-listener"===e)||r.push({reason:"window.onunload handler set",severity:"high",source:"detected",description:o["unload-listener"]}),/^https?:$/.test(location.protocol))try{const e=(await fetch(function(e){try{const o=new URL(e,location.href);return/^https?:$/.test(o.protocol)?(o.hash="webperf-snippets",o.href):e}catch{return e}}(location.href),{method:"HEAD",credentials:"same-origin"})).headers.get("cache-control")||"";/no-store/i.test(e)&&!a.some(({reason:e})=>"response-cache-control-no-store"===e)&&r.push({reason:"Cache-Control: no-store on the page response",severity:"medium",source:"detected",description:o["response-cache-control-no-store"]})}catch{}const i=document.querySelectorAll("iframe").length;return i>0&&r.push({reason:`${i} iframe(s) on the page`,severity:"info",source:"note",description:"A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation."}),"serviceWorker"in navigator&&navigator.serviceWorker.controller&&r.push({reason:"Service Worker controls the page",severity:"info",source:"note",description:"Service Workers are compatible with bfcache."}),e.blockingReasons=r,e.recommendations=[...new Set(r.filter(e=>"info"!==e.severity).map(e=>e.description))],e.eligibility=r.some(e=>"browser"===e.source)?"blocked":r.some(e=>"detected"===e.source)?"likely-blocked":"no-blockers-detected",e.eligibility},t=(e,o)=>{" ".repeat(o),e.reasons.forEach(e=>{e.toLowerCase()}),e.children.forEach((e,n)=>{t(e,o+3)})},a=()=>{performance.getEntriesByType("navigation"),e.notRestoredReasons&&t(e.notRestoredReasons,3),e.recommendations.length>0&&e.recommendations.forEach((e,o)=>{})},i=()=>({script:"Back-Forward-Cache",status:"ok",details:{eligibility:e.eligibility,wasRestored:e.wasRestored,supported:e.supported,navigationType:performance.getEntriesByType("navigation")[0]?.type??null,notRestoredReasons:e.notRestoredReasons},issues:e.blockingReasons.map(e=>({severity:"high"===e.severity?"error":"medium"===e.severity?"warning":"info",message:e.reason}))});return window.checkBfcache=async()=>(await r(),a(),i()),await r(),a(),{...i(),message:"bfcache analysis complete. Call checkBfcache() to re-run analysis."}})(); \ No newline at end of file +// snippets/Loading/Back-Forward-Cache.js | sha256:6b9e604a83c42bfb | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Back-Forward-Cache.js +(async()=>{const e={supported:"PerformanceNavigationTiming"in window,wasRestored:!1,eligibility:null,blockingReasons:[],notRestoredReasons:null,recommendations:[]};window.addEventListener("pageshow",o=>{o.persisted&&(e.wasRestored=!0)});const o={"unload-listener":"unload event listeners block bfcache. Use pagehide or visibilitychange instead.","response-cache-control-no-store":"Cache-Control: no-store on the page response prevents caching. Use no-cache instead.",websocket:"Open WebSocket connections prevent bfcache. Close them on pagehide.",broadcastchannel:"Open BroadcastChannel instances prevent bfcache. Close them on pagehide.","indexeddb-connection":"Open IndexedDB connections prevent bfcache. Close them on pagehide.",masked:"The browser does not disclose the exact reason (for example, a cross-origin frame)."},n=e=>e&&{url:e.url??null,src:e.src??null,id:e.id??null,name:e.name??null,reasons:(e.reasons||[]).map(e=>"string"==typeof e?e:e?.reason).filter(Boolean),children:(e.children||[]).map(n)},s=(e,o)=>[...e.reasons.map(e=>({reason:e,frame:o})),...e.children.flatMap((e,o)=>s(e,e.src||e.url||e.name||e.id||`iframe ${o+1}`))],r=async()=>{const r=[],t=performance.getEntriesByType("navigation")[0];e.notRestoredReasons=n(t?.notRestoredReasons);const a=e.notRestoredReasons?s(e.notRestoredReasons,e.notRestoredReasons.url||"main frame"):[];if(a.forEach(({reason:e,frame:n})=>{r.push({code:"not-restored",reason:`${e} (${n})`,severity:"high",source:"browser",description:o[e.toLowerCase()]||"Reported by the browser as a bfcache blocker"})}),null===window.onunload||a.some(({reason:e})=>"unload-listener"===e)||r.push({code:"unload-handler",reason:"window.onunload handler set",severity:"high",source:"detected",description:o["unload-listener"]}),/^https?:$/.test(location.protocol))try{const e=(await fetch(function(e){try{const o=new URL(e,location.href);return/^https?:$/.test(o.protocol)?(o.hash="webperf-snippets",o.href):e}catch{return e}}(location.href),{method:"HEAD",credentials:"same-origin"})).headers.get("cache-control")||"";/no-store/i.test(e)&&!a.some(({reason:e})=>"response-cache-control-no-store"===e)&&r.push({code:"cache-control-no-store",reason:"Cache-Control: no-store on the page response",severity:"medium",source:"detected",description:o["response-cache-control-no-store"]})}catch{}const i=document.querySelectorAll("iframe").length;return i>0&&r.push({code:"iframes",reason:`${i} iframe(s) on the page`,severity:"info",source:"note",description:"A bfcache blocker inside an iframe blocks the whole page. See notRestoredReasons after a back navigation."}),"serviceWorker"in navigator&&navigator.serviceWorker.controller&&r.push({code:"service-worker",reason:"Service Worker controls the page",severity:"info",source:"note",description:"Service Workers are compatible with bfcache."}),e.blockingReasons=r,e.recommendations=[...new Set(r.filter(e=>"info"!==e.severity).map(e=>e.description))],e.eligibility=r.some(e=>"browser"===e.source)?"blocked":r.some(e=>"detected"===e.source)?"likely-blocked":"no-blockers-detected",e.eligibility},t=(e,o)=>{" ".repeat(o),e.reasons.forEach(e=>{e.toLowerCase()}),e.children.forEach((e,n)=>{t(e,o+3)})},a=()=>{performance.getEntriesByType("navigation"),e.notRestoredReasons&&t(e.notRestoredReasons,3),e.recommendations.length>0&&e.recommendations.forEach((e,o)=>{})},i=()=>({script:"Back-Forward-Cache",status:"ok",details:{eligibility:e.eligibility,wasRestored:e.wasRestored,supported:e.supported,navigationType:performance.getEntriesByType("navigation")[0]?.type??null,notRestoredReasons:e.notRestoredReasons},issues:e.blockingReasons.map(e=>({code:e.code,severity:"high"===e.severity?"error":"medium"===e.severity?"warning":"info",message:e.reason,..."not-restored"===e.code?{target:e.reason}:{}}))});return window.checkBfcache=async()=>(await r(),a(),i()),await r(),a(),{...i(),message:"bfcache analysis complete. Call checkBfcache() to re-run analysis."}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js b/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js index e7785fd8..adb987e4 100644 --- a/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js +++ b/skills/webperf-loading/scripts/Cache-Strategy-Analysis.js @@ -1,2 +1,2 @@ -// snippets/Loading/Cache-Strategy-Analysis.js | sha256:7b1eb7a352f67f8d | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Cache-Strategy-Analysis.js -(async()=>{function e(e){try{const t=new URL(e,location.href);return/^https?:$/.test(t.protocol)?(t.hash="webperf-snippets",t.href):e}catch{return e}}const t=[];function n(e){const t=e.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(":"))return t;const n=t.split(".");return n.length<=2?t:2===n[n.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(n[n.length-2])?n.slice(-3).join("."):n.slice(-2).join(".")}function s(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}function r(e){const t=(e.name||"").split("?")[0].toLowerCase();return/\.(woff2?|ttf|otf|eot)$/.test(t)?"font":/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/.test(t)?"image":/\.(mp4|webm|ogg|mov)$/.test(t)?"video":/\.(mp3|wav|flac|aac)$/.test(t)?"audio":/\.css$/.test(t)?"style":/\.js$/.test(t)?"script":{script:"script",link:"style",css:"style",img:"image",image:"image",video:"video",audio:"audio",font:"font",fetch:"fetch",xmlhttprequest:"xhr",iframe:"iframe",other:"other"}[e.initiatorType]||e.initiatorType||"other"}function o(e){return[/[.-][a-f0-9]{8,32}\.(js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)(\?|$)/,/[?&](v|version|hash|rev|_v)=[a-zA-Z0-9._-]+/].some(t=>t.test(e))}function a(e){return["script","style","font","image"].includes(e)}function i(e){const t={};if(!e)return t;for(const n of e.split(",")){const[e,s]=n.trim().split("=");t[e.trim().toLowerCase()]=void 0===s||s.trim().replace(/"/g,"")}return t}function c(e){if(!e)return"unknown";const t=i(e["cache-control"]||""),n=!!e.expires;if(t["no-store"])return"no-store";if(t["no-cache"])return"no-cache";if(t.immutable)return"immutable";if(t["stale-while-revalidate"])return"swr";if(t["must-revalidate"]&&(e.etag||e["last-modified"]))return"must-revalidate";const s=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,r=(void 0!==t["s-maxage"]?parseInt(t["s-maxage"],10):null)??s;return null!==r?0===r?"no-cache":r<3600?"short":r<=86400?"medium":"long":n&&!e["cache-control"]?"expires-only":0!==Object.keys(t).length||n?"unknown":"none"}function l(e){if(!e)return{cdn:"unknown",status:"unknown"};if(e["cf-cache-status"])return{cdn:"Cloudflare",status:e["cf-cache-status"]};if(e["x-vercel-cache"])return{cdn:"Vercel",status:e["x-vercel-cache"]};if(e["x-cache"]){const t=e["x-cache"].toUpperCase();return{cdn:e["x-amz-cf-id"]?"CloudFront":"CDN",status:t.includes("HIT")?"HIT":"MISS"}}if(e.age)return{cdn:"Proxy/CDN",status:`HIT (Age: ${e.age}s)`};if(e.via)return{cdn:`Proxy (${e.via})`,status:"proxied"};if(e["server-timing"]){const t=e["server-timing"].toLowerCase();if(t.includes("cdn-cache")||t.includes("cache;"))return{cdn:"CDN (Server-Timing)",status:t.includes("hit")?"HIT":"MISS"}}return{cdn:"none",status:"no-cdn-headers"}}const h=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),d=new Map;for(const e of h)d.set(e.name,(d.get(e.name)||0)+1);const u=h.map(e=>{let s;try{s=new URL(e.name)}catch{s={hostname:location.hostname,pathname:e.name}}const a=function(e){const s=n(e);return s===n(location.hostname)||t.some(e=>n(String(e).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===s)}(s.hostname),i=e.encodedBodySize>0&&0===e.transferSize,c=0===e.encodedBodySize&&0===e.transferSize&&!a;return{url:e.name,shortName:(s.pathname||e.name).split("/").filter(Boolean).pop()||s.hostname,host:s.hostname,firstParty:a,type:r(e),transferSize:e.transferSize||0,encodedBodySize:e.encodedBodySize||0,decodedBodySize:e.decodedBodySize||0,isCached:i,isCorsRestricted:c,nextHopProtocol:e.nextHopProtocol||"unknown",duration:e.duration,hasHash:o(e.name),isDuplicate:(d.get(e.name)||1)>1,headers:null,cacheStrategy:null,cdnInfo:null,antiPatterns:[]}}),f=new Set(["script","style","font","image"]),g=u.filter(e=>e.url.startsWith(location.origin+"/")&&f.has(e.type)),m=[...new Set(g.map(e=>e.url))],p=new Map;for(let t=0;t{try{const n=await fetch(e(t),{method:"HEAD",cache:"no-store"});if(!n.ok)return void p.set(t,null);const s={};for(const[e,t]of n.headers)s[e.toLowerCase()]=t;p.set(t,s)}catch{p.set(t,null)}}))}for(const e of u)e.headers=p.get(e.url)||null;for(const e of u)e.cacheStrategy=e.headers?c(e.headers):e.isCorsRestricted?"unknown (CORS)":e.isCached?"cached (inferred)":"unknown",e.cdnInfo=l(e.headers);const y=new Set(["immutable","long","medium","swr","must-revalidate","cached (inferred)"]);for(const e of u){if(!e.headers)continue;const t=i(e.headers["cache-control"]||""),n=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,r=e.cacheStrategy,o=e.type;a(o)&&t["no-store"]&&e.antiPatterns.push({id:"static-no-store",severity:"error",message:`${o} asset with no-store; static assets should be cacheable`}),e.hasHash&&null!==n&&n<86400&&!t["no-store"]&&e.antiPatterns.push({id:"versioned-short-cache",severity:"warning",message:`Versioned URL (hash detected) but max-age=${n}s: consider long cache or immutable`}),e.hasHash&&!t.immutable&&!t["no-store"]&&(null===n||n>=86400)&&e.antiPatterns.push({id:"versioned-not-immutable",severity:"info",message:"Versioned URL (hash detected) without Cache-Control: immutable"}),e.decodedBodySize>102400&&["none","no-store","no-cache"].includes(r)&&e.antiPatterns.push({id:"large-no-cache",severity:"error",message:`Large resource (${s(e.decodedBodySize)}) with no effective cache`}),e.headers.expires&&!e.headers["cache-control"]&&e.antiPatterns.push({id:"expires-without-cc",severity:"warning",message:"Uses Expires header without Cache-Control (outdated pattern)"}),a(o)&&0===n&&e.antiPatterns.push({id:"maxage-zero-static",severity:"warning",message:"Static asset with max-age=0, which forces revalidation on every request"}),a(o)&&null!==n&&n>0&&n<3600&&!t["no-store"]&&e.antiPatterns.push({id:"short-cache-static",severity:"warning",message:`Static asset with max-age=${n}s (< 1h): consider a longer cache duration`})}const w=u.filter(e=>e.cdnInfo&&"none"!==e.cdnInfo.cdn&&"unknown"!==e.cdnInfo.cdn),v=w.filter(e=>e.cdnInfo.status.toUpperCase().startsWith("HIT")).length,S=w.length>0?Math.round(v/w.length*100):null,C=u.filter(e=>null!==e.headers),x=C.filter(e=>y.has(e.cacheStrategy)),b=C.length>0?Math.round(x.length/C.length*100):null,P=u.filter(e=>e.isCached).reduce((e,t)=>e+t.decodedBodySize,0),$={};for(const e of u){const t=e.nextHopProtocol||"unknown";$[t]=($[t]||0)+1}const z=u.filter(e=>e.encodedBodySize>1024&&e.decodedBodySize>0&&e.encodedBodySize/e.decodedBodySize>.9&&["script","style","fetch","xhr"].includes(e.type)),B=[...new Set(u.filter(e=>e.isDuplicate).map(e=>e.url))].map(e=>{const t=u.filter(t=>t.url===e),n=t.filter(e=>!e.isCached).length;return{url:e,shortName:t[0]?.shortName||e,count:d.get(e),type:t[0]?.type||"unknown",uncachedLoads:n,costly:n>0}}),k=B.filter(e=>e.costly),R=B.filter(e=>!e.costly);for(const e of z)e.antiPatterns.push({id:"uncompressed",severity:"warning",message:`${e.type} sent uncompressed (${s(e.encodedBodySize)} on the wire for ${s(e.decodedBodySize)}): enable Brotli or gzip`});for(const e of k)u.find(t=>t.url===e.url).antiPatterns.push({id:"duplicate-uncached",severity:"warning",message:`Loaded ${e.count} times, ${e.uncachedLoads} from the network: load it once or make it cacheable`});if(0===u.length)return{script:"Cache-Strategy-Analysis",status:"error",error:"No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again."};const H=u.flatMap(e=>e.antiPatterns.map(t=>({...t,resource:e.shortName,url:e.url}))),I={};for(const e of u){const t=e.cacheStrategy;I[t]||(I[t]=0),I[t]++}const M=u.filter(e=>["none","no-store","no-cache"].includes(e.cacheStrategy)),T=u.filter(e=>"short"===e.cacheStrategy),L=u.filter(e=>null!==e.headers).length,N=Math.round(L/u.length*100),A=u.filter(e=>e.isCorsRestricted).length,D=u.filter(e=>!(null!==e.headers||e.isCorsRestricted||e.url.startsWith(location.origin+"/")&&f.has(e.type)));let E=null;if(N<50){const e=new Map;for(const t of u.filter(e=>null===e.headers))e.set(t.host,(e.get(t.host)||0)+1);const t=[...e.entries()].sort((e,t)=>t[1]-e[1]).slice(0,3).map(([e])=>e).join(", ");E={severity:"info",id:"header-coverage",message:`Cache headers were read for ${L} of ${u.length} resources (${N}%): 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 ${t}; check those hosts in the Network panel or with curl -I`}}if(D.length>0&&[...new Set(D.map(e=>e.type))].join(", "),H.length>0&&(H.filter(e=>"error"===e.severity),H.filter(e=>"warning"===e.severity),H.filter(e=>"info"===e.severity)),["immutable","long","medium","short","swr","must-revalidate","no-cache","no-store","expires-only","none","cached (inferred)","unknown (CORS)","unknown"].filter(e=>I[e]).map(e=>({Strategy:e,Count:I[e],"% of Total":`${Math.round(I[e]/u.length*100)}%`,Effective:y.has(e)?"✅":"❌"})),H.length>0)for(const e of["error","warning","info"])H.filter(t=>t.severity===e);const j=[];if(M.filter(e=>a(e.type)).length>0&&j.push("🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."),T.filter(e=>e.hasHash).length>0&&j.push("🟡 Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."),H.some(e=>"expires-without-cc"===e.id)&&j.push("🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."),null!==b&&b<50&&j.push(`🔴 Cache efficiency is low (${b}%, measured on the ${C.length} resources whose cache headers could be read). Review caching strategy for those static assets.`),null!==S&&S<70&&j.push(`🟡 CDN hit rate is ${S}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`),$["http/1.1"]>0&&j.push(`🟡 ${$["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`),z.length>0&&j.push(`🟡 ${z.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`),k.length>0?j.push(`🟡 ${k.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`):R.length>0&&j.push(`â„šī¸ ${R.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`),A>0&&0===L&&j.push("â„šī¸ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights."),j.length>0)for(const e of j);u.filter(e=>!e.firstParty);const U=null===b?null:b>=80?"good":b>=50?"needs-improvement":"poor",O=u.filter(e=>e.antiPatterns.length>0||["none","no-store","no-cache","short"].includes(e.cacheStrategy));return{script:"Cache-Strategy-Analysis",status:"ok",count:u.length,corsLimitedAnalysis:A>0,details:{totalResources:u.length,actionableCount:O.length,headersAnalyzed:L,corsRestricted:A,excludedFromHeaders:D.length,headerCoveragePercent:N,cacheEfficiencyPercent:b,cacheEfficiencyRating:U,cacheEfficiencyResources:C.length,bandwidthSavedBytes:P,strategyDistribution:I,cdnDetected:w.length>0,cdnHitRate:S,cdnHitRateRating:null===S?null:S>=70?"good":"poor",antiPatternCount:H.length,duplicatesCostlyCount:k.length,uncompressedCount:z.length,protocolDistribution:$},items:O.sort((e,t)=>t.antiPatterns.length-e.antiPatterns.length||t.decodedBodySize-e.decodedBodySize).slice(0,50).map(e=>({shortName:e.shortName,host:e.host,type:e.type,firstParty:e.firstParty,cacheStrategy:e.cacheStrategy,sizeBytes:e.decodedBodySize,isCached:e.isCached,antiPatterns:e.antiPatterns.map(e=>e.id)})),issues:[...H.map(e=>({severity:e.severity,id:e.id,resource:e.resource,message:e.message})),...E?[E]:[]],recommendations:j}})(); \ No newline at end of file +// snippets/Loading/Cache-Strategy-Analysis.js | sha256:85c1a40556dc2f04 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Cache-Strategy-Analysis.js +(async()=>{function e(e){try{const t=new URL(e,location.href);return/^https?:$/.test(t.protocol)?(t.hash="webperf-snippets",t.href):e}catch{return e}}const t=[];function n(e){const t=e.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(":"))return t;const n=t.split(".");return n.length<=2?t:2===n[n.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(n[n.length-2])?n.slice(-3).join("."):n.slice(-2).join(".")}function r(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}function s(e){const t=(e.name||"").split("?")[0].toLowerCase();return/\.(woff2?|ttf|otf|eot)$/.test(t)?"font":/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/.test(t)?"image":/\.(mp4|webm|ogg|mov)$/.test(t)?"video":/\.(mp3|wav|flac|aac)$/.test(t)?"audio":/\.css$/.test(t)?"style":/\.js$/.test(t)?"script":{script:"script",link:"style",css:"style",img:"image",image:"image",video:"video",audio:"audio",font:"font",fetch:"fetch",xmlhttprequest:"xhr",iframe:"iframe",other:"other"}[e.initiatorType]||e.initiatorType||"other"}function o(e){return[/[.-][a-f0-9]{8,32}\.(js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)(\?|$)/,/[?&](v|version|hash|rev|_v)=[a-zA-Z0-9._-]+/].some(t=>t.test(e))}function a(e){return["script","style","font","image"].includes(e)}function i(e){const t={};if(!e)return t;for(const n of e.split(",")){const[e,r]=n.trim().split("=");t[e.trim().toLowerCase()]=void 0===r||r.trim().replace(/"/g,"")}return t}function c(e){if(!e)return"unknown";const t=i(e["cache-control"]||""),n=!!e.expires;if(t["no-store"])return"no-store";if(t["no-cache"])return"no-cache";if(t.immutable)return"immutable";if(t["stale-while-revalidate"])return"swr";if(t["must-revalidate"]&&(e.etag||e["last-modified"]))return"must-revalidate";const r=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,s=(void 0!==t["s-maxage"]?parseInt(t["s-maxage"],10):null)??r;return null!==s?0===s?"no-cache":s<3600?"short":s<=86400?"medium":"long":n&&!e["cache-control"]?"expires-only":0!==Object.keys(t).length||n?"unknown":"none"}function l(e){if(!e)return{cdn:"unknown",status:"unknown"};if(e["cf-cache-status"])return{cdn:"Cloudflare",status:e["cf-cache-status"]};if(e["x-vercel-cache"])return{cdn:"Vercel",status:e["x-vercel-cache"]};if(e["x-cache"]){const t=e["x-cache"].toUpperCase();return{cdn:e["x-amz-cf-id"]?"CloudFront":"CDN",status:t.includes("HIT")?"HIT":"MISS"}}if(e.age)return{cdn:"Proxy/CDN",status:`HIT (Age: ${e.age}s)`};if(e.via)return{cdn:`Proxy (${e.via})`,status:"proxied"};if(e["server-timing"]){const t=e["server-timing"].toLowerCase();if(t.includes("cdn-cache")||t.includes("cache;"))return{cdn:"CDN (Server-Timing)",status:t.includes("hit")?"HIT":"MISS"}}return{cdn:"none",status:"no-cdn-headers"}}const h=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),d=new Map;for(const e of h)d.set(e.name,(d.get(e.name)||0)+1);const u=h.map(e=>{let r;try{r=new URL(e.name)}catch{r={hostname:location.hostname,pathname:e.name}}const a=function(e){const r=n(e);return r===n(location.hostname)||t.some(e=>n(String(e).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===r)}(r.hostname),i=e.encodedBodySize>0&&0===e.transferSize,c=0===e.encodedBodySize&&0===e.transferSize&&!a;return{url:e.name,shortName:(r.pathname||e.name).split("/").filter(Boolean).pop()||r.hostname,host:r.hostname,firstParty:a,type:s(e),transferSize:e.transferSize||0,encodedBodySize:e.encodedBodySize||0,decodedBodySize:e.decodedBodySize||0,isCached:i,isCorsRestricted:c,nextHopProtocol:e.nextHopProtocol||"unknown",duration:e.duration,hasHash:o(e.name),isDuplicate:(d.get(e.name)||1)>1,headers:null,cacheStrategy:null,cdnInfo:null,antiPatterns:[]}}),f=new Set(["script","style","font","image"]),g=u.filter(e=>e.url.startsWith(location.origin+"/")&&f.has(e.type)),m=[...new Set(g.map(e=>e.url))],p=new Map;for(let t=0;t{try{const n=await fetch(e(t),{method:"HEAD",cache:"no-store"});if(!n.ok)return void p.set(t,null);const r={};for(const[e,t]of n.headers)r[e.toLowerCase()]=t;p.set(t,r)}catch{p.set(t,null)}}))}for(const e of u)e.headers=p.get(e.url)||null;for(const e of u)e.cacheStrategy=e.headers?c(e.headers):e.isCorsRestricted?"unknown (CORS)":e.isCached?"cached (inferred)":"unknown",e.cdnInfo=l(e.headers);const y=new Set(["immutable","long","medium","swr","must-revalidate","cached (inferred)"]);for(const e of u){if(!e.headers)continue;const t=i(e.headers["cache-control"]||""),n=void 0!==t["max-age"]?parseInt(t["max-age"],10):null,s=e.cacheStrategy,o=e.type;a(o)&&t["no-store"]&&e.antiPatterns.push({code:"static-no-store",id:"static-no-store",severity:"error",message:`${o} asset with no-store; static assets should be cacheable`}),e.hasHash&&null!==n&&n<86400&&!t["no-store"]&&e.antiPatterns.push({code:"versioned-short-cache",id:"versioned-short-cache",severity:"warning",message:`Versioned URL (hash detected) but max-age=${n}s: consider long cache or immutable`}),e.hasHash&&!t.immutable&&!t["no-store"]&&(null===n||n>=86400)&&e.antiPatterns.push({code:"versioned-not-immutable",id:"versioned-not-immutable",severity:"info",message:"Versioned URL (hash detected) without Cache-Control: immutable"}),e.decodedBodySize>102400&&["none","no-store","no-cache"].includes(s)&&e.antiPatterns.push({code:"large-no-cache",id:"large-no-cache",severity:"error",message:`Large resource (${r(e.decodedBodySize)}) with no effective cache`}),e.headers.expires&&!e.headers["cache-control"]&&e.antiPatterns.push({code:"expires-without-cc",id:"expires-without-cc",severity:"warning",message:"Uses Expires header without Cache-Control (outdated pattern)"}),a(o)&&0===n&&e.antiPatterns.push({code:"maxage-zero-static",id:"maxage-zero-static",severity:"warning",message:"Static asset with max-age=0, which forces revalidation on every request"}),a(o)&&null!==n&&n>0&&n<3600&&!t["no-store"]&&e.antiPatterns.push({code:"short-cache-static",id:"short-cache-static",severity:"warning",message:`Static asset with max-age=${n}s (< 1h): consider a longer cache duration`})}const w=u.filter(e=>e.cdnInfo&&"none"!==e.cdnInfo.cdn&&"unknown"!==e.cdnInfo.cdn),v=w.filter(e=>e.cdnInfo.status.toUpperCase().startsWith("HIT")).length,S=w.length>0?Math.round(v/w.length*100):null,C=u.filter(e=>null!==e.headers),x=C.filter(e=>y.has(e.cacheStrategy)),b=C.length>0?Math.round(x.length/C.length*100):null,P=u.filter(e=>e.isCached).reduce((e,t)=>e+t.decodedBodySize,0),$={};for(const e of u){const t=e.nextHopProtocol||"unknown";$[t]=($[t]||0)+1}const z=u.filter(e=>e.encodedBodySize>1024&&e.decodedBodySize>0&&e.encodedBodySize/e.decodedBodySize>.9&&["script","style","fetch","xhr"].includes(e.type)),B=[...new Set(u.filter(e=>e.isDuplicate).map(e=>e.url))].map(e=>{const t=u.filter(t=>t.url===e),n=t.filter(e=>!e.isCached).length;return{url:e,shortName:t[0]?.shortName||e,count:d.get(e),type:t[0]?.type||"unknown",uncachedLoads:n,costly:n>0}}),k=B.filter(e=>e.costly),R=B.filter(e=>!e.costly);for(const e of z)e.antiPatterns.push({code:"uncompressed",id:"uncompressed",severity:"warning",message:`${e.type} sent uncompressed (${r(e.encodedBodySize)} on the wire for ${r(e.decodedBodySize)}): enable Brotli or gzip`});for(const e of k)u.find(t=>t.url===e.url).antiPatterns.push({code:"duplicate-uncached",id:"duplicate-uncached",severity:"warning",message:`Loaded ${e.count} times, ${e.uncachedLoads} from the network: load it once or make it cacheable`});if(0===u.length)return{script:"Cache-Strategy-Analysis",status:"error",error:"No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again."};const H=u.flatMap(e=>e.antiPatterns.map(t=>({...t,resource:e.shortName,url:e.url}))),I={};for(const e of u){const t=e.cacheStrategy;I[t]||(I[t]=0),I[t]++}const M=u.filter(e=>["none","no-store","no-cache"].includes(e.cacheStrategy)),T=u.filter(e=>"short"===e.cacheStrategy),L=u.filter(e=>null!==e.headers).length,N=Math.round(L/u.length*100),A=u.filter(e=>e.isCorsRestricted).length,D=u.filter(e=>!(null!==e.headers||e.isCorsRestricted||e.url.startsWith(location.origin+"/")&&f.has(e.type)));let E=null;if(N<50){const e=new Map;for(const t of u.filter(e=>null===e.headers))e.set(t.host,(e.get(t.host)||0)+1);const t=[...e.entries()].sort((e,t)=>t[1]-e[1]).slice(0,3).map(([e])=>e).join(", ");E={severity:"info",code:"header-coverage",id:"header-coverage",message:`Cache headers were read for ${L} of ${u.length} resources (${N}%): 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 ${t}; check those hosts in the Network panel or with curl -I`}}if(D.length>0&&[...new Set(D.map(e=>e.type))].join(", "),H.length>0&&(H.filter(e=>"error"===e.severity),H.filter(e=>"warning"===e.severity),H.filter(e=>"info"===e.severity)),["immutable","long","medium","short","swr","must-revalidate","no-cache","no-store","expires-only","none","cached (inferred)","unknown (CORS)","unknown"].filter(e=>I[e]).map(e=>({Strategy:e,Count:I[e],"% of Total":`${Math.round(I[e]/u.length*100)}%`,Effective:y.has(e)?"✅":"❌"})),H.length>0)for(const e of["error","warning","info"])H.filter(t=>t.severity===e);const j=[];if(M.filter(e=>a(e.type)).length>0&&j.push("🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."),T.filter(e=>e.hasHash).length>0&&j.push("🟡 Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."),H.some(e=>"expires-without-cc"===e.id)&&j.push("🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."),null!==b&&b<50&&j.push(`🔴 Cache efficiency is low (${b}%, measured on the ${C.length} resources whose cache headers could be read). Review caching strategy for those static assets.`),null!==S&&S<70&&j.push(`🟡 CDN hit rate is ${S}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`),$["http/1.1"]>0&&j.push(`🟡 ${$["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`),z.length>0&&j.push(`🟡 ${z.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`),k.length>0?j.push(`🟡 ${k.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`):R.length>0&&j.push(`â„šī¸ ${R.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`),A>0&&0===L&&j.push("â„šī¸ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights."),j.length>0)for(const e of j);u.filter(e=>!e.firstParty);const U=null===b?null:b>=80?"good":b>=50?"needs-improvement":"poor",O=u.filter(e=>e.antiPatterns.length>0||["none","no-store","no-cache","short"].includes(e.cacheStrategy));return{script:"Cache-Strategy-Analysis",status:"ok",count:u.length,corsLimitedAnalysis:A>0,details:{totalResources:u.length,actionableCount:O.length,headersAnalyzed:L,corsRestricted:A,excludedFromHeaders:D.length,headerCoveragePercent:N,cacheEfficiencyPercent:b,cacheEfficiencyRating:U,cacheEfficiencyResources:C.length,bandwidthSavedBytes:P,strategyDistribution:I,cdnDetected:w.length>0,cdnHitRate:S,cdnHitRateRating:null===S?null:S>=70?"good":"poor",antiPatternCount:H.length,duplicatesCostlyCount:k.length,uncompressedCount:z.length,protocolDistribution:$},items:O.sort((e,t)=>t.antiPatterns.length-e.antiPatterns.length||t.decodedBodySize-e.decodedBodySize).slice(0,50).map(e=>({shortName:e.shortName,host:e.host,type:e.type,firstParty:e.firstParty,cacheStrategy:e.cacheStrategy,sizeBytes:e.decodedBodySize,isCached:e.isCached,antiPatterns:e.antiPatterns.map(e=>e.id)})),issues:[...H.map(e=>({code:e.code,severity:e.severity,id:e.id,resource:e.resource,message:e.message,target:e.url})),...E?[E]:[]],recommendations:j}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js b/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js index 7697bac1..88a0de59 100644 --- a/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js +++ b/skills/webperf-loading/scripts/Client-Side-Redirect-Detection.js @@ -1,2 +1,2 @@ -// snippets/Loading/Client-Side-Redirect-Detection.js | sha256:14b01c57daf55b1b | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Client-Side-Redirect-Detection.js -(async()=>{const e=performance.getEntriesByType("navigation");if(0===e.length)return{script:"Client-Side-Redirect-Detection",status:"unsupported",error:"Navigation Timing not available"};const t=e[0],r=new URL(window.location.href),i=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")).filter(e=>{if("navigation"!==e.initiatorType)return!1;try{return new URL(e.name).origin===r.origin}catch{return!1}}),n=t.redirectCount||0,a=t.redirectEnd-t.redirectStart,s=!!window.history?.state,o=window.history.length,c=document.referrer?new URL(document.referrer):null,d=c&&c.origin===r.origin,h=c?.pathname||"",g=r.pathname,m=r.searchParams.has("redirect")||r.searchParams.has("from")||r.searchParams.has("origin");let l=!1;const u=[];d&&h!==g&&""!==h&&(l=!0,u.push({type:"Same-origin navigation",from:h,to:g,severity:"warning"})),i.length>0&&(l=!0,i.forEach(e=>{u.push({type:"Document navigation",url:e.name,duration:e.duration.toFixed(1)+"ms",severity:"error"})})),m&&u.push({type:"Redirect parameter in URL",params:Array.from(r.searchParams.entries()).filter(([e])=>e.toLowerCase().includes("redirect")||e.toLowerCase().includes("from")||e.toLowerCase().includes("origin")).map(([e,t])=>`${e}=${t}`).join(", "),severity:"info"}),s&&o>1&&u.push({type:"SPA router detected",historyLength:o,state:JSON.stringify(window.history.state)?.slice(0,100)||"{}",severity:"info"});const p=t.domContentLoadedEventEnd-t.startTime,y=t.transferSize||t.encodedBodySize||0;d&&p<500&&y<1e4&&u.push({type:"Fast minimal-content navigation",duration:p.toFixed(1)+"ms",size:(y/1024).toFixed(1)+" KB",note:"Small page that loads quickly - possible redirect page",severity:"warning"}),0===u.length||u.forEach((e,t)=>{Object.entries(e).forEach(([e,t])=>{})}),l&&i.length>0&&i.reduce((e,t)=>e+t.duration,0);const f=i.length>0;return(f||n>0)&&f&&h&&g&&"/"===h&&g.match(/^\/[a-z]{2}\//),{script:"Client-Side-Redirect-Detection",status:"ok",details:{serverRedirects:n,redirectTimeMs:Math.round(a),clientRedirectIndicators:u.length,documentNavigations:i.length,hasSPARouter:s,historyLength:o,currentPath:g,referrerPath:h},issues:[...n>0?[{severity:"warning",message:`${n} server-side redirect(s) detected (${Math.round(a)}ms)`}]:[],...i.length>0?[{severity:"error",message:`${i.length} same-origin document navigation(s) detected, likely a client-side redirect`}]:[],...u.filter(e=>"warning"===e.severity).map(e=>({severity:"warning",message:e.type}))]}})(); \ No newline at end of file +// snippets/Loading/Client-Side-Redirect-Detection.js | sha256:fd9ba5519aed1976 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Client-Side-Redirect-Detection.js +(async()=>{const e=performance.getEntriesByType("navigation");if(0===e.length)return{script:"Client-Side-Redirect-Detection",status:"unsupported",error:"Navigation Timing not available"};const t=e[0],r=new URL(window.location.href),i=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")).filter(e=>{if("navigation"!==e.initiatorType)return!1;try{return new URL(e.name).origin===r.origin}catch{return!1}}),n=t.redirectCount||0,a=t.redirectEnd-t.redirectStart,o=!!window.history?.state,s=window.history.length,c=document.referrer?new URL(document.referrer):null,d=c&&c.origin===r.origin,g=c?.pathname||"",h=r.pathname,m=r.searchParams.has("redirect")||r.searchParams.has("from")||r.searchParams.has("origin");let l=!1;const u=[];d&&g!==h&&""!==g&&(l=!0,u.push({code:"same-origin-navigation",type:"Same-origin navigation",from:g,to:h,severity:"warning"})),i.length>0&&(l=!0,i.forEach(e=>{u.push({type:"Document navigation",url:e.name,duration:e.duration.toFixed(1)+"ms",severity:"error"})})),m&&u.push({type:"Redirect parameter in URL",params:Array.from(r.searchParams.entries()).filter(([e])=>e.toLowerCase().includes("redirect")||e.toLowerCase().includes("from")||e.toLowerCase().includes("origin")).map(([e,t])=>`${e}=${t}`).join(", "),severity:"info"}),o&&s>1&&u.push({type:"SPA router detected",historyLength:s,state:JSON.stringify(window.history.state)?.slice(0,100)||"{}",severity:"info"});const p=t.domContentLoadedEventEnd-t.startTime,y=t.transferSize||t.encodedBodySize||0;d&&p<500&&y<1e4&&u.push({code:"fast-minimal-navigation",type:"Fast minimal-content navigation",duration:p.toFixed(1)+"ms",size:(y/1024).toFixed(1)+" KB",note:"Small page that loads quickly - possible redirect page",severity:"warning"}),0===u.length||u.forEach((e,t)=>{Object.entries(e).forEach(([e,t])=>{})}),l&&i.length>0&&i.reduce((e,t)=>e+t.duration,0);const f=i.length>0;return(f||n>0)&&f&&g&&h&&"/"===g&&h.match(/^\/[a-z]{2}\//),{script:"Client-Side-Redirect-Detection",status:"ok",details:{serverRedirects:n,redirectTimeMs:Math.round(a),clientRedirectIndicators:u.length,documentNavigations:i.length,hasSPARouter:o,historyLength:s,currentPath:h,referrerPath:g},issues:[...n>0?[{code:"server-redirects",severity:"warning",message:`${n} server-side redirect(s) detected (${Math.round(a)}ms)`}]:[],...i.length>0?[{code:"document-navigation",severity:"error",message:`${i.length} same-origin document navigation(s) detected, likely a client-side redirect`}]:[],...u.filter(e=>"warning"===e.severity).map(e=>({code:e.code,severity:"warning",message:e.type}))]}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Compression-Audit.js b/skills/webperf-loading/scripts/Compression-Audit.js index 98df3e20..51bfde49 100644 --- a/skills/webperf-loading/scripts/Compression-Audit.js +++ b/skills/webperf-loading/scripts/Compression-Audit.js @@ -1,2 +1,2 @@ -// snippets/Loading/Compression-Audit.js | sha256:4e6fbba6a011bbb2 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Compression-Audit.js -(()=>{const e={js:.7,css:.7,html:.7,json:.7,svg:.7,xml:.7,font:.5},t={js:"js",mjs:"js",cjs:"js",css:"css",html:"html",htm:"html",json:"json",webmanifest:"json",svg:"svg",xml:"xml",rss:"xml",atom:"xml",ttf:"font",otf:"font",eot:"font"},n=[...performance.getEntriesByType("navigation"),...performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))],s=n.some(e=>"contentEncoding"in e),o=[];n.forEach(e=>{const n=(e=>{try{return new URL(e)}catch{return null}})(e.name);if(!n||!/^https?:$/.test(n.protocol))return;const s=((e,n)=>{if("navigation"===e.entryType)return"html";const s=n.pathname.split(".").pop().toLowerCase();return t[s]?t[s]:"script"===e.initiatorType?"js":null})(e,n);s&&o.push({entry:e,url:n,type:s})});const i=[],r=[];let c=0,a=0;const d={};o.forEach(({entry:t,url:n,type:s})=>{const o=t.decodedBodySize||0,l=t.encodedBodySize||0;if(0===o&&n.origin!==location.origin)return void c++;if(o<1024)return void a++;const u=(t.contentEncoding||"").toLowerCase();if(u?"identity"!==u:lt.estimatedSavingsBytes-e.estimatedSavingsBytes);const l=r.reduce((e,t)=>e+t.decodedBytes,0),u=r.reduce((e,t)=>e+t.estimatedSavingsBytes,0),m=c>0,p=[];return r.length>0&&p.push({severity:"warning",message:`${r.length} text resource(s) served without compression; enabling gzip or brotli could save about ${function(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}(u)}`}),m&&p.push({severity:"info",message:`${c} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`}),!s&&o.length>0&&p.push({severity:"info",message:"PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize"}),{script:"Compression-Audit",status:"ok",count:r.length,corsLimitedAnalysis:m,details:{totalTextResources:o.length,compressedCount:i.length,uncompressedCount:r.length,sizeUnknownCount:c,skippedSmallCount:a,totalUncompressedBytes:l,estimatedSavingsBytes:u,byEncoding:d,contentEncodingSupported:s},items:r.slice(0,50),issues:p}})(); \ No newline at end of file +// snippets/Loading/Compression-Audit.js | sha256:3ff52ec890aa8366 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Compression-Audit.js +(()=>{const e={js:.7,css:.7,html:.7,json:.7,svg:.7,xml:.7,font:.5},t={js:"js",mjs:"js",cjs:"js",css:"css",html:"html",htm:"html",json:"json",webmanifest:"json",svg:"svg",xml:"xml",rss:"xml",atom:"xml",ttf:"font",otf:"font",eot:"font"},n=[...performance.getEntriesByType("navigation"),...performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))],s=n.some(e=>"contentEncoding"in e),o=[];n.forEach(e=>{const n=(e=>{try{return new URL(e)}catch{return null}})(e.name);if(!n||!/^https?:$/.test(n.protocol))return;const s=((e,n)=>{if("navigation"===e.entryType)return"html";const s=n.pathname.split(".").pop().toLowerCase();return t[s]?t[s]:"script"===e.initiatorType?"js":null})(e,n);s&&o.push({entry:e,url:n,type:s})});const i=[],r=[];let c=0,a=0;const d={};o.forEach(({entry:t,url:n,type:s})=>{const o=t.decodedBodySize||0,l=t.encodedBodySize||0;if(0===o&&n.origin!==location.origin)return void c++;if(o<1024)return void a++;const u=(t.contentEncoding||"").toLowerCase();if(u?"identity"!==u:lt.estimatedSavingsBytes-e.estimatedSavingsBytes);const l=r.reduce((e,t)=>e+t.decodedBytes,0),u=r.reduce((e,t)=>e+t.estimatedSavingsBytes,0),m=c>0,p=[];return r.length>0&&p.push({code:"uncompressed",severity:"warning",message:`${r.length} text resource(s) served without compression; enabling gzip or brotli could save about ${function(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const t=["B","KB","MB","GB"],n=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1));return(e/Math.pow(1024,n)).toFixed(1)+" "+t[n]}(u)}`}),m&&p.push({code:"unknown-size",severity:"info",message:`${c} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`}),!s&&o.length>0&&p.push({code:"content-encoding-unavailable",severity:"info",message:"PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize"}),{script:"Compression-Audit",status:"ok",count:r.length,corsLimitedAnalysis:m,details:{totalTextResources:o.length,compressedCount:i.length,uncompressedCount:r.length,sizeUnknownCount:c,skippedSmallCount:a,totalUncompressedBytes:l,estimatedSavingsBytes:u,byEncoding:d,contentEncodingSupported:s},items:r.slice(0,50),issues:p}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js b/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js index 67c89709..e5aff89a 100644 --- a/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js +++ b/skills/webperf-loading/scripts/Prefetch-Resource-Validation.js @@ -1,2 +1,2 @@ -// snippets/Loading/Prefetch-Resource-Validation.js | sha256:a6e3aeeecf4f26ce | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Prefetch-Resource-Validation.js -(()=>{const e=Array.from(document.querySelectorAll('link[rel="prefetch"]'));if(0===e.length)return{script:"Prefetch-Resource-Validation",status:"ok",count:0,items:[],issues:[{severity:"info",message:'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.'}]};const s=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),t=new Map;s.forEach(e=>{try{const s=new URL(e.name,location.origin).href;t.has(s)||t.set(s,[]),t.get(s).push(e)}catch{}});const i=new Set(["script","style","font","image","video","audio","document","fetch","track","worker"]),r=[],n=[],a=new Set;let o=0,l=0;e.forEach(e=>{const s=e.href,d=e.getAttribute("as")||"unknown",h=s.split("/").pop()?.split("?")[0]?.split("#")[0]||s;let p;try{p=new URL(s,location.origin).href}catch{p=s}const c=t.get(p)||[],g=c[0],u={link:e,href:s,shortUrl:h,as:d,size:0,transferSize:0,duration:0,loaded:!1,isCurrentPage:!1,warnings:[]};if("unknown"===d?u.warnings.push({type:"missing-as",severity:"warning",message:"Missing 'as' attribute - browser cannot apply correct MIME type matching"}):i.has(d)||u.warnings.push({type:"invalid-as",severity:"warning",message:`Invalid 'as' value: "${d}" - should be one of: ${Array.from(i).join(", ")}`}),a.has(p)&&u.warnings.push({type:"duplicate-prefetch",severity:"warning",message:"Duplicate prefetch - this URL is prefetched multiple times"}),a.add(p),g){if(u.loaded=!0,u.size=g.decodedBodySize||0,u.transferSize=g.transferSize||0,u.duration=g.duration||0,u.cacheStatus=g.encodedBodySize>0&&0===g.transferSize?"cached":0===g.encodedBodySize&&0===g.transferSize?"unknown (CORS)":"network",o+=u.size,l+=u.transferSize,c.some(e=>"link"!==e.initiatorType&&e.startTime<5e3)&&(u.isCurrentPage=!0,u.warnings.push({type:"wrong-hint",severity:"error",message:"Resource used on current page - should use preload instead"})),u.size>512e3){const e=(u.size/1048576).toFixed(2);u.warnings.push({type:"large-file",severity:"warning",message:`Large file (${e}MB) - consider if prefetch is appropriate`})}let e=!1,s="";"video"===d||"audio"===d?(e=!0,s="Video/audio files are typically too large for prefetch"):"image"===d&&u.size>204800?(e=!0,s=`Image is ${(u.size/1024).toFixed(0)}KB (>200KB threshold)`):"script"===d&&u.size>1048576&&(e=!0,s=`Script is ${(u.size/1024).toFixed(0)}KB (>1MB threshold)`),e&&u.warnings.push({type:"inappropriate-type",severity:"warning",message:`Type "${d}" may not be suitable for prefetch: ${s}`})}else u.warnings.push({type:"not-loaded",severity:"info",message:"Not loaded yet or failed to load"});u.warnings.length>0?r.push(u):n.push(u)});const d=[];e.length>10&&d.push({type:"excessive-count",severity:"warning",message:`${e.length} prefetch hints found (recommended: <10)`,explanation:"Excessive prefetch can waste bandwidth, especially on mobile networks"});const h=(o/1048576).toFixed(2);(l/1048576).toFixed(2),o>5242880?d.push({type:"excessive-size",severity:"error",message:`Total prefetch size: ${h}MB (critical threshold exceeded)`,explanation:"This is very high and will significantly impact mobile users"}):o>2097152&&d.push({type:"high-size",severity:"warning",message:`Total prefetch size: ${h}MB (warning threshold exceeded)`,explanation:"Consider reducing prefetch to improve mobile experience"});const p=[...r,...n].filter(e=>e.loaded).length,c=r.filter(e=>e.warnings.some(e=>"error"===e.severity||"warning"===e.severity)).length+d.length;d.length>0&&d.forEach(e=>{});const g=r.filter(e=>e.warnings.some(e=>"error"===e.severity)),u=r.filter(e=>!e.warnings.some(e=>"error"===e.severity)&&e.warnings.some(e=>"warning"===e.severity)),f=r.filter(e=>e.warnings.every(e=>"info"===e.severity));return g.length>0&&(g.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),g.forEach((e,s)=>{e.warnings.forEach(e=>{})})),u.length>0&&u.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),f.length>0&&f.map(e=>({Resource:e.shortUrl,Type:e.as,Status:e.warnings.map(e=>e.message).join("; ")})),n.length>0&&n.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Status:e.loaded?"✅ Loaded":"Pending",Cache:e.cacheStatus||"N/A"})),{script:"Prefetch-Resource-Validation",status:"ok",count:e.length,details:{loadedCount:p,validCount:n.length,issueCount:c,totalSizeBytes:o,totalTransferBytes:l},items:[...n,...r].map(e=>({href:e.shortUrl,as:e.as,sizeBytes:e.size,loaded:e.loaded,isCurrentPage:e.isCurrentPage||!1})),issues:[...d.map(e=>({severity:"error"===e.severity?"error":"warning",message:e.message})),...r.flatMap(e=>e.warnings.filter(e=>"info"!==e.severity).map(s=>({severity:s.severity,message:`${e.shortUrl}: ${s.message}`})))]}})(); \ No newline at end of file +// snippets/Loading/Prefetch-Resource-Validation.js | sha256:f1c557ac1ad1c6d5 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Prefetch-Resource-Validation.js +(()=>{const e=Array.from(document.querySelectorAll('link[rel="prefetch"]'));if(0===e.length)return{script:"Prefetch-Resource-Validation",status:"ok",count:0,items:[],issues:[{code:"no-prefetch",severity:"info",message:'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.'}]};const s=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),t=new Map;s.forEach(e=>{try{const s=new URL(e.name,location.origin).href;t.has(s)||t.set(s,[]),t.get(s).push(e)}catch{}});const i=new Set(["script","style","font","image","video","audio","document","fetch","track","worker"]),r=[],n=[],a=new Set;let o=0,d=0;e.forEach(e=>{const s=e.href,c=e.getAttribute("as")||"unknown",l=s.split("/").pop()?.split("?")[0]?.split("#")[0]||s;let p;try{p=new URL(s,location.origin).href}catch{p=s}const h=t.get(p)||[],g=h[0],u={link:e,href:s,shortUrl:l,as:c,size:0,transferSize:0,duration:0,loaded:!1,isCurrentPage:!1,warnings:[]};if("unknown"===c?u.warnings.push({code:"missing-as",type:"missing-as",severity:"warning",message:"Missing 'as' attribute - browser cannot apply correct MIME type matching"}):i.has(c)||u.warnings.push({code:"invalid-as",type:"invalid-as",severity:"warning",message:`Invalid 'as' value: "${c}" - should be one of: ${Array.from(i).join(", ")}`}),a.has(p)&&u.warnings.push({code:"duplicate-prefetch",type:"duplicate-prefetch",severity:"warning",message:"Duplicate prefetch - this URL is prefetched multiple times"}),a.add(p),g){if(u.loaded=!0,u.size=g.decodedBodySize||0,u.transferSize=g.transferSize||0,u.duration=g.duration||0,u.cacheStatus=g.encodedBodySize>0&&0===g.transferSize?"cached":0===g.encodedBodySize&&0===g.transferSize?"unknown (CORS)":"network",o+=u.size,d+=u.transferSize,h.some(e=>"link"!==e.initiatorType&&e.startTime<5e3)&&(u.isCurrentPage=!0,u.warnings.push({code:"used-on-current-page",type:"wrong-hint",severity:"error",message:"Resource used on current page - should use preload instead"})),u.size>512e3){const e=(u.size/1048576).toFixed(2);u.warnings.push({code:"large-file",type:"large-file",severity:"warning",message:`Large file (${e}MB) - consider if prefetch is appropriate`})}let e=!1,s="";"video"===c||"audio"===c?(e=!0,s="Video/audio files are typically too large for prefetch"):"image"===c&&u.size>204800?(e=!0,s=`Image is ${(u.size/1024).toFixed(0)}KB (>200KB threshold)`):"script"===c&&u.size>1048576&&(e=!0,s=`Script is ${(u.size/1024).toFixed(0)}KB (>1MB threshold)`),e&&u.warnings.push({code:"inappropriate-type",type:"inappropriate-type",severity:"warning",message:`Type "${c}" may not be suitable for prefetch: ${s}`})}else u.warnings.push({type:"not-loaded",severity:"info",message:"Not loaded yet or failed to load"});u.warnings.length>0?r.push(u):n.push(u)});const c=[];e.length>10&&c.push({code:"too-many-prefetches",type:"excessive-count",severity:"warning",message:`${e.length} prefetch hints found (recommended: <10)`,explanation:"Excessive prefetch can waste bandwidth, especially on mobile networks"});const l=(o/1048576).toFixed(2);(d/1048576).toFixed(2),o>5242880?c.push({code:"large-total-size",type:"excessive-size",severity:"error",message:`Total prefetch size: ${l}MB (critical threshold exceeded)`,explanation:"This is very high and will significantly impact mobile users"}):o>2097152&&c.push({code:"large-total-size",type:"high-size",severity:"warning",message:`Total prefetch size: ${l}MB (warning threshold exceeded)`,explanation:"Consider reducing prefetch to improve mobile experience"});const p=[...r,...n].filter(e=>e.loaded).length,h=r.filter(e=>e.warnings.some(e=>"error"===e.severity||"warning"===e.severity)).length+c.length;c.length>0&&c.forEach(e=>{});const g=r.filter(e=>e.warnings.some(e=>"error"===e.severity)),u=r.filter(e=>!e.warnings.some(e=>"error"===e.severity)&&e.warnings.some(e=>"warning"===e.severity)),f=r.filter(e=>e.warnings.every(e=>"info"===e.severity));return g.length>0&&(g.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),g.forEach((e,s)=>{e.warnings.forEach(e=>{})})),u.length>0&&u.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Issue:e.warnings.map(e=>e.message).join("; ")})),f.length>0&&f.map(e=>({Resource:e.shortUrl,Type:e.as,Status:e.warnings.map(e=>e.message).join("; ")})),n.length>0&&n.map(e=>({Resource:e.shortUrl,Type:e.as,Size:e.size>0?`${(e.size/1024).toFixed(1)} KB`:"Unknown",Duration:e.duration>0?`${e.duration.toFixed(0)} ms`:"N/A",Status:e.loaded?"✅ Loaded":"Pending",Cache:e.cacheStatus||"N/A"})),{script:"Prefetch-Resource-Validation",status:"ok",count:e.length,details:{loadedCount:p,validCount:n.length,issueCount:h,totalSizeBytes:o,totalTransferBytes:d},items:[...n,...r].map(e=>({href:e.shortUrl,as:e.as,sizeBytes:e.size,loaded:e.loaded,isCurrentPage:e.isCurrentPage||!1})),issues:[...c.map(e=>({code:e.code,severity:"error"===e.severity?"error":"warning",message:e.message})),...r.flatMap(e=>e.warnings.filter(e=>"info"!==e.severity).map(s=>({code:s.code,severity:s.severity,message:`${e.shortUrl}: ${s.message}`,target:e.href})))]}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Priority-Hints-Audit.js b/skills/webperf-loading/scripts/Priority-Hints-Audit.js index 55aa6b47..771d1743 100644 --- a/skills/webperf-loading/scripts/Priority-Hints-Audit.js +++ b/skills/webperf-loading/scripts/Priority-Hints-Audit.js @@ -1,2 +1,2 @@ -// snippets/Loading/Priority-Hints-Audit.js | sha256:787ce6a286010f32 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Priority-Hints-Audit.js -(()=>{function t(t){const e=t.getBoundingClientRect();return e.top0&&e.left0&&e.width>0&&e.height>0}function e(t){const e=t.src||t.href||t.getAttribute("href")||"";return e.split("/").pop()?.split("?")[0]||e.slice(-50)||"(unknown)"}const r=new Map;function i(t){const e=t.src||t.href||t.getAttribute("href")||"";if(!e)return null;try{return r.get(new URL(e,location.origin).href)||null}catch{return null}}performance.getEntriesByType("resource").filter(t=>!t.name.endsWith("#webperf-snippets")).forEach(t=>{try{r.set(new URL(t.name,location.origin).href,t)}catch{}});const o=Array.from(document.querySelectorAll("[fetchpriority]")),n=o.filter(t=>"IMG"!==t.tagName),s=(o.filter(t=>"IMG"===t.tagName),Array.from(document.querySelectorAll('link[rel="preload"][fetchpriority]'))),c=function(){const e=Array.from(document.querySelectorAll("img")).filter(t);let r=null,i=0;return e.forEach(t=>{const{width:e,height:o}=t.getBoundingClientRect(),n=e*o;n>i&&(i=n,r=t)}),r}(),l=[];if(s.filter(t=>"low"===t.getAttribute("fetchpriority")).forEach(t=>{l.push({severity:"error",element:t,resource:e(t),message:'preload + fetchpriority="low" is contradictory',detail:'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.',fix:'Remove fetchpriority="low", or use fetchpriority="high" if the resource is truly critical'})}),o.filter(t=>"low"===t.getAttribute("fetchpriority")).forEach(t=>{const r=i(t);r&&r.startTime<500&&l.push({severity:"warning",element:t,resource:e(t),message:`fetchpriority="low" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.",fix:'Verify the resource is non-critical, or remove fetchpriority="low"'})}),o.filter(t=>"high"===t.getAttribute("fetchpriority")).forEach(t=>{const r=i(t);r&&r.startTime>3e3&&l.push({severity:"warning",element:t,resource:e(t),message:`fetchpriority="high" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.",fix:"Move the element earlier in the HTML or investigate render-blocking dependencies"})}),c&&"high"!==c.getAttribute("fetchpriority")){const t=c.getAttribute("fetchpriority");l.push({severity:"warning",element:c,resource:(c.currentSrc||c.src).split("/").pop()?.split("?")[0]||"LCP image",message:"LCP candidate image "+(t?`has fetchpriority="${t}"`:"has no fetchpriority set"),detail:'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".',fix:'Add fetchpriority="high" to the LCP image to improve Largest Contentful Paint'})}const h=o.filter(t=>"high"===t.getAttribute("fetchpriority")).length,a=o.filter(t=>"low"===t.getAttribute("fetchpriority")).length,u=o.filter(t=>"auto"===t.getAttribute("fetchpriority")).length;if(0===o.length&&!c)return{script:"Priority-Hints-Audit",status:"ok",count:0,items:[],issues:[]};c&&c.getAttribute("fetchpriority"),n.length>0&&n.forEach((t,e)=>{});const f=l.filter(t=>"error"===t.severity),g=l.filter(t=>"warning"===t.severity);return l.length>0&&[...f,...g].forEach(t=>{}),{script:"Priority-Hints-Audit",status:"ok",count:o.length,details:{highCount:h,lowCount:a,autoCount:u,preloadsWithPriorityCount:s.length,lcpCandidateHasFetchpriority:c?"high"===c.getAttribute("fetchpriority"):null},items:o.map(t=>({tag:t.tagName.toLowerCase(),fetchpriority:t.getAttribute("fetchpriority"),resource:(t.src||t.href||t.getAttribute("href")||"").split("/").pop()?.split("?")[0]||""})),issues:l.map(t=>({severity:t.severity,message:t.fix?`${t.message.replace(/\.$/,"")}. Fix: ${t.fix}`:t.message}))}})(); \ No newline at end of file +// snippets/Loading/Priority-Hints-Audit.js | sha256:e88ca8e26ddc425e | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Priority-Hints-Audit.js +(()=>{function e(e){const t=e.getBoundingClientRect();return t.top0&&t.left0&&t.width>0&&t.height>0}function t(e){const t=e.src||e.href||e.getAttribute("href")||"";return t.split("/").pop()?.split("?")[0]||t.slice(-50)||"(unknown)"}const r=new Map;function i(e){const t=e.src||e.href||e.getAttribute("href")||"";if(!t)return null;try{return r.get(new URL(t,location.origin).href)||null}catch{return null}}performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")).forEach(e=>{try{r.set(new URL(e.name,location.origin).href,e)}catch{}});const o=Array.from(document.querySelectorAll("[fetchpriority]")),n=o.filter(e=>"IMG"!==e.tagName),c=(o.filter(e=>"IMG"===e.tagName),Array.from(document.querySelectorAll('link[rel="preload"][fetchpriority]'))),s=function(){const t=Array.from(document.querySelectorAll("img")).filter(e);let r=null,i=0;return t.forEach(e=>{const{width:t,height:o}=e.getBoundingClientRect(),n=t*o;n>i&&(i=n,r=e)}),r}(),l=[];if(c.filter(e=>"low"===e.getAttribute("fetchpriority")).forEach(e=>{l.push({code:"preload-low-priority",severity:"error",element:e,url:e.href,resource:t(e),message:'preload + fetchpriority="low" is contradictory',detail:'preload signals this resource is critical for the current page; fetchpriority="low" contradicts that.',fix:'Remove fetchpriority="low", or use fetchpriority="high" if the resource is truly critical'})}),o.filter(e=>"low"===e.getAttribute("fetchpriority")).forEach(e=>{const r=i(e);r&&r.startTime<500&&l.push({code:"low-priority-loaded-early",severity:"warning",element:e,url:e.src||e.href,resource:t(e),message:`fetchpriority="low" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"This resource loaded very early. If it's genuinely non-critical, the browser is deprioritizing it correctly. Otherwise, reconsider the priority.",fix:'Verify the resource is non-critical, or remove fetchpriority="low"'})}),o.filter(e=>"high"===e.getAttribute("fetchpriority")).forEach(e=>{const r=i(e);r&&r.startTime>3e3&&l.push({code:"high-priority-loaded-late",severity:"warning",element:e,url:e.src||e.href,resource:t(e),message:`fetchpriority="high" but loaded at ${r.startTime.toFixed(0)}ms`,detail:"High-priority resources should load early. A late start may indicate render-blocking dependencies or incorrect placement in the document.",fix:"Move the element earlier in the HTML or investigate render-blocking dependencies"})}),s&&"high"!==s.getAttribute("fetchpriority")){const e=s.getAttribute("fetchpriority");l.push({code:"lcp-no-fetchpriority",severity:"warning",element:s,url:s.currentSrc||s.src,resource:(s.currentSrc||s.src).split("/").pop()?.split("?")[0]||"LCP image",message:"LCP candidate image "+(e?`has fetchpriority="${e}"`:"has no fetchpriority set"),detail:'The largest visible image is likely the LCP element and benefits most from fetchpriority="high".',fix:'Add fetchpriority="high" to the LCP image to improve Largest Contentful Paint'})}const h=o.filter(e=>"high"===e.getAttribute("fetchpriority")).length,a=o.filter(e=>"low"===e.getAttribute("fetchpriority")).length,u=o.filter(e=>"auto"===e.getAttribute("fetchpriority")).length;if(0===o.length&&!s)return{script:"Priority-Hints-Audit",status:"ok",count:0,items:[],issues:[]};s&&s.getAttribute("fetchpriority"),n.length>0&&n.forEach((e,t)=>{});const f=l.filter(e=>"error"===e.severity),g=l.filter(e=>"warning"===e.severity);return l.length>0&&[...f,...g].forEach(e=>{}),{script:"Priority-Hints-Audit",status:"ok",count:o.length,details:{highCount:h,lowCount:a,autoCount:u,preloadsWithPriorityCount:c.length,lcpCandidateHasFetchpriority:s?"high"===s.getAttribute("fetchpriority"):null},items:o.map(e=>({tag:e.tagName.toLowerCase(),fetchpriority:e.getAttribute("fetchpriority"),resource:(e.src||e.href||e.getAttribute("href")||"").split("/").pop()?.split("?")[0]||""})),issues:l.map(e=>({code:e.code,severity:e.severity,message:e.fix?`${e.message.replace(/\.$/,"")}. Fix: ${e.fix}`:e.message,...e.url?{target:e.url}:{}}))}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Resource-Hints-Validation.js b/skills/webperf-loading/scripts/Resource-Hints-Validation.js index 4af2db6e..f33aa62e 100644 --- a/skills/webperf-loading/scripts/Resource-Hints-Validation.js +++ b/skills/webperf-loading/scripts/Resource-Hints-Validation.js @@ -1,2 +1,2 @@ -// snippets/Loading/Resource-Hints-Validation.js | sha256:3153640241cc575d | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints-Validation.js -(()=>{document;const e=Array.from(document.querySelectorAll('link[rel="preload"]')),t=Array.from(document.querySelectorAll('link[rel="preconnect"]')),n=Array.from(document.querySelectorAll('link[rel="dns-prefetch"]')),r=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),a=e=>{try{const t=e.startsWith("http")?e:new URL(e,location.href).href;return new URL(t).origin}catch{return null}},o=e=>{try{const t=new URL(e,location.origin);return t.origin+t.pathname+t.search}catch{return e}},s=new Set(r.map(e=>o(e.name))),i=new Set(r.map(e=>a(e.name)).filter(Boolean));i.add(location.origin);const c=document.querySelectorAll("img[src]");if(0===e.length);else{const t=e.map(e=>{const t=e.href,n=e.getAttribute("as")||"unknown",a=e.getAttribute("media"),i=o(t),c=t.split("/").pop()?.split("?").shift()||t,l=s.has(i),h=r.find(e=>o(e.name)===i);let d=!0;if(a)try{d=window.matchMedia(a).matches}catch{d=!1}return{shortUrl:c,href:t,as:n,media:a,mediaMatches:d,wasUsed:l,size:h?(f=h.decodedBodySize||h.transferSize,f?(f/1024).toFixed(1)+" KB":"N/A"):"N/A",link:e};var f}),n=t.filter(e=>e.wasUsed),a=(t.filter(e=>!e.wasUsed&&(!e.media||e.mediaMatches)),t.filter(e=>!e.wasUsed&&e.media&&!e.mediaMatches)),i=e.length-a.length;i>0&&Math.round(n.length/i*100)}const l=new Set;if(c.forEach(e=>{try{const t=a(e.src);t&&t!==location.origin&&!i.has(t)&&l.add(t)}catch{}}),0===t.length);else{const e=t.map(e=>{const t=e.href,n=a(t),o=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>a(e.name)===n).length;return{domain:n,href:t,wasUsed:o,isLazyLoaded:s,requestCount:c,link:e}}),n=e.filter(e=>e.wasUsed),o=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((n.length+o.length)/t.length*100)}if(0===n.length);else{const e=n.map(e=>{const t=e.href,n=a(t),o=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>a(e.name)===n).length;return{domain:n,href:t,wasUsed:o,isLazyLoaded:s,requestCount:c,link:e}}),t=e.filter(e=>e.wasUsed),o=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((t.length+o.length)/n.length*100)}const h=new Set(t.map(e=>a(e.href)).filter(Boolean)),d=new Set(n.map(e=>a(e.href)).filter(Boolean)),f=Array.from(h).filter(e=>d.has(e));f.length>0&&f.forEach(e=>{});const m=new Set;c.forEach(e=>{try{const t=a(e.src);t&&t!==location.origin&&m.add(t)}catch{}});const u=Array.from(m).filter(e=>!i.has(e));u.length>0&&u.map(e=>{const t=Array.from(c).filter(t=>{try{return a(t.src)===e}catch{return!1}}).length;return{domain:e,imgCount:t}}).filter(e=>e.domain&&"null"!==e.domain).sort((e,t)=>t.imgCount-e.imgCount).forEach(e=>{});const g=new Set([...t.map(e=>a(e.href)),...n.map(e=>a(e.href))]),p={};r.forEach(e=>{const t=a(e.name);t&&t!==location.origin&&(p[t]=(p[t]||0)+1)});const w=Object.entries(p).filter(([e,t])=>t>=2&&!g.has(e)).sort((e,t)=>t[1]-e[1]),y=[...w.filter(([,e])=>e>=5).slice(0,3),...w.filter(([,e])=>e>=2&&e<5).slice(0,5)];w.length>0&&y.forEach(([e,t])=>{});const L=new Set;c.forEach(e=>{try{const t=a(e.src);t&&t!==location.origin&&!i.has(t)&&L.add(t)}catch{}});const U=e=>{const t=a(e.href);return t&&!i.has(t)&&!L.has(t)},A=[...e.filter(e=>!s.has(o(e.href))&&!(e=>{const t=e.getAttribute("media");if(!t)return!1;try{return!window.matchMedia(t).matches}catch{return!0}})(e)).map(e=>({domain:a(e.href),href:e.href,requestCount:0,action:"remove-unused-preload",recommendedHint:null})),...t.filter(U).map(e=>({domain:a(e.href),href:e.href,requestCount:0,action:"remove-unused-preconnect",recommendedHint:null})),...n.filter(U).map(e=>({domain:a(e.href),href:e.href,requestCount:0,action:"remove-unused-dns-prefetch",recommendedHint:null}))],S=A.length;return{script:"Resource-Hints-Validation",status:"ok",count:e.length+t.length+n.length,details:{preloadCount:e.length,preconnectCount:t.length,dnsPrefetchCount:n.length,totalResourcesLoaded:r.length,unusedHints:S,missingPreconnects:w.length,redundantHints:f.length},items:[...A,...w.map(([e,t])=>({domain:e,href:null,requestCount:t,action:t>=5?"add-preconnect":"add-dns-prefetch",recommendedHint:t>=5?"preconnect":"dns-prefetch"})),...f.map(e=>({domain:e,href:null,requestCount:p[e]||0,action:"remove-dns-prefetch",recommendedHint:"preconnect"}))].slice(0,50),issues:[...S>0?[{severity:"warning",message:`${S} unused hint(s) waste bandwidth`}]:[],...w.length>0?[{severity:"info",message:`${w.length} domain(s) could benefit from connection hints`}]:[],...f.length>0?[{severity:"warning",message:`${f.length} domain(s) have both preconnect and dns-prefetch (redundant)`}]:[]]}})(); \ No newline at end of file +// snippets/Loading/Resource-Hints-Validation.js | sha256:dbffcd164a73b907 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints-Validation.js +(()=>{document;const e=Array.from(document.querySelectorAll('link[rel="preload"]')),t=Array.from(document.querySelectorAll('link[rel="preconnect"]')),n=Array.from(document.querySelectorAll('link[rel="dns-prefetch"]')),r=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),o=e=>{try{const t=e.startsWith("http")?e:new URL(e,location.href).href;return new URL(t).origin}catch{return null}},a=e=>{try{const t=new URL(e,location.origin);return t.origin+t.pathname+t.search}catch{return e}},s=new Set(r.map(e=>a(e.name))),i=new Set(r.map(e=>o(e.name)).filter(Boolean));i.add(location.origin);const c=document.querySelectorAll("img[src]");if(0===e.length);else{const t=e.map(e=>{const t=e.href,n=e.getAttribute("as")||"unknown",o=e.getAttribute("media"),i=a(t),c=t.split("/").pop()?.split("?").shift()||t,l=s.has(i),d=r.find(e=>a(e.name)===i);let h=!0;if(o)try{h=window.matchMedia(o).matches}catch{h=!1}return{shortUrl:c,href:t,as:n,media:o,mediaMatches:h,wasUsed:l,size:d?(m=d.decodedBodySize||d.transferSize,m?(m/1024).toFixed(1)+" KB":"N/A"):"N/A",link:e};var m}),n=t.filter(e=>e.wasUsed),o=(t.filter(e=>!e.wasUsed&&(!e.media||e.mediaMatches)),t.filter(e=>!e.wasUsed&&e.media&&!e.mediaMatches)),i=e.length-o.length;i>0&&Math.round(n.length/i*100)}const l=new Set;if(c.forEach(e=>{try{const t=o(e.src);t&&t!==location.origin&&!i.has(t)&&l.add(t)}catch{}}),0===t.length);else{const e=t.map(e=>{const t=e.href,n=o(t),a=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>o(e.name)===n).length;return{domain:n,href:t,wasUsed:a,isLazyLoaded:s,requestCount:c,link:e}}),n=e.filter(e=>e.wasUsed),a=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((n.length+a.length)/t.length*100)}if(0===n.length);else{const e=n.map(e=>{const t=e.href,n=o(t),a=!!n&&i.has(n),s=!!n&&l.has(n),c=r.filter(e=>o(e.name)===n).length;return{domain:n,href:t,wasUsed:a,isLazyLoaded:s,requestCount:c,link:e}}),t=e.filter(e=>e.wasUsed),a=e.filter(e=>!e.wasUsed&&e.isLazyLoaded);e.filter(e=>!e.wasUsed&&!e.isLazyLoaded),Math.round((t.length+a.length)/n.length*100)}const d=new Set(t.map(e=>o(e.href)).filter(Boolean)),h=new Set(n.map(e=>o(e.href)).filter(Boolean)),m=Array.from(d).filter(e=>h.has(e));m.length>0&&m.forEach(e=>{});const f=new Set;c.forEach(e=>{try{const t=o(e.src);t&&t!==location.origin&&f.add(t)}catch{}});const u=Array.from(f).filter(e=>!i.has(e));u.length>0&&u.map(e=>{const t=Array.from(c).filter(t=>{try{return o(t.src)===e}catch{return!1}}).length;return{domain:e,imgCount:t}}).filter(e=>e.domain&&"null"!==e.domain).sort((e,t)=>t.imgCount-e.imgCount).forEach(e=>{});const g=new Set([...t.map(e=>o(e.href)),...n.map(e=>o(e.href))]),p={};r.forEach(e=>{const t=o(e.name);t&&t!==location.origin&&(p[t]=(p[t]||0)+1)});const w=Object.entries(p).filter(([e,t])=>t>=2&&!g.has(e)).sort((e,t)=>t[1]-e[1]),y=[...w.filter(([,e])=>e>=5).slice(0,3),...w.filter(([,e])=>e>=2&&e<5).slice(0,5)];w.length>0&&y.forEach(([e,t])=>{});const L=new Set;c.forEach(e=>{try{const t=o(e.src);t&&t!==location.origin&&!i.has(t)&&L.add(t)}catch{}});const U=e=>{const t=o(e.href);return t&&!i.has(t)&&!L.has(t)},A=[...e.filter(e=>!s.has(a(e.href))&&!(e=>{const t=e.getAttribute("media");if(!t)return!1;try{return!window.matchMedia(t).matches}catch{return!0}})(e)).map(e=>({domain:o(e.href),href:e.href,requestCount:0,action:"remove-unused-preload",recommendedHint:null})),...t.filter(U).map(e=>({domain:o(e.href),href:e.href,requestCount:0,action:"remove-unused-preconnect",recommendedHint:null})),...n.filter(U).map(e=>({domain:o(e.href),href:e.href,requestCount:0,action:"remove-unused-dns-prefetch",recommendedHint:null}))],S=A.length;return{script:"Resource-Hints-Validation",status:"ok",count:e.length+t.length+n.length,details:{preloadCount:e.length,preconnectCount:t.length,dnsPrefetchCount:n.length,totalResourcesLoaded:r.length,unusedHints:S,missingPreconnects:w.length,redundantHints:m.length},items:[...A,...w.map(([e,t])=>({domain:e,href:null,requestCount:t,action:t>=5?"add-preconnect":"add-dns-prefetch",recommendedHint:t>=5?"preconnect":"dns-prefetch"})),...m.map(e=>({domain:e,href:null,requestCount:p[e]||0,action:"remove-dns-prefetch",recommendedHint:"preconnect"}))].slice(0,50),issues:[...S>0?[{code:"unused-hints",severity:"warning",message:`${S} unused hint(s) waste bandwidth`}]:[],...w.length>0?[{code:"missing-connection-hints",severity:"info",message:`${w.length} domain(s) could benefit from connection hints`}]:[],...m.length>0?[{code:"redundant-hints",severity:"warning",message:`${m.length} domain(s) have both preconnect and dns-prefetch (redundant)`}]:[]]}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Resource-Hints.js b/skills/webperf-loading/scripts/Resource-Hints.js index 9d5bcb0e..f4ee8410 100644 --- a/skills/webperf-loading/scripts/Resource-Hints.js +++ b/skills/webperf-loading/scripts/Resource-Hints.js @@ -1,2 +1,2 @@ -// snippets/Loading/Resource-Hints.js | sha256:7bb7f76a112a5b76 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints.js -(()=>{function e(e){return e.name.endsWith("#webperf-snippets")}function r(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const r=["B","KB","MB","GB"],t=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),r.length-1));return(e/Math.pow(1024,t)).toFixed(1)+" "+r[t]}const t=[{rel:"preload",icon:"⚡",description:"Critical resource for current page",requiresAs:!0},{rel:"modulepreload",icon:"đŸ“Ļ",description:"ES module for current page",requiresAs:!1},{rel:"preconnect",icon:"🔌",description:"Early connection to origin",requiresAs:!1},{rel:"dns-prefetch",icon:"🔍",description:"DNS lookup for origin",requiresAs:!1},{rel:"prefetch",icon:"đŸ“Ĩ",description:"Resource for future navigation",requiresAs:!1},{rel:"prerender",icon:"đŸ–ŧī¸",description:"Pre-render page",requiresAs:!1}],i=[],o=[];t.forEach(r=>{document.querySelectorAll(`link[rel="${r.rel}"]`).forEach(t=>{const n=t.href||t.getAttribute("href")||"";let s="";try{s=new URL(n,location.origin).hostname}catch{}const c={rel:r.rel,icon:r.icon,href:n,shortHref:n.length>50?"..."+n.slice(-47):n,hostname:s,as:t.getAttribute("as")||"",crossorigin:t.crossOrigin||t.getAttribute("crossorigin")||"",fetchpriority:t.fetchPriority||t.getAttribute("fetchpriority")||"",type:t.type||"",media:t.media||"",element:t};i.push(c),r.requiresAs&&!c.as&&o.push({hint:c,issue:"Missing 'as' attribute (required for preload)",severity:"error",fix:'Add as="script|style|font|image|fetch"'}),"font"!==c.as||c.crossorigin||o.push({hint:c,issue:"Font preload without crossorigin (causes double fetch)",severity:"error",fix:'Add crossorigin="anonymous"'}),"preconnect"!==r.rel||c.crossorigin||performance.getEntriesByType("resource").filter(r=>!e(r)).filter(e=>e.name.includes(s)&&("script"===e.initiatorType||"css"===e.initiatorType||"font"===e.initiatorType)).length>0&&o.push({hint:c,issue:"Preconnect may need crossorigin for CORS resources",severity:"warning",fix:'Consider adding crossorigin="anonymous"'})})});const n=i.filter(e=>"preload"===e.rel),s=performance.getEntriesByType("resource").filter(r=>!e(r));n.forEach(e=>{s.some(r=>r.name===e.href)||o.push({hint:e,issue:"Preloaded resource not used within page load",severity:"warning",fix:"Remove unused preload or verify it's needed"})});const c=i.filter(e=>"preconnect"===e.rel||"dns-prefetch"===e.rel);c.forEach(e=>{!s.some(r=>{try{return new URL(r.name).hostname===e.hostname}catch{return!1}})&&e.hostname&&o.push({hint:e,issue:`No resources loaded from ${e.hostname}`,severity:"warning",fix:"Remove unused preconnect or verify it's needed for future resources"})}),n.length>6&&o.push({hint:{rel:"preload",href:`${n.length} preloads`},issue:`Too many preloads (${n.length}) - they compete for bandwidth`,severity:"warning",fix:"Limit to 5-6 most critical resources"});const a=new Map;let h=0;s.forEach(e=>{try{const r=new URL(e.name);if(r.hostname!==location.hostname){a.has(r.origin)||a.set(r.origin,{count:0,size:0,types:new Set});const t=a.get(r.origin);t.count++,t.size+=e.transferSize||0,0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&e.duration>0&&h++,t.types.add(e.initiatorType)}}catch{}});const l=new Set([...c].map(e=>{try{return new URL(e.href).origin}catch{return""}})),u=[];a.forEach((e,r)=>{!l.has(r)&&e.count>=2&&u.push({origin:r,...e})}),t.map(e=>{const r=i.filter(r=>r.rel===e.rel).length;return{type:e.rel,count:r,icon:e.icon}}).forEach(e=>{}),t.forEach(e=>{const r=i.filter(r=>r.rel===e.rel);0!==r.length&&(r.map(r=>{const t={Resource:r.shortHref};if("preload"===e.rel||"modulepreload"===e.rel){t.As=r.as||"âš ī¸ missing",t.Crossorigin=r.crossorigin||"-",r.fetchpriority&&(t.Priority=r.fetchpriority);const e=s.some(e=>e.name===r.href);t.Used=e?"✅":"âš ī¸ No"}if("preconnect"===e.rel||"dns-prefetch"===e.rel){t.Origin=r.hostname,t.Crossorigin=r.crossorigin||"-";const e=s.filter(e=>{try{return new URL(e.name).hostname===r.hostname}catch{return!1}});t["Resources Loaded"]=e.length}return t}),r.forEach((e,r)=>{}))});const f=o.filter(e=>"error"===e.severity),p=o.filter(e=>"warning"===e.severity);o.length>0&&(f.length>0&&f.forEach(e=>{}),p.length>0&&p.forEach(e=>{})),u.length>0&&(u.sort((e,r)=>r.count-e.count).slice(0,5).map(e=>({Origin:e.origin,Resources:e.count,Size:r(e.size),Types:Array.from(e.types).join(", ")})),u.slice(0,3).forEach(e=>{}));const d=e=>o.some(r=>r.hint===e&&"error"===r.severity)?0:o.some(r=>r.hint===e)?1:2,g=i.map((e,r)=>({h:e,index:r})).sort((e,r)=>d(e.h)-d(r.h)||e.index-r.index).map(({h:e})=>e);return{script:"Resource-Hints",status:"ok",count:i.length,corsLimitedAnalysis:h>0,details:{sizeUnknownCount:h,byType:Object.fromEntries(t.map(e=>[e.rel,i.filter(r=>r.rel===e.rel).length])),missingPreconnectsCount:u.length,missingPreconnects:u.slice(0,20).map(e=>({origin:e.origin,requestCount:e.count,sizeBytes:e.size,resourceTypes:Array.from(e.types),recommendedHint:"preconnect"}))},items:g.slice(0,50).map(e=>({rel:e.rel,href:e.shortHref,as:e.as,crossorigin:e.crossorigin,fetchpriority:e.fetchpriority})),issues:o.map(e=>({severity:e.severity,message:`${e.hint.rel}: ${e.issue}. Fix: ${e.fix}`}))}})(); \ No newline at end of file +// snippets/Loading/Resource-Hints.js | sha256:07b5029ae84b9011 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Resource-Hints.js +(()=>{function e(e){return e.name.endsWith("#webperf-snippets")}function r(e){if(null==e||Number.isNaN(e))return"-";if(0===e)return"0 B";const r=["B","KB","MB","GB"],o=Math.max(0,Math.min(Math.floor(Math.log(e)/Math.log(1024)),r.length-1));return(e/Math.pow(1024,o)).toFixed(1)+" "+r[o]}const o=[{rel:"preload",icon:"⚡",description:"Critical resource for current page",requiresAs:!0},{rel:"modulepreload",icon:"đŸ“Ļ",description:"ES module for current page",requiresAs:!1},{rel:"preconnect",icon:"🔌",description:"Early connection to origin",requiresAs:!1},{rel:"dns-prefetch",icon:"🔍",description:"DNS lookup for origin",requiresAs:!1},{rel:"prefetch",icon:"đŸ“Ĩ",description:"Resource for future navigation",requiresAs:!1},{rel:"prerender",icon:"đŸ–ŧī¸",description:"Pre-render page",requiresAs:!1}],t=[],i=[];o.forEach(r=>{document.querySelectorAll(`link[rel="${r.rel}"]`).forEach(o=>{const n=o.href||o.getAttribute("href")||"";let s="";try{s=new URL(n,location.origin).hostname}catch{}const c={rel:r.rel,icon:r.icon,href:n,shortHref:n.length>50?"..."+n.slice(-47):n,hostname:s,as:o.getAttribute("as")||"",crossorigin:o.crossOrigin||o.getAttribute("crossorigin")||"",fetchpriority:o.fetchPriority||o.getAttribute("fetchpriority")||"",type:o.type||"",media:o.media||"",element:o};t.push(c),r.requiresAs&&!c.as&&i.push({hint:c,code:"preload-missing-as",issue:"Missing 'as' attribute (required for preload)",severity:"error",fix:'Add as="script|style|font|image|fetch"'}),"font"!==c.as||c.crossorigin||i.push({hint:c,code:"font-preload-no-crossorigin",issue:"Font preload without crossorigin (causes double fetch)",severity:"error",fix:'Add crossorigin="anonymous"'}),"preconnect"!==r.rel||c.crossorigin||performance.getEntriesByType("resource").filter(r=>!e(r)).filter(e=>e.name.includes(s)&&("script"===e.initiatorType||"css"===e.initiatorType||"font"===e.initiatorType)).length>0&&i.push({hint:c,code:"preconnect-no-crossorigin",issue:"Preconnect may need crossorigin for CORS resources",severity:"warning",fix:'Consider adding crossorigin="anonymous"'})})});const n=t.filter(e=>"preload"===e.rel),s=performance.getEntriesByType("resource").filter(r=>!e(r));n.forEach(e=>{s.some(r=>r.name===e.href)||i.push({hint:e,code:"unused-preload",issue:"Preloaded resource not used within page load",severity:"warning",fix:"Remove unused preload or verify it's needed"})});const c=t.filter(e=>"preconnect"===e.rel||"dns-prefetch"===e.rel);c.forEach(e=>{!s.some(r=>{try{return new URL(r.name).hostname===e.hostname}catch{return!1}})&&e.hostname&&i.push({hint:e,code:"unused-preconnect",issue:`No resources loaded from ${e.hostname}`,severity:"warning",fix:"Remove unused preconnect or verify it's needed for future resources"})}),n.length>6&&i.push({hint:{rel:"preload",href:`${n.length} preloads`},code:"too-many-preloads",issue:`Too many preloads (${n.length}) - they compete for bandwidth`,severity:"warning",fix:"Limit to 5-6 most critical resources"});const a=new Map;let l=0;s.forEach(e=>{try{const r=new URL(e.name);if(r.hostname!==location.hostname){a.has(r.origin)||a.set(r.origin,{count:0,size:0,types:new Set});const o=a.get(r.origin);o.count++,o.size+=e.transferSize||0,0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&e.duration>0&&l++,o.types.add(e.initiatorType)}}catch{}});const h=new Set([...c].map(e=>{try{return new URL(e.href).origin}catch{return""}})),u=[];a.forEach((e,r)=>{!h.has(r)&&e.count>=2&&u.push({origin:r,...e})}),o.map(e=>{const r=t.filter(r=>r.rel===e.rel).length;return{type:e.rel,count:r,icon:e.icon}}).forEach(e=>{}),o.forEach(e=>{const r=t.filter(r=>r.rel===e.rel);0!==r.length&&(r.map(r=>{const o={Resource:r.shortHref};if("preload"===e.rel||"modulepreload"===e.rel){o.As=r.as||"âš ī¸ missing",o.Crossorigin=r.crossorigin||"-",r.fetchpriority&&(o.Priority=r.fetchpriority);const e=s.some(e=>e.name===r.href);o.Used=e?"✅":"âš ī¸ No"}if("preconnect"===e.rel||"dns-prefetch"===e.rel){o.Origin=r.hostname,o.Crossorigin=r.crossorigin||"-";const e=s.filter(e=>{try{return new URL(e.name).hostname===r.hostname}catch{return!1}});o["Resources Loaded"]=e.length}return o}),r.forEach((e,r)=>{}))});const p=i.filter(e=>"error"===e.severity),d=i.filter(e=>"warning"===e.severity);i.length>0&&(p.length>0&&p.forEach(e=>{}),d.length>0&&d.forEach(e=>{})),u.length>0&&(u.sort((e,r)=>r.count-e.count).slice(0,5).map(e=>({Origin:e.origin,Resources:e.count,Size:r(e.size),Types:Array.from(e.types).join(", ")})),u.slice(0,3).forEach(e=>{}));const f=e=>i.some(r=>r.hint===e&&"error"===r.severity)?0:i.some(r=>r.hint===e)?1:2,g=t.map((e,r)=>({h:e,index:r})).sort((e,r)=>f(e.h)-f(r.h)||e.index-r.index).map(({h:e})=>e);return{script:"Resource-Hints",status:"ok",count:t.length,corsLimitedAnalysis:l>0,details:{sizeUnknownCount:l,byType:Object.fromEntries(o.map(e=>[e.rel,t.filter(r=>r.rel===e.rel).length])),missingPreconnectsCount:u.length,missingPreconnects:u.slice(0,20).map(e=>({origin:e.origin,requestCount:e.count,sizeBytes:e.size,resourceTypes:Array.from(e.types),recommendedHint:"preconnect"}))},items:g.slice(0,50).map(e=>({rel:e.rel,href:e.shortHref,as:e.as,crossorigin:e.crossorigin,fetchpriority:e.fetchpriority})),issues:i.map(e=>({code:e.code,severity:e.severity,message:`${e.hint.rel}: ${e.issue}. Fix: ${e.fix}`,...e.hint.element?{target:e.hint.href}:{}}))}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js b/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js index 9b83a0f1..fadf9b8b 100644 --- a/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js +++ b/skills/webperf-loading/scripts/Server-Timing-Early-Hints.js @@ -1,2 +1,2 @@ -// snippets/Loading/Server-Timing-Early-Hints.js | sha256:196197df69458a61 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Server-Timing-Early-Hints.js -(()=>{const e="Server-Timing-Early-Hints",[r]=performance.getEntriesByType("navigation");if(!r)return{script:e,status:"error",error:"No navigation entry available"};if(!Array.isArray(r.serverTiming))return{script:e,status:"unsupported",error:"Server-Timing entries are not supported in this browser"};const s=e=>Math.round(100*e)/100,i=Math.max(0,r.activationStart??0),t=e=>e>0?s(Math.max(0,e-i)):0,n=(e,r,i)=>({source:r,url:i,name:e.name,durationMs:s(e.duration||0),description:e.description||""}),o=r.serverTiming.map(e=>n(e,"navigation",location.href));let a=0,c=0;for(const e of performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))){if(e.serverTiming.length>0){a++;for(const r of e.serverTiming)o.push(n(r,"resource",e.name));continue}let r=!1;try{r=new URL(e.name).origin!==location.origin}catch{}r&&0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&c++}o.sort((e,r)=>r.durationMs-e.durationMs);const d=o.slice(0,50),m=t(r.firstInterimResponseStart??0),p=t(r.finalResponseHeadersStart??r.responseStart),g=m>0,l={responseStartMs:t(r.responseStart),firstInterimResponseStartMs:m,finalResponseHeadersStartMs:p,earlyHintsReceived:g,earlyHintsLeadMs:g?s(Math.max(0,p-m)):0,serverTimingCount:r.serverTiming.length},u=[];return 0===r.serverTiming.length&&u.push({severity:"info",message:"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."}),c>0&&u.push({severity:"info",message:`${c} 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.`}),g||u.push({severity:"info",message:"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."}),{script:e,status:"ok",count:o.length,corsLimitedAnalysis:c>0,details:{navigation:l,resourcesWithServerTiming:a,corsRestrictedCount:c},items:d,issues:u}})(); \ No newline at end of file +// snippets/Loading/Server-Timing-Early-Hints.js | sha256:5d59b89aac1ec7b3 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Server-Timing-Early-Hints.js +(()=>{const e="Server-Timing-Early-Hints",[r]=performance.getEntriesByType("navigation");if(!r)return{script:e,status:"error",error:"No navigation entry available"};if(!Array.isArray(r.serverTiming))return{script:e,status:"unsupported",error:"Server-Timing entries are not supported in this browser"};const i=e=>Math.round(100*e)/100,s=Math.max(0,r.activationStart??0),n=e=>e>0?i(Math.max(0,e-s)):0,t=(e,r,s)=>({source:r,url:s,name:e.name,durationMs:i(e.duration||0),description:e.description||""}),o=r.serverTiming.map(e=>t(e,"navigation",location.href));let a=0,d=0;for(const e of performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets"))){if(e.serverTiming.length>0){a++;for(const r of e.serverTiming)o.push(t(r,"resource",e.name));continue}let r=!1;try{r=new URL(e.name).origin!==location.origin}catch{}r&&0===e.transferSize&&0===e.encodedBodySize&&0===e.decodedBodySize&&d++}o.sort((e,r)=>r.durationMs-e.durationMs);const c=o.slice(0,50),m=n(r.firstInterimResponseStart??0),g=n(r.finalResponseHeadersStart??r.responseStart),p=m>0,l={responseStartMs:n(r.responseStart),firstInterimResponseStartMs:m,finalResponseHeadersStartMs:g,earlyHintsReceived:p,earlyHintsLeadMs:p?i(Math.max(0,g-m)):0,serverTimingCount:r.serverTiming.length},h=[];return 0===r.serverTiming.length&&h.push({code:"no-server-timing",severity:"info",message:"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."}),d>0&&h.push({code:"timing-hidden",severity:"info",message:`${d} 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.`}),p||h.push({code:"no-early-hints",severity:"info",message:"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."}),{script:e,status:"ok",count:o.length,corsLimitedAnalysis:d>0,details:{navigation:l,resourcesWithServerTiming:a,corsRestrictedCount:d},items:c,issues:h}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Service-Worker-Analysis.js b/skills/webperf-loading/scripts/Service-Worker-Analysis.js index de6bea82..af7803eb 100644 --- a/skills/webperf-loading/scripts/Service-Worker-Analysis.js +++ b/skills/webperf-loading/scripts/Service-Worker-Analysis.js @@ -1,2 +1,2 @@ -// snippets/Loading/Service-Worker-Analysis.js | sha256:c9a1ae09edc5f977 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Service-Worker-Analysis.js -(async()=>{if(!("serviceWorker"in navigator))return{script:"Service-Worker-Analysis",status:"unsupported",error:"Service Workers not supported in this browser",count:0,details:{},items:[],issues:[{severity:"error",message:"Service Workers not supported in this browser"}]};const e=await navigator.serviceWorker.getRegistrations(),t=navigator.serviceWorker.controller,r=performance.getEntriesByType("navigation")[0],n=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),s=n.filter(e=>e.workerStart>0),i=s.filter(e=>0===e.transferSize&&e.encodedBodySize>0),a=s.filter(e=>e.transferSize>0),o=s.filter(e=>0===e.transferSize&&0===e.encodedBodySize),l=i.length+a.length,c=n.filter(e=>0===e.workerStart);let g=null;r&&r.workerStart>0&&(g=Math.round(10*Math.max(r.fetchStart-r.startTime-(r.workerStart-r.startTime),0))/10);const h=l>0?parseFloat((i.length/l*100).toFixed(1)):null,u=i.reduce((e,t)=>e+(t.encodedBodySize||0),0),d=new Map;for(const t of e)if(t.navigationPreload)try{const e=await t.navigationPreload.getState();d.set(t,e)}catch{}const p=[];if("caches"in window)try{const e=await caches.keys();for(const t of e){const e=await caches.open(t),r=await e.keys();p.push({name:t,entries:r.length})}}catch{}const v=[...p].sort((e,t)=>t.entries-e.entries).slice(0,20),f=e.map(e=>{const t=d.get(e)||null;let r="none",n=null;return e.active?(r="active",n=e.active.scriptURL):e.waiting?(r="waiting",n=e.waiting.scriptURL):e.installing&&(r="installing",n=e.installing.scriptURL),{scope:e.scope,scriptURL:n,state:r,hasWaiting:!!e.waiting,hasInstalling:!!e.installing,navigationPreloadEnabled:t?t.enabled:null,navigationPreloadHeaderValue:t?.headerValue||null}}),w=[];0===e.length&&w.push({severity:"info",message:"No Service Workers registered"}),e.length>0&&!t&&w.push({severity:"warning",message:"Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload."});for(const e of f)e.hasWaiting&&w.push({severity:"warning",message:`Service Worker update pending for ${e.scope}. Call skipWaiting() to activate the new version.`}),!1===e.navigationPreloadEnabled&&w.push({severity:"info",message:`Navigation Preload is disabled for ${e.scope}. Consider enabling it with registration.navigationPreload.enable().`});null!==g&&g>100?w.push({severity:"error",message:`High SW startup time (${g}ms). Enable Navigation Preload to reduce overhead.`}):null!==g&&g>50&&w.push({severity:"warning",message:`Moderate SW startup time (${g}ms)`}),null!==h&&h<50?w.push({severity:"error",message:`Low SW cache hit rate (${h}%). Review the caching strategy.`}):null!==h&&h<80&&w.push({severity:"warning",message:`Moderate SW cache hit rate (${h}%). Review the caching strategy for more resources.`}),o.length>0&&w.push({severity:"info",message:`${o.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`});const m=null===h?null:h>=80?"good":h>=50?"needs-improvement":"poor";if(0===e.length)return{script:"Service-Worker-Analysis",status:"ok",count:0,details:{controlled:!1,controllerState:null,swOverheadMs:null,totalResources:n.length,swIntercepted:0,notIntercepted:n.length,fromCache:0,fromNetwork:0,fromUnknown:0,cacheHitRate:null,savedBytes:0,cacheStorageCount:p.length,cacheStorage:v},items:[],issues:w};for(const t of e)d.get(t);if(p.length>0){p.reduce((e,t)=>e+t.entries,0);for(const{}of p);}return{script:"Service-Worker-Analysis",status:"ok",count:e.length,...null!==m&&{rating:m},details:{controlled:!!t,controllerState:t?.state||null,swOverheadMs:g,totalResources:n.length,swIntercepted:s.length,notIntercepted:c.length,fromCache:i.length,fromNetwork:a.length,fromUnknown:o.length,cacheHitRate:h,savedBytes:u,cacheStorageCount:p.length,cacheStorage:v},items:f,issues:w}})(); \ No newline at end of file +// snippets/Loading/Service-Worker-Analysis.js | sha256:1e4ed59b35983d19 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Service-Worker-Analysis.js +(async()=>{if(!("serviceWorker"in navigator))return{script:"Service-Worker-Analysis",status:"unsupported",error:"Service Workers not supported in this browser",count:0,details:{},items:[],issues:[{code:"unsupported",severity:"error",message:"Service Workers not supported in this browser"}]};const e=await navigator.serviceWorker.getRegistrations(),t=navigator.serviceWorker.controller,r=performance.getEntriesByType("navigation")[0],n=performance.getEntriesByType("resource").filter(e=>!e.name.endsWith("#webperf-snippets")),o=n.filter(e=>e.workerStart>0),s=o.filter(e=>0===e.transferSize&&e.encodedBodySize>0),a=o.filter(e=>e.transferSize>0),i=o.filter(e=>0===e.transferSize&&0===e.encodedBodySize),l=s.length+a.length,c=n.filter(e=>0===e.workerStart);let g=null;r&&r.workerStart>0&&(g=Math.round(10*Math.max(r.fetchStart-r.startTime-(r.workerStart-r.startTime),0))/10);const d=l>0?parseFloat((s.length/l*100).toFixed(1)):null,h=s.reduce((e,t)=>e+(t.encodedBodySize||0),0),u=new Map;for(const t of e)if(t.navigationPreload)try{const e=await t.navigationPreload.getState();u.set(t,e)}catch{}const p=[];if("caches"in window)try{const e=await caches.keys();for(const t of e){const e=await caches.open(t),r=await e.keys();p.push({name:t,entries:r.length})}}catch{}const v=[...p].sort((e,t)=>t.entries-e.entries).slice(0,20),w=e.map(e=>{const t=u.get(e)||null;let r="none",n=null;return e.active?(r="active",n=e.active.scriptURL):e.waiting?(r="waiting",n=e.waiting.scriptURL):e.installing&&(r="installing",n=e.installing.scriptURL),{scope:e.scope,scriptURL:n,state:r,hasWaiting:!!e.waiting,hasInstalling:!!e.installing,navigationPreloadEnabled:t?t.enabled:null,navigationPreloadHeaderValue:t?.headerValue||null}}),f=[];0===e.length&&f.push({code:"no-service-worker",severity:"info",message:"No Service Workers registered"}),e.length>0&&!t&&f.push({code:"not-controlled",severity:"warning",message:"Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload."});for(const e of w)e.hasWaiting&&f.push({code:"update-pending",severity:"warning",message:`Service Worker update pending for ${e.scope}. Call skipWaiting() to activate the new version.`,target:e.scope}),!1===e.navigationPreloadEnabled&&f.push({code:"navigation-preload-disabled",severity:"info",message:`Navigation Preload is disabled for ${e.scope}. Consider enabling it with registration.navigationPreload.enable().`,target:e.scope});null!==g&&g>100?f.push({code:"slow-startup",severity:"error",message:`High SW startup time (${g}ms). Enable Navigation Preload to reduce overhead.`}):null!==g&&g>50&&f.push({code:"slow-startup",severity:"warning",message:`Moderate SW startup time (${g}ms)`}),null!==d&&d<50?f.push({code:"low-cache-hit-rate",severity:"error",message:`Low SW cache hit rate (${d}%). Review the caching strategy.`}):null!==d&&d<80&&f.push({code:"low-cache-hit-rate",severity:"warning",message:`Moderate SW cache hit rate (${d}%). Review the caching strategy for more resources.`}),i.length>0&&f.push({code:"unknown-source",severity:"info",message:`${i.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`});const m=null===d?null:d>=80?"good":d>=50?"needs-improvement":"poor";if(0===e.length)return{script:"Service-Worker-Analysis",status:"ok",count:0,details:{controlled:!1,controllerState:null,swOverheadMs:null,totalResources:n.length,swIntercepted:0,notIntercepted:n.length,fromCache:0,fromNetwork:0,fromUnknown:0,cacheHitRate:null,savedBytes:0,cacheStorageCount:p.length,cacheStorage:v},items:[],issues:f};for(const t of e)u.get(t);if(p.length>0){p.reduce((e,t)=>e+t.entries,0);for(const{}of p);}return{script:"Service-Worker-Analysis",status:"ok",count:e.length,...null!==m&&{rating:m},details:{controlled:!!t,controllerState:t?.state||null,swOverheadMs:g,totalResources:n.length,swIntercepted:o.length,notIntercepted:c.length,fromCache:s.length,fromNetwork:a.length,fromUnknown:i.length,cacheHitRate:d,savedBytes:h,cacheStorageCount:p.length,cacheStorage:v},items:w,issues:f}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js b/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js index 7d19a1ae..67046032 100644 --- a/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js +++ b/skills/webperf-loading/scripts/Speculation-Rules-Inspector.js @@ -1,2 +1,2 @@ -// snippets/Loading/Speculation-Rules-Inspector.js | sha256:f4a2b4176f88e5c0 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Speculation-Rules-Inspector.js -(()=>{const e={activated:!document.prerendering,activatedAt:null};document.prerendering&&document.addEventListener("prerenderingchange",()=>{e.activated=!0,e.activatedAt=Math.round(performance.now())},{once:!0});const t=()=>{const t=[],r=[],s={},n="function"==typeof window.HTMLScriptElement.supports&&window.HTMLScriptElement.supports("speculationrules"),[i]=performance.getEntriesByType("navigation"),a=Math.round(i?.activationStart??0),o=i?.deliveryType??"",c=[...document.querySelectorAll('script[type="speculationrules"]')];return c.forEach((e,n)=>{let i;try{i=JSON.parse(e.textContent)}catch(e){return void t.push({severity:"error",message:`Speculation rules script #${n+1} is not valid JSON (${e.message}). The browser ignores the whole script.`})}for(const[e,a]of Object.entries(i??{}))if(Array.isArray(a)){s[e]=(s[e]??0)+a.length;for(const s of a){const i=s.source??(s.where?"document":"list"),a=Array.isArray(s.urls)?s.urls:[],o=s.eagerness??("document"===i?"conservative":"immediate");r.push({action:e,source:i,eagerness:o,urls:a.slice(0,20),urlCount:a.length,where:s.where??null,requires:s.requires??[],referrerPolicy:s.referrer_policy??null,scriptIndex:n});const c="prerender"===e||"prerender_until_script"===e;c&&"list"===i&&("immediate"===o||"eager"===o)&&a.length>10&&t.push({severity:"warning",message:`A ${e} rule lists ${a.length} URLs with eagerness "${o}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`}),!c||"document"!==i||"immediate"!==o&&"eager"!==o||t.push({severity:"warning",message:`A document ${e} rule uses eagerness "${o}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`})}}}),0===c.length?t.push({severity:"info",message:"No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript."}):0!==r.length||t.some(e=>"error"===e.severity)||t.push({severity:"info",message:"Speculation rules scripts were found but contain no prefetch or prerender rules."}),!n&&c.length>0&&t.push({severity:"info",message:"This browser does not support speculation rules, so the rules are ignored."}),a>0&&t.push({severity:"info",message:`This page was prerendered and activated at ${a}ms. Navigation timings are relative to activationStart.`}),{script:"Speculation-Rules-Inspector",status:"ok",count:r.length,details:{supported:n,prerendering:!0===document.prerendering,activated:e.activated,activatedAt:e.activatedAt,activationStart:a,deliveryType:o,scriptCount:c.length,actions:s},items:r.slice(0,50),issues:t}};window.getSpeculationRulesInspection=t;const r=t();return r.issues.forEach(e=>{}),document.prerendering?{script:"Speculation-Rules-Inspector",status:"tracking",message:"This page is prerendering. Call getSpeculationRulesInspection() after activation to see the final timings.",getDataFn:"getSpeculationRulesInspection"}:r})(); \ No newline at end of file +// snippets/Loading/Speculation-Rules-Inspector.js | sha256:6bd00e36c4f0b97c | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/Speculation-Rules-Inspector.js +(()=>{const e={activated:!document.prerendering,activatedAt:null};document.prerendering&&document.addEventListener("prerenderingchange",()=>{e.activated=!0,e.activatedAt=Math.round(performance.now())},{once:!0});const r=()=>{const r=[],t=[],s={},n="function"==typeof window.HTMLScriptElement.supports&&window.HTMLScriptElement.supports("speculationrules"),[i]=performance.getEntriesByType("navigation"),a=Math.round(i?.activationStart??0),o=i?.deliveryType??"",c=[...document.querySelectorAll('script[type="speculationrules"]')];return c.forEach((e,n)=>{let i;try{i=JSON.parse(e.textContent)}catch(e){return void r.push({code:"invalid-json",severity:"error",message:`Speculation rules script #${n+1} is not valid JSON (${e.message}). The browser ignores the whole script.`})}for(const[e,a]of Object.entries(i??{}))if(Array.isArray(a)){s[e]=(s[e]??0)+a.length;for(const s of a){const i=s.source??(s.where?"document":"list"),a=Array.isArray(s.urls)?s.urls:[],o=s.eagerness??("document"===i?"conservative":"immediate");t.push({action:e,source:i,eagerness:o,urls:a.slice(0,20),urlCount:a.length,where:s.where??null,requires:s.requires??[],referrerPolicy:s.referrer_policy??null,scriptIndex:n});const c="prerender"===e||"prerender_until_script"===e;c&&"list"===i&&("immediate"===o||"eager"===o)&&a.length>10&&r.push({code:"eager-list-capped",severity:"warning",message:`A ${e} rule lists ${a.length} URLs with eagerness "${o}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`}),!c||"document"!==i||"immediate"!==o&&"eager"!==o||r.push({code:"eager-document-rule",severity:"warning",message:`A document ${e} rule uses eagerness "${o}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`})}}}),0===c.length?r.push({code:"no-rules",severity:"info",message:"No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript."}):0!==t.length||r.some(e=>"error"===e.severity)||r.push({code:"no-prefetch-or-prerender",severity:"info",message:"Speculation rules scripts were found but contain no prefetch or prerender rules."}),!n&&c.length>0&&r.push({code:"unsupported",severity:"info",message:"This browser does not support speculation rules, so the rules are ignored."}),a>0&&r.push({code:"prerendered",severity:"info",message:`This page was prerendered and activated at ${a}ms. Navigation timings are relative to activationStart.`}),{script:"Speculation-Rules-Inspector",status:"ok",count:t.length,details:{supported:n,prerendering:!0===document.prerendering,activated:e.activated,activatedAt:e.activatedAt,activationStart:a,deliveryType:o,scriptCount:c.length,actions:s},items:t.slice(0,50),issues:r}};window.getSpeculationRulesInspection=r;const t=r();return t.issues.forEach(e=>{}),document.prerendering?{script:"Speculation-Rules-Inspector",status:"tracking",message:"This page is prerendering. Call getSpeculationRulesInspection() after activation to see the final timings.",getDataFn:"getSpeculationRulesInspection"}:t})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/TTFB-Resources.js b/skills/webperf-loading/scripts/TTFB-Resources.js index a9338a9e..fb00c474 100644 --- a/skills/webperf-loading/scripts/TTFB-Resources.js +++ b/skills/webperf-loading/scripts/TTFB-Resources.js @@ -1,2 +1,2 @@ -// snippets/Loading/TTFB-Resources.js | sha256:784e36c4612e294b | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Resources.js -(()=>{function t(t){return t.name.endsWith("#webperf-snippets")}const e=[],r=t=>t.responseStart-t.requestStart,n=t=>t.responseStart>0;function s(t){const e=t.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(e)||e.includes(":"))return e;const r=e.split(".");return r.length<=2?e:2===r[r.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(r[r.length-2])?r.slice(-3).join("."):r.slice(-2).join(".")}const i=t=>{try{return!function(t){const r=s(t);return r===s(location.hostname)||e.some(t=>s(String(t).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===r)}(new URL(t).hostname)}catch{return!1}};new PerformanceObserver(e=>{const s=e.getEntries().filter(e=>!t(e)),o=(s.filter(t=>!n(t)),s.filter(n).map(t=>{const e=i(t.name);return{ttfb:r(t),duration:t.duration,type:t.initiatorType,thirdParty:e,resource:t.name.length>70?"..."+t.name.slice(-67):t.name,fullUrl:t.name}}).sort((t,e)=>e.ttfb-t.ttfb));if(0===o.length)return;const a=o.map(t=>t.ttfb),l=(a.reduce((t,e)=>t+e,0),Math.max(...a),Math.min(...a),o.filter(t=>t.thirdParty),o.filter(t=>t.ttfb>500),o.slice(0,25).map(t=>({"TTFB (ms)":t.ttfb.toFixed(0),"Duration (ms)":t.duration.toFixed(0),Type:t.type,"3rd Party":t.thirdParty?"Yes":"",Resource:t.resource})),o.slice(0,5));l[0].ttfb>500&&l.forEach((t,e)=>{})}).observe({type:"resource",buffered:!0});const o=performance.getEntriesByType("resource").filter(e=>!t(e)),a=o.filter(t=>!n(t)).length,l=o.filter(n).map(t=>({url:t.name,ttfbMs:Math.round(r(t)),durationMs:Math.round(t.duration),type:t.initiatorType,isThirdParty:i(t.name)})).sort((t,e)=>e.ttfbMs-t.ttfbMs);if(0===l.length)return{script:"TTFB-Resources",status:"ok",count:0,corsLimitedAnalysis:a>0,details:{avgTtfbMs:null,maxTtfbMs:null,minTtfbMs:null,thirdPartyCount:0,slowCount:0,corsRestrictedCount:a},items:[],issues:[{severity:"info",message:"No resources with TTFB data available"}]};const u=l.map(t=>t.ttfbMs);return{script:"TTFB-Resources",status:"ok",count:l.length,corsLimitedAnalysis:a>0,details:{avgTtfbMs:Math.round(u.reduce((t,e)=>t+e,0)/u.length),maxTtfbMs:Math.max(...u),minTtfbMs:Math.min(...u),thirdPartyCount:l.filter(t=>t.isThirdParty).length,slowCount:l.filter(t=>t.ttfbMs>500).length,corsRestrictedCount:a},items:l.slice(0,50)}})(); \ No newline at end of file +// snippets/Loading/TTFB-Resources.js | sha256:358f34a389183974 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Resources.js +(()=>{function t(t){return t.name.endsWith("#webperf-snippets")}const e=[],r=t=>t.responseStart-t.requestStart,s=t=>t.responseStart>0;function n(t){const e=t.replace(/\.$/,"");if(/^\d{1,3}(\.\d{1,3}){3}$/.test(e)||e.includes(":"))return e;const r=e.split(".");return r.length<=2?e:2===r[r.length-1].length&&["ac","co","com","edu","go","gob","gouv","gov","govt","mil","ne","net","nom","or","org","sch"].includes(r[r.length-2])?r.slice(-3).join("."):r.slice(-2).join(".")}const i=t=>{try{return!function(t){const r=n(t);return r===n(location.hostname)||e.some(t=>n(String(t).trim().toLowerCase().replace(/^[a-z]+:\/\//,"").split("/")[0])===r)}(new URL(t).hostname)}catch{return!1}};new PerformanceObserver(e=>{const n=e.getEntries().filter(e=>!t(e)),o=(n.filter(t=>!s(t)),n.filter(s).map(t=>{const e=i(t.name);return{ttfb:r(t),duration:t.duration,type:t.initiatorType,thirdParty:e,resource:t.name.length>70?"..."+t.name.slice(-67):t.name,fullUrl:t.name}}).sort((t,e)=>e.ttfb-t.ttfb));if(0===o.length)return;const a=o.map(t=>t.ttfb),c=(a.reduce((t,e)=>t+e,0),Math.max(...a),Math.min(...a),o.filter(t=>t.thirdParty),o.filter(t=>t.ttfb>500),o.slice(0,25).map(t=>({"TTFB (ms)":t.ttfb.toFixed(0),"Duration (ms)":t.duration.toFixed(0),Type:t.type,"3rd Party":t.thirdParty?"Yes":"",Resource:t.resource})),o.slice(0,5));c[0].ttfb>500&&c.forEach((t,e)=>{})}).observe({type:"resource",buffered:!0});const o=performance.getEntriesByType("resource").filter(e=>!t(e)),a=o.filter(t=>!s(t)).length,c=o.filter(s).map(t=>({url:t.name,ttfbMs:Math.round(r(t)),durationMs:Math.round(t.duration),type:t.initiatorType,isThirdParty:i(t.name)})).sort((t,e)=>e.ttfbMs-t.ttfbMs);if(0===c.length)return{script:"TTFB-Resources",status:"ok",count:0,corsLimitedAnalysis:a>0,details:{avgTtfbMs:null,maxTtfbMs:null,minTtfbMs:null,thirdPartyCount:0,slowCount:0,corsRestrictedCount:a},items:[],issues:[{code:"no-resources",severity:"info",message:"No resources with TTFB data available"}]};const u=c.map(t=>t.ttfbMs);return{script:"TTFB-Resources",status:"ok",count:c.length,corsLimitedAnalysis:a>0,details:{avgTtfbMs:Math.round(u.reduce((t,e)=>t+e,0)/u.length),maxTtfbMs:Math.max(...u),minTtfbMs:Math.min(...u),thirdPartyCount:c.filter(t=>t.isThirdParty).length,slowCount:c.filter(t=>t.ttfbMs>500).length,corsRestrictedCount:a},items:c.slice(0,50)}})(); \ No newline at end of file diff --git a/skills/webperf-loading/scripts/TTFB-Sub-Parts.js b/skills/webperf-loading/scripts/TTFB-Sub-Parts.js index c19f9dc9..11e4c2d9 100644 --- a/skills/webperf-loading/scripts/TTFB-Sub-Parts.js +++ b/skills/webperf-loading/scripts/TTFB-Sub-Parts.js @@ -1,2 +1,2 @@ -// snippets/Loading/TTFB-Sub-Parts.js | sha256:ca68d328bd7e2938 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Sub-Parts.js -(()=>{new PerformanceObserver(e=>{const[t]=e.getEntriesByType("navigation"),r=t.activationStart||0,n=Math.max((t.workerStart||t.fetchStart)-r,0),o=Math.max(t.domainLookupStart-r,0),a=Math.max(t.connectStart-r,0),i=Math.max(t.connectEnd-r,0),s=t.secureConnectionStart>0?Math.max(t.secureConnectionStart-r,0):i,c=Math.max(t.responseStart-r,0);let d,u;c<=800?(d="Good",u="#22c55e"):c<=1800?(d="Needs Improvement",u="#f59e0b"):(d="Poor",u="#ef4444");const m=[{name:"Redirect/Wait",duration:n,icon:"🔄"},{name:"Service Worker/Cache",duration:o-n,icon:"âš™ī¸"},{name:"DNS Lookup",duration:a-o,icon:"🔍"},{name:"TCP Connection",duration:s-a,icon:"🔌"},{name:"SSL/TLS",duration:i-s,icon:"🔒"},{name:"Server Response",duration:c-i,icon:"đŸ“Ĩ"}];m.forEach(({})=>{}),m.reduce((e,t)=>e.duration>t.duration?e:t)}).observe({type:"navigation",buffered:!0});const[e]=performance.getEntriesByType("navigation");if(!e)return{script:"TTFB-Sub-Parts",status:"error",error:"No navigation entry"};const t=e.activationStart||0,r=Math.max(0,e.responseStart-t),n=Math.round(r),o=n<=800?"good":n<=1800?"needs-improvement":"poor",a=Math.max((e.workerStart||e.fetchStart)-t,0),i=Math.max(e.domainLookupStart-t,0),s=Math.max(e.connectStart-t,0),c=Math.max(e.connectEnd-t,0),d=e.secureConnectionStart>0?Math.max(e.secureConnectionStart-t,0):c,u=[{key:"redirectWait",duration:a},{key:"serviceWorkerCache",duration:i-a},{key:"dnsLookup",duration:s-i},{key:"tcpConnection",duration:d-s},{key:"sslTls",duration:c-d},{key:"serverResponse",duration:r-c}],m=u.reduce((e,t)=>e.duration>t.duration?e:t),h={redirectWait:"redirect and wait",serviceWorkerCache:"service worker and cache",dnsLookup:"DNS lookup",tcpConnection:"TCP connection",sslTls:"SSL/TLS",serverResponse:"server response"},p={redirectWait:"minimize redirects and link to the final URL",serviceWorkerCache:"review the service worker startup and consider a cache-first strategy",dnsLookup:"use dns-prefetch or a faster DNS provider",tcpConnection:"enable HTTP/2 or HTTP/3 and keep connections alive",sslTls:"enable TLS 1.3 and session resumption, and check the certificate chain",serverResponse:"optimize server processing, cache the HTML and check slow queries"},k=[];if("good"!==o){const e=Math.round(m.duration),t=r>0?Math.round(m.duration/r*100):0;k.push({severity:"poor"===o?"error":"warning",message:`TTFB is ${n} ms (${o}); ${h[m.key]} takes ${e} ms of it (${t}%). Fix: ${p[m.key]}.`})}return{script:"TTFB-Sub-Parts",status:"ok",metric:"TTFB",value:n,unit:"ms",rating:o,thresholds:{good:800,needsImprovement:1800},details:{subParts:Object.fromEntries(u.map(e=>[e.key,{value:Math.round(e.duration),unit:"ms"}])),slowestPhase:m.key},issues:k}})(); \ No newline at end of file +// snippets/Loading/TTFB-Sub-Parts.js | sha256:bd057af385152ba3 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/Loading/TTFB-Sub-Parts.js +(()=>{new PerformanceObserver(e=>{const[t]=e.getEntriesByType("navigation"),r=t.activationStart||0,n=Math.max((t.workerStart||t.fetchStart)-r,0),o=Math.max(t.domainLookupStart-r,0),a=Math.max(t.connectStart-r,0),i=Math.max(t.connectEnd-r,0),s=t.secureConnectionStart>0?Math.max(t.secureConnectionStart-r,0):i,c=Math.max(t.responseStart-r,0);let d,u;c<=800?(d="Good",u="#22c55e"):c<=1800?(d="Needs Improvement",u="#f59e0b"):(d="Poor",u="#ef4444");const m=[{name:"Redirect/Wait",duration:n,icon:"🔄"},{name:"Service Worker/Cache",duration:o-n,icon:"âš™ī¸"},{name:"DNS Lookup",duration:a-o,icon:"🔍"},{name:"TCP Connection",duration:s-a,icon:"🔌"},{name:"SSL/TLS",duration:i-s,icon:"🔒"},{name:"Server Response",duration:c-i,icon:"đŸ“Ĩ"}];m.forEach(({})=>{}),m.reduce((e,t)=>e.duration>t.duration?e:t)}).observe({type:"navigation",buffered:!0});const[e]=performance.getEntriesByType("navigation");if(!e)return{script:"TTFB-Sub-Parts",status:"error",error:"No navigation entry"};const t=e.activationStart||0,r=Math.max(0,e.responseStart-t),n=Math.round(r),o=n<=800?"good":n<=1800?"needs-improvement":"poor",a=Math.max((e.workerStart||e.fetchStart)-t,0),i=Math.max(e.domainLookupStart-t,0),s=Math.max(e.connectStart-t,0),c=Math.max(e.connectEnd-t,0),d=e.secureConnectionStart>0?Math.max(e.secureConnectionStart-t,0):c,u=[{key:"redirectWait",duration:a},{key:"serviceWorkerCache",duration:i-a},{key:"dnsLookup",duration:s-i},{key:"tcpConnection",duration:d-s},{key:"sslTls",duration:c-d},{key:"serverResponse",duration:r-c}],m=u.reduce((e,t)=>e.duration>t.duration?e:t),h={redirectWait:"redirect and wait",serviceWorkerCache:"service worker and cache",dnsLookup:"DNS lookup",tcpConnection:"TCP connection",sslTls:"SSL/TLS",serverResponse:"server response"},p={redirectWait:"minimize redirects and link to the final URL",serviceWorkerCache:"review the service worker startup and consider a cache-first strategy",dnsLookup:"use dns-prefetch or a faster DNS provider",tcpConnection:"enable HTTP/2 or HTTP/3 and keep connections alive",sslTls:"enable TLS 1.3 and session resumption, and check the certificate chain",serverResponse:"optimize server processing, cache the HTML and check slow queries"},k=[];if("good"!==o){const e=Math.round(m.duration),t=r>0?Math.round(m.duration/r*100):0;k.push({code:"slow-ttfb",severity:"poor"===o?"error":"warning",message:`TTFB is ${n} ms (${o}); ${h[m.key]} takes ${e} ms of it (${t}%). Fix: ${p[m.key]}.`})}return{script:"TTFB-Sub-Parts",status:"ok",metric:"TTFB",value:n,unit:"ms",rating:o,thresholds:{good:800,needsImprovement:1800},details:{subParts:Object.fromEntries(u.map(e=>[e.key,{value:Math.round(e.duration),unit:"ms"}])),slowestPhase:m.key},issues:k}})(); \ No newline at end of file diff --git a/skills/webperf-media/references/schema.md b/skills/webperf-media/references/schema.md index 988a08ea..975142c7 100644 --- a/skills/webperf-media/references/schema.md +++ b/skills/webperf-media/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/skills/webperf-resources/references/schema.md b/skills/webperf-resources/references/schema.md index 988a08ea..975142c7 100644 --- a/skills/webperf-resources/references/schema.md +++ b/skills/webperf-resources/references/schema.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When | diff --git a/snippets/Loading/Back-Forward-Cache.js b/snippets/Loading/Back-Forward-Cache.js index 4c07c971..8eee945a 100644 --- a/snippets/Loading/Back-Forward-Cache.js +++ b/snippets/Loading/Back-Forward-Cache.js @@ -82,6 +82,7 @@ : []; confirmed.forEach(({ reason, frame }) => { issues.push({ + code: 'not-restored', reason: `${reason} (${frame})`, severity: 'high', source: 'browser', @@ -93,6 +94,7 @@ // cannot be detected from JavaScript. if (window.onunload !== null && !confirmed.some(({ reason }) => reason === 'unload-listener')) { issues.push({ + code: 'unload-handler', reason: 'window.onunload handler set', severity: 'high', source: 'detected', @@ -108,6 +110,7 @@ const cacheControl = res.headers.get('cache-control') || ''; if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) { issues.push({ + code: 'cache-control-no-store', reason: 'Cache-Control: no-store on the page response', severity: 'medium', source: 'detected', @@ -123,6 +126,7 @@ const iframeCount = document.querySelectorAll('iframe').length; if (iframeCount > 0) { issues.push({ + code: 'iframes', reason: `${iframeCount} iframe(s) on the page`, severity: 'info', source: 'note', @@ -132,6 +136,7 @@ if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { issues.push({ + code: 'service-worker', reason: 'Service Worker controls the page', severity: 'info', source: 'note', @@ -255,8 +260,10 @@ notRestoredReasons: results.notRestoredReasons, }, issues: results.blockingReasons.map((i) => ({ + code: i.code, severity: i.severity === 'high' ? 'error' : i.severity === 'medium' ? 'warning' : 'info', message: i.reason, + ...(i.code === 'not-restored' ? { target: i.reason } : {}), })), }); diff --git a/snippets/Loading/Cache-Strategy-Analysis.js b/snippets/Loading/Cache-Strategy-Analysis.js index 9e496f4a..20a77e16 100644 --- a/snippets/Loading/Cache-Strategy-Analysis.js +++ b/snippets/Loading/Cache-Strategy-Analysis.js @@ -337,6 +337,7 @@ if (isStaticAsset(type) && cc["no-store"]) { entry.antiPatterns.push({ + code: "static-no-store", id: "static-no-store", severity: "error", message: `${type} asset with no-store; static assets should be cacheable`, @@ -345,6 +346,7 @@ if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) { entry.antiPatterns.push({ + code: "versioned-short-cache", id: "versioned-short-cache", severity: "warning", message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable`, @@ -353,6 +355,7 @@ if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) { entry.antiPatterns.push({ + code: "versioned-not-immutable", id: "versioned-not-immutable", severity: "info", message: `Versioned URL (hash detected) without Cache-Control: immutable`, @@ -364,6 +367,7 @@ ["none", "no-store", "no-cache"].includes(strategy) ) { entry.antiPatterns.push({ + code: "large-no-cache", id: "large-no-cache", severity: "error", message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`, @@ -372,6 +376,7 @@ if (entry.headers["expires"] && !entry.headers["cache-control"]) { entry.antiPatterns.push({ + code: "expires-without-cc", id: "expires-without-cc", severity: "warning", message: `Uses Expires header without Cache-Control (outdated pattern)`, @@ -380,6 +385,7 @@ if (isStaticAsset(type) && maxAge === 0) { entry.antiPatterns.push({ + code: "maxage-zero-static", id: "maxage-zero-static", severity: "warning", message: `Static asset with max-age=0, which forces revalidation on every request`, @@ -394,6 +400,7 @@ !cc["no-store"] ) { entry.antiPatterns.push({ + code: "short-cache-static", id: "short-cache-static", severity: "warning", message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration`, @@ -472,6 +479,7 @@ // are anti-patterns like the header-based ones: they reach `items` and `issues`. for (const entry of uncompressedResources) { entry.antiPatterns.push({ + code: "uncompressed", id: "uncompressed", severity: "warning", message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip`, @@ -480,6 +488,7 @@ for (const duplicate of costlyDuplicates) { // One finding per URL, on the first request, not one per request entries.find((e) => e.url === duplicate.url).antiPatterns.push({ + code: "duplicate-uncached", id: "duplicate-uncached", severity: "warning", message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable`, @@ -551,6 +560,7 @@ .join(", "); coverageIssue = { severity: "info", + code: "header-coverage", id: "header-coverage", 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`, }; @@ -916,10 +926,12 @@ })), issues: [ ...allAntiPatterns.map((p) => ({ + code: p.code, severity: p.severity, id: p.id, resource: p.resource, message: p.message, + target: p.url, })), ...(coverageIssue ? [coverageIssue] : []), ], diff --git a/snippets/Loading/Client-Side-Redirect-Detection.js b/snippets/Loading/Client-Side-Redirect-Detection.js index a55047b4..75d392dc 100644 --- a/snippets/Loading/Client-Side-Redirect-Detection.js +++ b/snippets/Loading/Client-Side-Redirect-Detection.js @@ -99,6 +99,7 @@ if (sameOrigin && referrerPath !== currentPath && referrerPath !== '') { hasClientRedirect = true; indicators.push({ + code: 'same-origin-navigation', type: 'Same-origin navigation', from: referrerPath, to: currentPath, @@ -153,6 +154,7 @@ // Only flag if it's BOTH fast AND small (likely a redirect page) if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) { indicators.push({ + code: 'fast-minimal-navigation', type: 'Fast minimal-content navigation', duration: domContentLoaded.toFixed(1) + 'ms', size: (responseSize / 1024).toFixed(1) + ' KB', @@ -337,9 +339,9 @@ referrerPath, }, issues: [ - ...(serverRedirects > 0 ? [{ severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []), - ...(documentNavigations.length > 0 ? [{ severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []), - ...indicators.filter(i => i.severity === "warning").map(i => ({ severity: "warning", message: i.type })), + ...(serverRedirects > 0 ? [{ code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []), + ...(documentNavigations.length > 0 ? [{ code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []), + ...indicators.filter(i => i.severity === "warning").map(i => ({ code: i.code, severity: "warning", message: i.type })), ], }; })(); diff --git a/snippets/Loading/Compression-Audit.js b/snippets/Loading/Compression-Audit.js index f95e2474..9d1609af 100644 --- a/snippets/Loading/Compression-Audit.js +++ b/snippets/Loading/Compression-Audit.js @@ -119,18 +119,21 @@ const issues = []; if (uncompressed.length > 0) { issues.push({ + code: "uncompressed", severity: "warning", message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`, }); } if (corsLimitedAnalysis) { issues.push({ + code: "unknown-size", severity: "info", message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`, }); } if (!contentEncodingSupported && candidates.length > 0) { issues.push({ + code: "content-encoding-unavailable", severity: "info", message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize", }); diff --git a/snippets/Loading/Prefetch-Resource-Validation.js b/snippets/Loading/Prefetch-Resource-Validation.js index d23b6a05..1c733b4b 100644 --- a/snippets/Loading/Prefetch-Resource-Validation.js +++ b/snippets/Loading/Prefetch-Resource-Validation.js @@ -28,7 +28,7 @@ status: "ok", count: 0, items: [], - issues: [{ severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' }], + issues: [{ code: "no-prefetch", severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' }], }; } @@ -116,12 +116,14 @@ // Check for missing or invalid 'as' attribute if (as === "unknown") { analysis.warnings.push({ + code: "missing-as", type: "missing-as", severity: "warning", message: "Missing 'as' attribute - browser cannot apply correct MIME type matching", }); } else if (!validAsValues.has(as)) { analysis.warnings.push({ + code: "invalid-as", type: "invalid-as", severity: "warning", message: `Invalid 'as' value: "${as}" - should be one of: ${Array.from(validAsValues).join(", ")}`, @@ -131,6 +133,7 @@ // Check for duplicate prefetch if (seenUrls.has(normalizedUrl)) { analysis.warnings.push({ + code: "duplicate-prefetch", type: "duplicate-prefetch", severity: "warning", message: "Duplicate prefetch - this URL is prefetched multiple times", @@ -169,6 +172,7 @@ if (isCurrentPageResource) { analysis.isCurrentPage = true; analysis.warnings.push({ + code: "used-on-current-page", type: "wrong-hint", severity: "error", message: "Resource used on current page - should use preload instead", @@ -179,6 +183,7 @@ if (analysis.size > THRESHOLDS.largeFileSize) { const sizeMB = (analysis.size / (1024 * 1024)).toFixed(2); analysis.warnings.push({ + code: "large-file", type: "large-file", severity: "warning", message: `Large file (${sizeMB}MB) - consider if prefetch is appropriate`, @@ -203,6 +208,7 @@ if (isInappropriate) { analysis.warnings.push({ + code: "inappropriate-type", type: "inappropriate-type", severity: "warning", message: `Type "${as}" may not be suitable for prefetch: ${inappropriateReason}`, @@ -229,6 +235,7 @@ const countIssues = []; if (prefetchLinks.length > THRESHOLDS.maxCount) { countIssues.push({ + code: "too-many-prefetches", type: "excessive-count", severity: "warning", message: `${prefetchLinks.length} prefetch hints found (recommended: <${THRESHOLDS.maxCount})`, @@ -242,6 +249,7 @@ if (totalSize > THRESHOLDS.totalSizeCritical) { countIssues.push({ + code: "large-total-size", type: "excessive-size", severity: "error", message: `Total prefetch size: ${totalSizeMB}MB (critical threshold exceeded)`, @@ -249,6 +257,7 @@ }); } else if (totalSize > THRESHOLDS.totalSizeWarning) { countIssues.push({ + code: "large-total-size", type: "high-size", severity: "warning", message: `Total prefetch size: ${totalSizeMB}MB (warning threshold exceeded)`, @@ -463,8 +472,8 @@ }, items: [...validPrefetch, ...issues].map(item => ({ href: item.shortUrl, as: item.as, sizeBytes: item.size, loaded: item.loaded, isCurrentPage: item.isCurrentPage || false })), issues: [ - ...countIssues.map(i => ({ severity: i.severity === "error" ? "error" : "warning", message: i.message })), - ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ severity: w.severity, message: `${item.shortUrl}: ${w.message}` }))), + ...countIssues.map(i => ({ code: i.code, severity: i.severity === "error" ? "error" : "warning", message: i.message })), + ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ code: w.code, severity: w.severity, message: `${item.shortUrl}: ${w.message}`, target: item.href }))), ], }; })(); diff --git a/snippets/Loading/Priority-Hints-Audit.js b/snippets/Loading/Priority-Hints-Audit.js index 6b341d63..b1af9e6f 100644 --- a/snippets/Loading/Priority-Hints-Audit.js +++ b/snippets/Loading/Priority-Hints-Audit.js @@ -77,8 +77,10 @@ .filter((link) => link.getAttribute("fetchpriority") === "low") .forEach((link) => { issues.push({ + code: "preload-low-priority", severity: "error", element: link, + url: link.href, resource: shortUrl(link), message: 'preload + fetchpriority="low" is contradictory', detail: @@ -94,8 +96,10 @@ const entry = getEntry(el); if (entry && entry.startTime < 500) { issues.push({ + code: "low-priority-loaded-early", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="low" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: @@ -112,8 +116,10 @@ const entry = getEntry(el); if (entry && entry.startTime > 3000) { issues.push({ + code: "high-priority-loaded-late", severity: "warning", element: el, + url: el.src || el.href, resource: shortUrl(el), message: `fetchpriority="high" but loaded at ${entry.startTime.toFixed(0)}ms`, detail: @@ -127,8 +133,10 @@ if (lcpCandidate && lcpCandidate.getAttribute("fetchpriority") !== "high") { const fp = lcpCandidate.getAttribute("fetchpriority"); issues.push({ + code: "lcp-no-fetchpriority", severity: "warning", element: lcpCandidate, + url: lcpCandidate.currentSrc || lcpCandidate.src, resource: (lcpCandidate.currentSrc || lcpCandidate.src).split("/").pop()?.split("?")[0] || "LCP image", @@ -340,8 +348,10 @@ resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "", })), issues: issues.map(i => ({ + code: i.code, severity: i.severity, message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message, + ...(i.url ? { target: i.url } : {}), })), }; })(); diff --git a/snippets/Loading/Resource-Hints-Validation.js b/snippets/Loading/Resource-Hints-Validation.js index a659042f..e3c09f38 100644 --- a/snippets/Loading/Resource-Hints-Validation.js +++ b/snippets/Loading/Resource-Hints-Validation.js @@ -538,9 +538,9 @@ })), ].slice(0, 50), issues: [ - ...(totalIssues > 0 ? [{ severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []), - ...(missingHints.length > 0 ? [{ severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []), - ...(redundantDomains.length > 0 ? [{ severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` }] : []), + ...(totalIssues > 0 ? [{ code: "unused-hints", severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []), + ...(missingHints.length > 0 ? [{ code: "missing-connection-hints", severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []), + ...(redundantDomains.length > 0 ? [{ code: "redundant-hints", severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` }] : []), ], }; })(); diff --git a/snippets/Loading/Resource-Hints.js b/snippets/Loading/Resource-Hints.js index 646daa41..abc52fce 100644 --- a/snippets/Loading/Resource-Hints.js +++ b/snippets/Loading/Resource-Hints.js @@ -93,6 +93,7 @@ if (type.requiresAs && !hint.as) { issues.push({ hint, + code: "preload-missing-as", issue: "Missing 'as' attribute (required for preload)", severity: "error", fix: `Add as="script|style|font|image|fetch"`, @@ -102,6 +103,7 @@ if (hint.as === "font" && !hint.crossorigin) { issues.push({ hint, + code: "font-preload-no-crossorigin", issue: "Font preload without crossorigin (causes double fetch)", severity: "error", fix: 'Add crossorigin="anonymous"', @@ -122,6 +124,7 @@ if (corsResources.length > 0) { issues.push({ hint, + code: "preconnect-no-crossorigin", issue: "Preconnect may need crossorigin for CORS resources", severity: "warning", fix: 'Consider adding crossorigin="anonymous"', @@ -140,6 +143,7 @@ if (!wasUsed) { issues.push({ hint: preload, + code: "unused-preload", issue: "Preloaded resource not used within page load", severity: "warning", fix: "Remove unused preload or verify it's needed", @@ -162,6 +166,7 @@ if (!hasResources && pc.hostname) { issues.push({ hint: pc, + code: "unused-preconnect", issue: `No resources loaded from ${pc.hostname}`, severity: "warning", fix: "Remove unused preconnect or verify it's needed for future resources", @@ -173,6 +178,7 @@ if (preloads.length > 6) { issues.push({ hint: { rel: "preload", href: `${preloads.length} preloads` }, + code: "too-many-preloads", issue: `Too many preloads (${preloads.length}) - they compete for bandwidth`, severity: "warning", fix: "Limit to 5-6 most critical resources", @@ -424,6 +430,6 @@ })), }, items: rankedHints.slice(0, MAX_ITEMS).map(h => ({ rel: h.rel, href: h.shortHref, as: h.as, crossorigin: h.crossorigin, fetchpriority: h.fetchpriority })), - issues: issues.map(i => ({ severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` })), + issues: issues.map(i => ({ code: i.code, severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}`, ...(i.hint.element ? { target: i.hint.href } : {}) })), }; })(); diff --git a/snippets/Loading/Server-Timing-Early-Hints.js b/snippets/Loading/Server-Timing-Early-Hints.js index ff2fc174..d541ec7b 100644 --- a/snippets/Loading/Server-Timing-Early-Hints.js +++ b/snippets/Loading/Server-Timing-Early-Hints.js @@ -83,6 +83,7 @@ const issues = []; if (nav.serverTiming.length === 0) { issues.push({ + code: "no-server-timing", severity: "info", message: "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.", @@ -90,12 +91,14 @@ } if (corsRestrictedCount > 0) { issues.push({ + code: "timing-hidden", severity: "info", 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.`, }); } if (!earlyHintsReceived) { issues.push({ + code: "no-early-hints", severity: "info", message: "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.", diff --git a/snippets/Loading/Service-Worker-Analysis.js b/snippets/Loading/Service-Worker-Analysis.js index 8cc26a18..919e18a6 100644 --- a/snippets/Loading/Service-Worker-Analysis.js +++ b/snippets/Loading/Service-Worker-Analysis.js @@ -22,7 +22,7 @@ count: 0, details: {}, items: [], - issues: [{ severity: "error", message: "Service Workers not supported in this browser" }], + issues: [{ code: "unsupported", severity: "error", message: "Service Workers not supported in this browser" }], }; } @@ -116,11 +116,12 @@ const issues = []; if (registrations.length === 0) { - issues.push({ severity: "info", message: "No Service Workers registered" }); + issues.push({ code: "no-service-worker", severity: "info", message: "No Service Workers registered" }); } if (registrations.length > 0 && !controller) { issues.push({ + code: "not-controlled", severity: "warning", message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload.", }); @@ -129,25 +130,31 @@ for (const item of items) { if (item.hasWaiting) { issues.push({ + code: "update-pending", severity: "warning", message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`, + target: item.scope, }); } if (item.navigationPreloadEnabled === false) { issues.push({ + code: "navigation-preload-disabled", severity: "info", message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`, + target: item.scope, }); } } if (swOverheadMs !== null && swOverheadMs > 100) { issues.push({ + code: "slow-startup", severity: "error", message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.`, }); } else if (swOverheadMs !== null && swOverheadMs > 50) { issues.push({ + code: "slow-startup", severity: "warning", message: `Moderate SW startup time (${swOverheadMs}ms)`, }); @@ -155,11 +162,13 @@ if (cacheHitRate !== null && cacheHitRate < 50) { issues.push({ + code: "low-cache-hit-rate", severity: "error", message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.`, }); } else if (cacheHitRate !== null && cacheHitRate < 80) { issues.push({ + code: "low-cache-hit-rate", severity: "warning", message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.`, }); @@ -167,6 +176,7 @@ if (unknownSource.length > 0) { issues.push({ + code: "unknown-source", severity: "info", message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`, }); diff --git a/snippets/Loading/Speculation-Rules-Inspector.js b/snippets/Loading/Speculation-Rules-Inspector.js index 0ec811ee..78b52c88 100644 --- a/snippets/Loading/Speculation-Rules-Inspector.js +++ b/snippets/Loading/Speculation-Rules-Inspector.js @@ -39,6 +39,7 @@ parsed = JSON.parse(script.textContent); } catch (e) { issues.push({ + code: 'invalid-json', severity: 'error', message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.`, }); @@ -68,12 +69,14 @@ const isPrerender = action === 'prerender' || action === 'prerender_until_script'; if (isPrerender && source === 'list' && (eagerness === 'immediate' || eagerness === 'eager') && urls.length > EAGER_PRERENDER_LIMIT) { issues.push({ + code: 'eager-list-capped', severity: 'warning', message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`, }); } if (isPrerender && source === 'document' && (eagerness === 'immediate' || eagerness === 'eager')) { issues.push({ + code: 'eager-document-rule', severity: 'warning', message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`, }); @@ -83,15 +86,15 @@ }); if (scripts.length === 0) { - issues.push({ severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' }); + issues.push({ code: 'no-rules', severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' }); } else if (items.length === 0 && !issues.some((i) => i.severity === 'error')) { - issues.push({ severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' }); + issues.push({ code: 'no-prefetch-or-prerender', severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' }); } if (!supported && scripts.length > 0) { - issues.push({ severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' }); + issues.push({ code: 'unsupported', severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' }); } if (activationStart > 0) { - issues.push({ severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); + issues.push({ code: 'prerendered', severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` }); } return { diff --git a/snippets/Loading/TTFB-Resources.js b/snippets/Loading/TTFB-Resources.js index 28f1eeba..001d7507 100644 --- a/snippets/Loading/TTFB-Resources.js +++ b/snippets/Loading/TTFB-Resources.js @@ -168,7 +168,7 @@ corsLimitedAnalysis: corsRestrictedCount > 0, details: { avgTtfbMs: null, maxTtfbMs: null, minTtfbMs: null, thirdPartyCount: 0, slowCount: 0, corsRestrictedCount }, items: [], - issues: [{ severity: "info", message: "No resources with TTFB data available" }], + issues: [{ code: "no-resources", severity: "info", message: "No resources with TTFB data available" }], }; } const ttfbVals = resourcesSync.map((r) => r.ttfbMs); diff --git a/snippets/Loading/TTFB-Sub-Parts.js b/snippets/Loading/TTFB-Sub-Parts.js index d2cce2f2..a872afc7 100644 --- a/snippets/Loading/TTFB-Sub-Parts.js +++ b/snippets/Loading/TTFB-Sub-Parts.js @@ -129,6 +129,7 @@ const slowestMs = Math.round(slowestSync.duration); const share = ttfbValueSync > 0 ? Math.round((slowestSync.duration / ttfbValueSync) * 100) : 0; issues.push({ + code: "slow-ttfb", severity: rating === "poor" ? "error" : "warning", message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.`, }); diff --git a/snippets/SCHEMA.md b/snippets/SCHEMA.md index 988a08ea..975142c7 100644 --- a/snippets/SCHEMA.md +++ b/snippets/SCHEMA.md @@ -1298,6 +1298,143 @@ Each issue names the preloaded script in `target`. | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority | | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` | +### Back-Forward-Cache + +| Code | Severity | When | +|------|----------|------| +| `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame | +| `unload-handler` | error | A `window.onunload` handler | +| `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response | +| `iframes` | info | Iframes on the page, whose blockers block the whole page | +| `service-worker` | info | A Service Worker controls the page, which bfcache allows | + +### Cache-Strategy-Analysis + +Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. + +| Code | Severity | When | +|------|----------|------| +| `static-no-store` | error | A static asset with `no-store` | +| `versioned-short-cache` | warning | A URL with a hash and a short `max-age` | +| `versioned-not-immutable` | info | A URL with a hash and no `immutable` | +| `large-no-cache` | error | A large resource with no effective cache | +| `expires-without-cc` | warning | An `Expires` header without `Cache-Control` | +| `maxage-zero-static` | warning | A static asset with `max-age=0` | +| `short-cache-static` | warning | A static asset with a `max-age` under an hour | +| `uncompressed` | warning | A text resource sent uncompressed | +| `duplicate-uncached` | warning | A resource loaded several times from the network | +| `header-coverage` | info | The share of resources whose cache headers could be read | + +### Client-Side-Redirect-Detection + +| Code | Severity | When | +|------|----------|------| +| `server-redirects` | warning | Server-side redirects before the document | +| `document-navigation` | error | Same-origin document navigations, likely a client-side redirect | +| `same-origin-navigation` | warning | A same-origin referrer with another path | +| `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page | + +### Compression-Audit + +| Code | Severity | When | +|------|----------|------| +| `uncompressed` | warning | Text resources served without compression | +| `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown | +| `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes | + +### Prefetch-Resource-Validation + +Each issue about one prefetch names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `no-prefetch` | info | No `rel="prefetch"` hints on the page | +| `missing-as` | warning | A prefetch without `as` | +| `invalid-as` | warning | A prefetch with an `as` value that does not exist | +| `duplicate-prefetch` | warning | A URL prefetched more than once | +| `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` | +| `large-file` | warning | A prefetched file above 500 KB | +| `inappropriate-type` | warning | A prefetched type that may not suit prefetch | +| `too-many-prefetches` | warning | More than 10 prefetch hints | +| `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB | + +### Priority-Hints-Audit + +Each issue names its resource in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-low-priority` | error | A preload with `fetchpriority="low"` | +| `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early | +| `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late | +| `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` | + +### Resource-Hints + +Each issue about one hint names its URL in `target`. + +| Code | Severity | When | +|------|----------|------| +| `preload-missing-as` | error | A preload without `as` | +| `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice | +| `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources | +| `unused-preload` | warning | A preload that the page did not use during the load | +| `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from | +| `too-many-preloads` | warning | More than 6 preloads | + +### Resource-Hints-Validation + +| Code | Severity | When | +|------|----------|------| +| `unused-hints` | warning | Hints the page did not use | +| `missing-connection-hints` | info | Domains that could use a connection hint | +| `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` | + +### Server-Timing-Early-Hints + +| Code | Severity | When | +|------|----------|------| +| `no-server-timing` | info | The document response has no `Server-Timing` header | +| `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read | +| `no-early-hints` | info | No 103 Early Hints response for the document | + +### Service-Worker-Analysis + +| Code | Severity | When | +|------|----------|------| +| `unsupported` | error | The browser has no Service Workers | +| `no-service-worker` | info | No Service Worker registered | +| `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit | +| `update-pending` | warning | A waiting update; `target` is the scope | +| `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope | +| `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms | +| `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% | +| `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate | + +### Speculation-Rules-Inspector + +| Code | Severity | When | +|------|----------|------| +| `invalid-json` | error | A speculation rules script that is not valid JSON | +| `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders | +| `eager-document-rule` | warning | A document prerender rule with eager eagerness | +| `no-rules` | info | No speculation rules in the DOM | +| `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules | +| `unsupported` | info | The browser does not support speculation rules | +| `prerendered` | info | The page was prerendered, so its timings start at `activationStart` | + +### TTFB-Resources + +| Code | Severity | When | +|------|----------|------| +| `no-resources` | info | No resources with TTFB data | + +### TTFB-Sub-Parts + +| Code | Severity | When | +|------|----------|------| +| `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase | + ### DOM-Size-and-Depth | Code | Severity | When |