Skip to content

Badge caching, HTTP semantics, and dev server fixes#2

Merged
amery merged 5 commits into
mainfrom
pr-amery-nuxt
Apr 2, 2026
Merged

Badge caching, HTTP semantics, and dev server fixes#2
amery merged 5 commits into
mainfrom
pr-amery-nuxt

Conversation

@amery

@amery amery commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fix badge vertical alignment in prose tables (scoped CSS beats prose specificity)
  • Add dir prop to BadgeVersionGo for subpackages sharing a parent go.mod
  • Refactor badge endpoints into a BadgeHandler class that encapsulates method
    dispatch, input validation, cache headers, SVG rendering, and cache management
    — each endpoint now provides only its identity and fetch callback
  • Set proper HTTP semantics: weak ETag, Last-Modified, split Cache-Control
    (max-age=3600 browser, s-maxage=300 CF edge), 405 method guard. The npm
    handler uses $fetch.raw to extract Last-Modified from registry headers
  • DELETE on a badge URL busts the KV cache and purges the CF edge
  • Cache version lookups in Cloudflare KV with per-entry TTL (1 hour success,
    60 seconds error — failed entries auto-expire). In-flight deduplication
    coalesces concurrent requests; all KV operations degrade gracefully
  • Strip unresolvable @nuxtjs/mdc optimizeDeps entries via vite:extendConfig hook
  • Prepend nuxt cleanup to dev script to prevent stale client-side SQL dumps
  • Add consola for structured logging in badge handlers

Test plan

  • pnpm precommit passes (generate, lint, typecheck)
  • Badge renders correctly on /projects/darvaza-slog (8 Go module badges in tables)
  • Verify curl -I shows ETag, Last-Modified, Cache-Control with s-maxage=300
  • curl -X DELETE returns 204 and next GET fetches fresh
  • curl -X POST returns 405 with Allow: GET, HEAD, DELETE
  • Dev server starts clean after content changes (no stale 404s)
  • No optimizeDeps warnings from @nuxtjs/mdc in dev server output

Summary by CodeRabbit

  • New Features

    • Cloudflare KV-backed version cache with per-key TTLs, in-flight request coalescing, explicit DELETE invalidation and edge purge; split browser vs edge caching with weak ETag and Last-Modified support
  • UI / Style

    • Go version badge accepts optional subpath; badge alignment and image sizing adjusted; added dedicated npm/go badge components
  • Documentation

    • Expanded badge, caching, and dev workflow docs; README updated to document the version cache
  • Chores

    • Dev startup runs cleanup before serving; added structured logging runtime dependency

amery added 2 commits March 30, 2026 20:43
BadgeVersion: override prose's img margin with a scoped class and
use inline-flex so badges sit inline within table cells.

BadgeVersionGo: add optional dir prop for subpackages that share
a parent go.mod — badge fetches the parent module version while
the link points to the specific package on pkg.go.dev.

Signed-off-by: Alejandro Mery <amery@apptly.co>
The MDC module pushes remark/rehype dependencies into Vite's
optimizeDeps.include after config merges, but they can't be
resolved (server-only). The static `include: []` didn't help.

Use a vite:extendConfig hook to filter out entries containing
"@nuxtjs/mdc >" after all modules have registered theirs.

Signed-off-by: Alejandro Mery <amery@apptly.co>
@amery amery added bug Something isn't working enhancement New feature or request labels Mar 30, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Mar 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
awesome-apptly b3f8941 Commit Preview URL

Branch Preview URL
Apr 02 2026, 01:29 AM

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Badge caching, HTTP semantics, and dev server fixes

✨ Enhancement 🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Add throttled version caching with HTTP semantics (ETag, Last-Modified, split Cache-Control)
• Implement DELETE method to bust in-memory cache and purge CF edge cache
• Fix badge vertical alignment in prose tables with scoped CSS
• Add dir prop to BadgeVersionGo for subpackages sharing parent go.mod
• Strip unresolvable @nuxtjs/mdc optimizeDeps entries via vite:extendConfig hook
• Prepend nuxt cleanup to dev script to prevent stale client-side SQL dumps
• Add consola for structured logging in badge handlers
Diagram
flowchart LR
  A["Badge Requests<br/>GET/HEAD/DELETE"] -->|"assertBadgeMethod"| B["Method Guard<br/>405 + Allow header"]
  A -->|"fetchVersion"| C["Version Cache<br/>1hr throttle"]
  C -->|"Poll Upstream"| D["Go Proxy/<br/>npm Registry"]
  A -->|"DELETE"| E["Cache Bust<br/>+ CF Purge"]
  C -->|"renderBadge"| F["HTTP Headers<br/>ETag, Last-Modified<br/>Cache-Control"]
  F -->|"SVG Response"| G["Badge Image"]
  H["Dev Server"] -->|"nuxt cleanup"| I["Remove Stale<br/>SQL Dumps"]
  J["Vite Config"] -->|"extendConfig hook"| K["Filter MDC<br/>optimizeDeps"]
Loading

Grey Divider

File Changes

1. app/components/badge-version-go.vue ✨ Enhancement +5/-3

Add dir prop for subpackage support

app/components/badge-version-go.vue


2. app/components/badge-version.vue 🐞 Bug fix +7/-1

Fix badge alignment and add scoped styles

app/components/badge-version.vue


3. nuxt.config.ts 🐞 Bug fix +12/-3

Filter MDC optimizeDeps via vite hook

nuxt.config.ts


View more (8)
4. server/api/badge/go/[...package].ts ✨ Enhancement +20/-5

Add caching, HTTP semantics, DELETE support

server/api/badge/go/[...package].ts


5. server/api/badge/npm/[...package].ts ✨ Enhancement +20/-5

Add caching, HTTP semantics, DELETE support

server/api/badge/npm/[...package].ts


6. server/utils/badge.ts ✨ Enhancement +42/-10

Implement HTTP headers and method validation

server/utils/badge.ts


7. server/utils/version-cache.ts ✨ Enhancement +65/-0

New throttled version caching utility

server/utils/version-cache.ts


8. AGENTS.md 📝 Documentation +30/-10

Document badge endpoints and caching behavior

AGENTS.md


9. README.md 📝 Documentation +4/-7

Update dev setup and content DB instructions

README.md


10. package.json Dependencies +2/-1

Add consola dependency and nuxt cleanup to dev

package.json


11. pnpm-lock.yaml Dependencies +3/-0

Lock consola dependency version

pnpm-lock.yaml


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Mar 30, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (1) 📎 Requirement gaps (0)

Grey Divider


Action required

1. BadgeHandler allows HEAD 📘 Rule violation ≡ Correctness ⭐ New
Description
The badge handler explicitly allows HEAD requests and includes HEAD in the Allow header, but
the compliance checklist requires only GET and DELETE to be accepted (all others must return
405). This can cause non-compliant method behaviour across all badge endpoints using
BadgeHandler.
Code

server/utils/badge.ts[R62-70]

+    switch (event.method) {
+      case 'GET':
+      case 'HEAD':
+      case 'DELETE':
+        break;
+      default:
+        setResponseStatus(event, 405);
+        setHeader(event, 'allow', 'GET, HEAD, DELETE');
+        return '';
Evidence
PR Compliance ID 7 requires that only GET and DELETE are accepted and other methods return 405
with an Allow header. The new BadgeHandler method dispatch allows HEAD and advertises it in
the Allow header.

AGENTS.md
server/utils/badge.ts[62-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`BadgeHandler` currently treats `HEAD` as an allowed method and returns `Allow: GET, HEAD, DELETE`, but the compliance requirement states only `GET` and `DELETE` may be accepted; all other methods must return `405` with an `Allow` header.

## Issue Context
This affects all badge endpoints that instantiate `BadgeHandler` (e.g. go/npm), since the method guard is centralised in `server/utils/badge.ts`.

## Fix Focus Areas
- server/utils/badge.ts[62-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unbounded version cache growth 🐞 Bug ⛨ Security
Description
server/utils/version-cache.ts caches every requested badge key in a module-level Map without
eviction, and the key is derived from the user-controlled route param in both badge endpoints. An
attacker (or crawl) can request many unique packages to grow memory unbounded and destabilize the
worker/process.
Code

server/utils/version-cache.ts[R12-62]

+const POLL_INTERVAL = 3_600_000; // 1 hour
+const POLL_INTERVAL_ERROR = 60_000; // 1 minute
+
+const versions = new Map<string, VersionEntry>();
+
+/** Remove a cached version entry, forcing a fresh upstream poll. */
+export function deleteCachedVersion(key: string): void {
+  versions.delete(key);
+}
+
+/**
+ * Returns the last known version, polling upstream only when
+ * the throttle window has elapsed. Uses the upstream publish
+ * time when available, otherwise preserves firstSeen when
+ * the version is unchanged across refreshes.
+ */
+export async function fetchVersion(
+  key: string,
+  fetcher: () => Promise<FetchResult | undefined>,
+): Promise<{ version: string | undefined; firstSeen: Date }> {
+  const entry = versions.get(key);
+  const now = Date.now();
+
+  // Throttled — return cached
+  if (entry && now < entry.nextCheck) {
+    return { version: entry.version, firstSeen: entry.firstSeen };
+  }
+
+  // Poll upstream
+  const result = await fetcher();
+  const version = result?.version;
+
+  // Upstream time > stable firstSeen > new Date
+  let firstSeen: Date;
+  if (result?.time) {
+    firstSeen = result.time;
+  } else if (entry?.version !== undefined && entry.version === version) {
+    firstSeen = entry.firstSeen;
+  } else {
+    firstSeen = new Date();
+  }
+
+  const interval = version === undefined ?
+    POLL_INTERVAL_ERROR :
+    POLL_INTERVAL;
+
+  versions.set(key, {
+    version,
+    firstSeen,
+    nextCheck: now + interval,
+  });
Evidence
The version cache is a global Map that only ever grows via versions.set(...) and is never pruned
unless a specific key is deleted. The go/npm handlers construct cache keys directly from the router
parameter, and there is no server middleware shown that would rate-limit or authenticate these
requests.

server/utils/version-cache.ts[12-65]
server/api/badge/go/[...package].ts[10-35]
server/api/badge/npm/[...package].ts[10-35]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`versions` is an unbounded, attacker-influenced in-memory Map. Over time (or under abuse) this can grow without limit.
### Issue Context
Keys are derived from request router params (`go:${pkg}`, `npm:${pkg}`) and stored indefinitely.
### Fix Focus Areas
- Implement a size cap + eviction (LRU) or TTL cleanup independent of `nextCheck`.
- Consider rejecting/cache-bypassing obviously non-existent packages less aggressively, or adding basic rate limiting for DELETE/refresh paths.
- Ensure eviction policy is deterministic and covered by a small unit test if this repo has a test framework.
### Fix Focus Areas (code references)
- server/utils/version-cache.ts[12-65]
- server/api/badge/go/[...package].ts[10-35]
- server/api/badge/npm/[...package].ts[10-35]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. ETag equality check 🐞 Bug ≡ Correctness ⭐ New
Description
BadgeHandler.setCacheHeaders only returns 304 when the If-None-Match header exactly equals the
generated weak ETag, so wildcard (*) and multi-value (comma-separated) If-None-Match headers won’t
revalidate and will return 200 instead.
Code

server/utils/badge.ts[R111-121]

+    const etag = `W/"${version}"`;
+    setHeader(event, 'cache-control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_S_MAX_AGE}`);
+    setHeader(event, 'etag', etag);
+    if (lastModified) {
+      setHeader(event, 'last-modified', lastModified.toUTCString());
+    }
+
+    // h3 doesn't check conditional headers automatically
+    const ifNoneMatch = getRequestHeader(event, 'if-none-match');
+    return ifNoneMatch === etag;
+  }
Evidence
The handler generates a weak ETag and then performs a strict string equality check against the raw
If-None-Match header, without parsing multiple ETags or handling the wildcard case.

server/utils/badge.ts[111-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`BadgeHandler.setCacheHeaders()` uses `ifNoneMatch === etag`, which fails for valid `If-None-Match` forms like `*` and comma-separated lists. This prevents expected `304 Not Modified` responses and reduces cache efficiency.

### Issue Context
The handler emits a weak ETag (`W/"${version}"`) and should treat `If-None-Match` according to HTTP semantics (handle `*`, and check each comma-separated value after trimming).

### Fix Focus Areas
- server/utils/badge.ts[104-121]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. If-Modified-Since ignored 🐞 Bug ≡ Correctness ⭐ New
Description
BadgeHandler sets Last-Modified but never evaluates If-Modified-Since, so clients/proxies using
Last-Modified-based revalidation will never receive 304 responses.
Code

server/utils/badge.ts[R114-121]

+    if (lastModified) {
+      setHeader(event, 'last-modified', lastModified.toUTCString());
+    }
+
+    // h3 doesn't check conditional headers automatically
+    const ifNoneMatch = getRequestHeader(event, 'if-none-match');
+    return ifNoneMatch === etag;
+  }
Evidence
The code emits a last-modified header when available, but the conditional request logic only
inspects if-none-match and has no if-modified-since handling path.

server/utils/badge.ts[114-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`BadgeHandler.setCacheHeaders()` emits `Last-Modified` but does not check `If-Modified-Since`, preventing `304 Not Modified` responses for clients that revalidate using that header.

### Issue Context
Per standard precedence rules, check `If-None-Match` first; if it’s absent and `lastModified` exists, compare `If-Modified-Since` to `lastModified` and return 304 when appropriate.

### Fix Focus Areas
- server/utils/badge.ts[105-121]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. No in-flight refresh dedupe 🐞 Bug ➹ Performance
Description
fetchVersion() does not coalesce concurrent refreshes when nextCheck has elapsed, so multiple
simultaneous requests for the same badge can trigger multiple upstream fetches. This defeats the
intended throttling and can spike upstream traffic at each refresh boundary.
Code

server/utils/version-cache.ts[R32-42]

+  const entry = versions.get(key);
+  const now = Date.now();
+
+  // Throttled — return cached
+  if (entry && now < entry.nextCheck) {
+    return { version: entry.version, firstSeen: entry.firstSeen };
+  }
+
+  // Poll upstream
+  const result = await fetcher();
+  const version = result?.version;
Evidence
When now >= entry.nextCheck, every concurrent request passes the throttle check and reaches `await
fetcher() before any request stores the updated nextCheck`, because there is no lock/in-flight
promise stored in the entry.

server/utils/version-cache.ts[28-64]
server/api/badge/go/[...package].ts[25-33]
server/api/badge/npm/[...package].ts[25-33]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Concurrent requests can all call the upstream `fetcher()` at the same time when the refresh window opens.
### Issue Context
`fetchVersion()` has a time-based throttle but no in-flight tracking.
### Fix Focus Areas
- Track an `inFlight` Promise per key (separate Map or add to VersionEntry) and return it to concurrent callers.
- Ensure the `inFlight` reference is cleared on both success and failure.
### Fix Focus Areas (code references)
- server/utils/version-cache.ts[28-64]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
6. Invalid Last-Modified header 🐞 Bug ≡ Correctness
Description
The Go badge handler creates a Date from the upstream Time string without validation; if it’s
invalid, renderBadge() will still set Last-Modified to the literal string Invalid Date. This
breaks HTTP caching semantics for that response.
Code

server/api/badge/go/[...package].ts[R25-33]

+  const { version, firstSeen } = await fetchVersion(`go:${pkg}`, async () => {
+    const proxyUrl = `https://proxy.golang.org/${pkg}/@latest`;
+    const info = await $fetch<{ Version: string; Time: string }>(proxyUrl).catch((error) => {
+      logger.warn(`failed to fetch ${pkg}:`, error);
+      return undefined;
+    });
+    if (!info) return undefined;
+    return { version: info.Version, time: new Date(info.Time) };
 });
Evidence
The handler passes time: new Date(info.Time) into the cache, and renderBadge() uses
lastModified.toUTCString() whenever a Date object is provided (an Invalid Date object is still
truthy).

server/api/badge/go/[...package].ts[25-35]
server/utils/badge.ts[28-37]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Last-Modified` can be set to an invalid HTTP-date string when `info.Time` cannot be parsed.
### Issue Context
`new Date(info.Time)` can produce an Invalid Date; `toUTCString()` then returns `"Invalid Date"`.
### Fix Focus Areas
- Validate parsed time in the Go fetcher (`const d = new Date(...); if (isNaN(d.getTime())) omit time`).
- Alternatively/additionally, harden `renderBadge()` to only set `last-modified` when `!isNaN(lastModified.getTime())`.
### Fix Focus Areas (code references)
- server/api/badge/go/[...package].ts[25-33]
- server/utils/badge.ts[31-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

7. Cache purge assumes default cache 🐞 Bug ≡ Correctness
Description
purgeBadgeCache() checks only for the existence of global caches and then unconditionally accesses
caches.default. In non-Cloudflare runtimes that expose CacheStorage but not the
Cloudflare-specific .default, DELETE requests would throw.
Code

server/utils/badge.ts[R65-68]

+export async function purgeBadgeCache(event: H3Event): Promise<void> {
+  if (typeof caches !== 'undefined') {
+    await caches.default.delete(getRequestURL(event).href);
+  }
Evidence
The code documents that caches.default is a Cloudflare extension, but the runtime guard does not
verify that .default exists before calling .delete(...).

server/utils/badge.ts[4-9]
server/utils/badge.ts[64-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`purgeBadgeCache()` may throw if `caches` exists but `caches.default` does not.
### Issue Context
This is mainly a portability/defensive issue; Cloudflare Workers provides `caches.default`, but other runtimes may not.
### Fix Focus Areas
- Add a runtime guard for `caches.default` (or optional chaining): `await caches?.default?.delete(url)`.
### Fix Focus Areas (code references)
- server/utils/badge.ts[64-68]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Grey Divider

Previous review results

Review updated until commit b3f8941

Results up to commit 82ff7e3


🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider
Action required
1. Unbounded version cache growth 🐞 Bug ⛨ Security
Description
server/utils/version-cache.ts caches every requested badge key in a module-level Map without
eviction, and the key is derived from the user-controlled route param in both badge endpoints. An
attacker (or crawl) can request many unique packages to grow memory unbounded and destabilize the
worker/process.
Code

server/utils/version-cache.ts[R12-62]

+const POLL_INTERVAL = 3_600_000; // 1 hour
+const POLL_INTERVAL_ERROR = 60_000; // 1 minute
+
+const versions = new Map<string, VersionEntry>();
+
+/** Remove a cached version entry, forcing a fresh upstream poll. */
+export function deleteCachedVersion(key: string): void {
+  versions.delete(key);
+}
+
+/**
+ * Returns the last known version, polling upstream only when
+ * the throttle window has elapsed. Uses the upstream publish
+ * time when available, otherwise preserves firstSeen when
+ * the version is unchanged across refreshes.
+ */
+export async function fetchVersion(
+  key: string,
+  fetcher: () => Promise<FetchResult | undefined>,
+): Promise<{ version: string | undefined; firstSeen: Date }> {
+  const entry = versions.get(key);
+  const now = Date.now();
+
+  // Throttled — return cached
+  if (entry && now < entry.nextCheck) {
+    return { version: entry.version, firstSeen: entry.firstSeen };
+  }
+
+  // Poll upstream
+  const result = await fetcher();
+  const version = result?.version;
+
+  // Upstream time > stable firstSeen > new Date
+  let firstSeen: Date;
+  if (result?.time) {
+    firstSeen = result.time;
+  } else if (entry?.version !== undefined && entry.version === version) {
+    firstSeen = entry.firstSeen;
+  } else {
+    firstSeen = new Date();
+  }
+
+  const interval = version === undefined ?
+    POLL_INTERVAL_ERROR :
+    POLL_INTERVAL;
+
+  versions.set(key, {
+    version,
+    firstSeen,
+    nextCheck: now + interval,
+  });
Evidence
The version cache is a global Map that only ever grows via versions.set(...) and is never pruned
unless a specific key is deleted. The go/npm handlers construct cache keys directly from the router
parameter, and there is no server middleware shown that would rate-limit or authenticate these
requests.

server/utils/version-cache.ts[12-65]
server/api/badge/go/[...package].ts[10-35]
server/api/badge/npm/[...package].ts[10-35]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`versions` is an unbounded, attacker-influenced in-memory Map. Over time (or under abuse) this can grow without limit.

### Issue Context
Keys are derived from request router params (`go:${pkg}`, `npm:${pkg}`) and stored indefinitely.

### Fix Focus Areas
- Implement a size cap + eviction (LRU) or TTL cleanup independent of `nextCheck`.
- Consider rejecting/cache-bypassing obviously non-existent packages less aggressively, or adding basic rate limiting for DELETE/refresh paths.
- Ensure eviction policy is deterministic and covered by a small unit test if this repo has a test framework.

### Fix Focus Areas (code references)
- server/utils/version-cache.ts[12-65]
- server/api/badge/go/[...package].ts[10-35]
- server/api/badge/npm/[...package].ts[10-35]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. No in-flight refresh dedupe 🐞 Bug ➹ Performance
Description
fetchVersion() does not coalesce concurrent refreshes when nextCheck has elapsed, so multiple
simultaneous requests for the same badge can trigger multiple upstream fetches. This defeats the
intended throttling and can spike upstream traffic at each refresh boundary.
Code

server/utils/version-cache.ts[R32-42]

+  const entry = versions.get(key);
+  const now = Date.now();
+
+  // Throttled — return cached
+  if (entry && now < entry.nextCheck) {
+    return { version: entry.version, firstSeen: entry.firstSeen };
+  }
+
+  // Poll upstream
+  const result = await fetcher();
+  const version = result?.version;
Evidence
When now >= entry.nextCheck, every concurrent request passes the throttle check and reaches `await
fetcher() before any request stores the updated nextCheck`, because there is no lock/in-flight
promise stored in the entry.

server/utils/version-cache.ts[28-64]
server/api/badge/go/[...package].ts[25-33]
server/api/badge/npm/[...package].ts[25-33]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Concurrent requests can all call the upstream `fetcher()` at the same time when the refresh window opens.

### Issue Context
`fetchVersion()` has a time-based throttle but no in-flight tracking.

### Fix Focus Areas
- Track an `inFlight` Promise per key (separate Map or add to VersionEntry) and return it to concurrent callers.
- Ensure the `inFlight` reference is cleared on both success and failure.

### Fix Focus Areas (code references)
- server/utils/version-cache.ts[28-64]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Invalid Last-Modified header 🐞 Bug ⛯ Reliability
Description
The Go badge handler creates a Date from the upstream Time string without validation; if it’s
invalid, renderBadge() will still set Last-Modified to the literal string Invalid Date. This
breaks HTTP caching semantics for that response.
Code

server/api/badge/go/[...package].ts[R25-33]

+  const { version, firstSeen } = await fetchVersion(`go:${pkg}`, async () => {
+    const proxyUrl = `https://proxy.golang.org/${pkg}/@latest`;
+    const info = await $fetch<{ Version: string; Time: string }>(proxyUrl).catch((error) => {
+      logger.warn(`failed to fetch ${pkg}:`, error);
+      return undefined;
+    });
+    if (!info) return undefined;
+    return { version: info.Version, time: new Date(info.Time) };
  });
Evidence
The handler passes time: new Date(info.Time) into the cache, and renderBadge() uses
lastModified.toUTCString() whenever a Date object is provided (an Invalid Date object is still
truthy).

server/api/badge/go/[...package].ts[25-35]
server/utils/badge.ts[28-37]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Last-Modified` can be set to an invalid HTTP-date string when `info.Time` cannot be parsed.

### Issue Context
`new Date(info.Time)` can produce an Invalid Date; `toUTCString()` then returns `"Invalid Date"`.

### Fix Focus Areas
- Validate parsed time in the Go fetcher (`const d = new Date(...); if (isNaN(d.getTime())) omit time`).
- Alternatively/additionally, harden `renderBadge()` to only set `last-modified` when `!isNaN(lastModified.getTime())`.

### Fix Focus Areas (code references)
- server/api/badge/go/[...package].ts[25-33]
- server/utils/badge.ts[31-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments
4. Cache purge assumes default cache 🐞 Bug ⛯ Reliability
Description
purgeBadgeCache() checks only for the existence of global caches and then unconditionally accesses
caches.default. In non-Cloudflare runtimes that expose CacheStorage but not the
Cloudflare-specific .default, DELETE requests would throw.
Code

server/utils/badge.ts[R65-68]

+export async function purgeBadgeCache(event: H3Event): Promise<void> {
+  if (typeof caches !== 'undefined') {
+    await caches.default.delete(getRequestURL(event).href);
+  }
Evidence
The code documents that caches.default is a Cloudflare extension, but the runtime guard does not
verify that .default exists before calling .delete(...).

server/utils/badge.ts[4-9]
server/utils/badge.ts[64-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`purgeBadgeCache()` may throw if `caches` exists but `caches.default` does not.

### Issue Context
This is mainly a portability/defensive issue; Cloudflare Workers provides `caches.default`, but other runtimes may not.

### Fix Focus Areas
- Add a runtime guard for `caches.default` (or optional chaining): `await caches?.default?.delete(url)`.

### Fix Focus Areas (code references)
- server/utils/badge.ts[64-68]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider Grey Divider

Qodo Logo

Comment thread server/utils/version-cache.ts Outdated
@amery amery self-assigned this Mar 30, 2026
The client-side SQLite dump from a previous build can become stale,
causing SPA 404s while SSR works fine. Prepending nuxt cleanup to
the dev script removes the old output on every restart.

Signed-off-by: Alejandro Mery <amery@apptly.co>
@amery

amery commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

Branch update (force-push)

Split the monolithic badge commit into two logical commits and
replaced the in-memory version cache with Cloudflare KV:

  • Refactor badge endpoints into BadgeHandler class with HTTP semantics
    class-based handler encapsulating method dispatch, validation, cache headers,
    SVG rendering, and edge cache purge. npm handler uses $fetch.raw for
    Last-Modified. Shared parseDate helper. consola as explicit dependency.

  • Add KV-backed version caching with per-entry TTL
    KV via Nitro useStorage('versions') replaces unbounded in-memory Map.
    Per-entry TTL auto-expires failed lookups (60s) and successful ones (1h).
    In-flight deduplication coalesces concurrent requests. All KV operations
    degrade gracefully.

The earlier commits (badge alignment + dir prop, optimizeDeps fix,
nuxt cleanup) are unchanged.

@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralized badge logic into a BadgeHandler that validates requests, renders SVGs, manages ETag/Last-Modified and split caching, and supports GET/HEAD/DELETE. Added a Cloudflare KV-backed version cache with per-key TTLs and in-flight request deduplication; endpoints and components updated accordingly.

Changes

Cohort / File(s) Summary
Documentation
AGENTS.md, README.md
Updated Badge API docs: explicit GET /api/badge/go/{module} and GET /api/badge/npm/{package}, added DELETE to bust cache/edge, documented split browser vs Cloudflare caching, weak ETag/Last-Modified, unknown-package TTL, KV version-cache behavior, consola logging, and dev workflow (nuxt cleanup).
Components
app/components/badge-version-go.vue, app/components/badge-version.vue
badge-version-go.vue: added optional dir?: string and pkg computed prop; href/alt/title now use pkg. badge-version.vue: added wrapper Tailwind classes and moved image sizing to scoped .badge-version-img.
Server Utilities: Badge
server/utils/badge.ts
Replaced render helpers with BadgeHandler class exposing eventHandler; enforces allowed methods, validates package pattern, handles GET/HEAD/DELETE, computes cache keys, sets split Cache-Control, weak ETag, optional Last-Modified, supports 304, renders unknown badge, added BadgeResult and parseDate.
Server Utilities: Version Cache
server/utils/version-cache.ts
New KV-backed version cache: fetchVersion with per-key TTLs (success vs unknown), module-level in-flight promise dedupe, conditional KV writes, graceful KV error handling, and deleteCachedVersion.
API Endpoints
server/api/badge/go/[...package].ts, server/api/badge/npm/[...package].ts
Endpoints refactored to configure and export badge.eventHandler from BadgeHandler instances; upstream fetch logic moved into handler-provided callbacks; logging via consola instances; fetch results include version and lastModified.
Configuration & Tooling
nuxt.config.ts, wrangler.toml, package.json
Added Nitro storage.versions Cloudflare KV binding with devStorage.versions memory fallback; Vite hook filters problematic optimizeDeps.include entries; wrangler.toml KV namespace VERSIONS_KV added; package.json dev script now runs nuxt cleanup && nuxt dev --dotenv .env.local; added consola dependency.
sequenceDiagram
    participant Client
    participant Endpoint as Badge Endpoint
    participant Handler as BadgeHandler
    participant Cache as Version Cache
    participant KV as Cloudflare KV
    participant Registry as Upstream Registry

    Client->>Endpoint: GET /api/badge/{type}/{pkg}
    Endpoint->>Handler: invoke eventHandler (pattern + fetch callback)
    Handler->>Handler: validate package
    alt invalid
        Handler-->>Client: 400 Bad Request
    else valid
        Handler->>Cache: fetchVersion(cacheKey, fetcher)
        Cache->>KV: get(cacheKey)
        alt cached
            KV-->>Cache: {version, lastModified}
            Cache-->>Handler: {version, lastModified}
        else miss or KV error
            Cache->>Cache: check in-flight map
            alt join in-flight
                Cache-->>Handler: await existing promise
            else fetch upstream
                Cache->>Registry: fetcher(pkg)
                Registry-->>Cache: {version?, time?}
                Cache->>KV: put(cacheKey, {version?, lastModified}, TTL)
                Cache-->>Handler: {version?, lastModified}
            end
        end
        Handler->>Handler: render SVG (or unknown)
        Handler-->>Client: 200 + SVG + Cache-Control, ETag, Last-Modified?
    end

    alt DELETE
        Client->>Endpoint: DELETE /api/badge/...
        Endpoint->>Handler: eventHandler handles DELETE
        Handler->>Cache: deleteCachedVersion(cacheKey)
        Cache->>KV: delete(cacheKey)
        Handler-->>Client: 204 No Content
    else other method
        Client->>Endpoint: POST/PUT ...
        Handler-->>Client: 405 Method Not Allowed (Allow: GET, HEAD, DELETE)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped the code and stitched the trail,
KV keys hum softly, versions set sail,
Fetches join hands to avoid repeat,
Badges beam true with headers neat,
A DELETE nibble clears the cache — tada, back to the vale.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and comprehensively describes the three main changes: badge caching via Cloudflare KV, HTTP semantics implementation (ETag/Last-Modified/Cache-Control), and dev server fixes (nuxt cleanup and optimizeDeps handling).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr-amery-nuxt

Comment @coderabbitai help to get the list of available commands and usage tips.

@amery

amery commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

Branch update

Created KV namespaces (awesome-apptly-versions + preview) and
filled in the real IDs in wrangler.toml — the placeholders were
causing the Cloudflare deploy to fail.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
server/utils/badge.ts (1)

130-138: Consider using this.logger for consistency.

Line 136 uses the module-level logger (tagged badge) instead of this.logger (tagged per-handler, e.g., badge:go). This makes it harder to correlate edge cache purge failures with specific badge endpoints in logs.

♻️ Suggested fix
   private async purgeBadgeCache(event: H3Event): Promise<void> {
     try {
       if (typeof caches !== 'undefined' && caches.default) {
         await caches.default.delete(getRequestURL(event).href);
       }
     } catch (error) {
-      logger.warn('edge cache purge failed:', error);
+      this.logger.warn('edge cache purge failed:', error);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/badge.ts` around lines 130 - 138, The purgeBadgeCache method
uses the module-level logger variable (logger) when catching errors; change it
to use the instance-level this.logger so logs are tagged per-handler (e.g.,
badge:go). In the purgeBadgeCache function, replace usages of logger.warn(...)
with this.logger.warn(...) and keep the same error message and context
(including getRequestURL(event).href and the caught error) so the edge cache
purge failure is correlated to the specific badge handler; ensure the class has
this.logger initialized where other handlers do.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/api/badge/go/`[...package].ts:
- Around line 12-19: The fetch function builds proxyUrl using modulePath which
must have each uppercase letter encoded as "!" + lowercase (e.g., "A" -> "!a")
before calling proxy.golang.org; update the code in async fetch(modulePath,
logger) to transform modulePath by replacing every A–Z with "!" followed by its
lowercase counterpart (preserving other characters) prior to constructing
proxyUrl so requests for modules like github.com/Azure/azure-sdk-for-go do not
404; keep the rest of the logic (the $fetch call, logger.warn, and returning {
version: info?.Version, lastModified: parseDate(info?.Time) }) unchanged.

In `@server/utils/version-cache.ts`:
- Around line 70-86: The IIFE assigned to pending currently lets exceptions from
fetcher() bubble up and reject the shared promise; wrap the fetcher() call in a
try/catch inside that IIFE so any thrown error is caught, log the error (use
logger.warn with the key), treat the fetch as a failure by setting
result/version to undefined, set ttl to TTL_ERROR and firstSeen to now (or use
result?.time fallback), attempt the same storage.setItem write with the failure
values, and ensure the IIFE always resolves (never throws) so concurrent callers
awaiting pending get a graceful degraded response instead of a rejection.

---

Nitpick comments:
In `@server/utils/badge.ts`:
- Around line 130-138: The purgeBadgeCache method uses the module-level logger
variable (logger) when catching errors; change it to use the instance-level
this.logger so logs are tagged per-handler (e.g., badge:go). In the
purgeBadgeCache function, replace usages of logger.warn(...) with
this.logger.warn(...) and keep the same error message and context (including
getRequestURL(event).href and the caught error) so the edge cache purge failure
is correlated to the specific badge handler; ensure the class has this.logger
initialized where other handlers do.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8224b145-7352-4ba3-af69-7ea790e0e76e

📥 Commits

Reviewing files that changed from the base of the PR and between 1c4adf9 and 7c78d29.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • app/components/badge-version-go.vue
  • app/components/badge-version.vue
  • nuxt.config.ts
  • package.json
  • server/api/badge/go/[...package].ts
  • server/api/badge/npm/[...package].ts
  • server/utils/badge.ts
  • server/utils/version-cache.ts
  • wrangler.toml

Comment thread server/api/badge/go/[...package].ts
Comment thread server/utils/version-cache.ts
@amery

amery commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

Branch update (force-push)

Three bug fixes squashed into their respective commits
(from CodeRabbit review):

  • Go proxy uppercase escaping — module paths with uppercase
    letters (e.g. github.com/Azure/...) are now encoded per the
    Go proxy convention (A!a) before fetching from
    proxy.golang.org.

  • Unhandled fetcher exceptions — the in-flight deduplication
    IIFE in version-cache.ts now catches fetcher() errors so
    concurrent callers get a graceful "unknown" badge instead of
    a rejected promise.

  • purgeBadgeCache logger — uses the per-handler
    this.logger (e.g. badge:go) instead of the module-level
    logger, and removes the now-unused module-level instance.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
server/api/badge/go/[...package].ts (1)

6-8: Consider using replaceAll() for clarity.

While replace() with a global regex (/g) works correctly, replaceAll() is more explicit about the intent to replace all occurrences.

♻️ Suggested change
 // Go proxy requires uppercase letters encoded as !lowercase
 const escapeModulePath = (path: string) =>
-  path.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`);
+  path.replaceAll(/[A-Z]/g, (c) => `!${c.toLowerCase()}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/badge/go/`[...package].ts around lines 6 - 8, The helper
escapeModulePath currently uses path.replace(/[A-Z]/g, ...) to transform
uppercase letters; update it to call replaceAll with the same regex and replacer
so the intent to replace all matches is explicit (i.e., in escapeModulePath use
path.replaceAll(/[A-Z]/g, c => `!${c.toLowerCase()}`) while keeping the same
replacement behavior).
server/utils/badge.ts (1)

104-126: The practical risk is low, but consider hashing the version for defensive ETag handling.

npm and Go registries enforce semantic versioning formats that don't allow quote characters, making this scenario extremely unlikely. However, if you want defensive programming around header generation, hashing the version is safer than removing quotes, since it guarantees a valid ETag regardless of version content.

♻️ Safer ETag generation with hashing
+import { createHash } from 'crypto';
+
  private renderBadge(event: H3Event, { version, lastModified }: BadgeResult): string {
    if (version) {
      setHeader(event, 'cache-control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_S_MAX_AGE}`);
-      setHeader(event, 'etag', `W/"${version}"`);
+      const versionHash = createHash('sha256').update(version).digest('hex').slice(0, 16);
+      setHeader(event, 'etag', `W/"${versionHash}"`);

This avoids potential edge cases and follows HTTP caching best practices.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/badge.ts` around lines 104 - 126, The ETag currently uses the
raw version string in renderBadge (private renderBadge(event: H3Event, {
version, lastModified }: BadgeResult)), which could contain unexpected
characters; instead compute a stable hash (e.g., SHA-256 or other digest) of
version and use that hashed value in the setHeader call for 'etag' (wrap as
W/"<hash>") so the header always contains a safe token; leave last-modified and
cache-control logic unchanged and use the same hashed value only when version is
present, otherwise keep the existing error cache header path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/api/badge/go/`[...package].ts:
- Around line 6-8: The helper escapeModulePath currently uses
path.replace(/[A-Z]/g, ...) to transform uppercase letters; update it to call
replaceAll with the same regex and replacer so the intent to replace all matches
is explicit (i.e., in escapeModulePath use path.replaceAll(/[A-Z]/g, c =>
`!${c.toLowerCase()}`) while keeping the same replacement behavior).

In `@server/utils/badge.ts`:
- Around line 104-126: The ETag currently uses the raw version string in
renderBadge (private renderBadge(event: H3Event, { version, lastModified }:
BadgeResult)), which could contain unexpected characters; instead compute a
stable hash (e.g., SHA-256 or other digest) of version and use that hashed value
in the setHeader call for 'etag' (wrap as W/"<hash>") so the header always
contains a safe token; leave last-modified and cache-control logic unchanged and
use the same hashed value only when version is present, otherwise keep the
existing error cache header path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b67d4bb5-f812-4dc6-8b70-51c1d59a4fd3

📥 Commits

Reviewing files that changed from the base of the PR and between a225c48 and b67d3b2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • AGENTS.md
  • README.md
  • nuxt.config.ts
  • package.json
  • server/api/badge/go/[...package].ts
  • server/api/badge/npm/[...package].ts
  • server/utils/badge.ts
  • server/utils/version-cache.ts
  • wrangler.toml
✅ Files skipped from review due to trivial changes (1)
  • wrangler.toml
🚧 Files skipped from review as they are similar to previous changes (3)
  • README.md
  • package.json
  • server/api/badge/npm/[...package].ts

@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@AGENTS.md`:
- Around line 91-95: Update the AGENTS.md text describing response headers so
the Last-Modified behaviour matches implementation: change the sentences that
say Last-Modified is sent only when the upstream provides a timestamp to state
that handlers will use the upstream timestamp if present, otherwise they fall
back to the locally tracked firstSeen time; apply the same clarification to the
other paragraph that documents Last-Modified (the second occurrence of this
description) so both sections accurately reflect the cached-response contract.

In `@server/utils/badge.ts`:
- Around line 104-117: The renderBadge method sets validator headers but never
invokes h3's conditional-check helper; import handleCacheHeaders and call it
(await handleCacheHeaders(event, { maxAge: CACHE_MAX_AGE })) inside renderBadge
after you set the ETag/Last-Modified/cache-control headers (and before returning
the badge) so h3 can send 304 responses; ensure the import for
handleCacheHeaders is added at the top of the file and that the call uses the
appropriate maxAge when version exists and the error max-age constant
(CACHE_MAX_AGE_ERROR) when not.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: af372fe6-28d1-4811-bc60-8459e5a6e300

📥 Commits

Reviewing files that changed from the base of the PR and between 1c4adf9 and aced1d3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • app/components/badge-version-go.vue
  • app/components/badge-version.vue
  • nuxt.config.ts
  • package.json
  • server/api/badge/go/[...package].ts
  • server/api/badge/npm/[...package].ts
  • server/utils/badge.ts
  • server/utils/version-cache.ts
  • wrangler.toml

Comment thread AGENTS.md Outdated
Comment thread server/utils/badge.ts Outdated
Comment thread server/utils/version-cache.ts
amery added 2 commits April 1, 2026 21:04
Replace loose utility functions (renderBadge, renderBadgeError,
assertBadgeMethod) with a BadgeHandler class that encapsulates
method dispatch, input validation, cache headers, SVG rendering,
and edge cache purge. Each endpoint now provides only its identity
and fetch callback.

HTTP improvements: split Cache-Control (max-age=3600 for browsers,
s-maxage=300 for the CF edge), weak ETag from the version string,
Last-Modified when the upstream provides a timestamp, DELETE for
edge cache purging (current PoP), and 405 for unsupported methods.

The npm handler switches to $fetch.raw to extract Last-Modified
from registry response headers. Both handlers use a shared
parseDate helper to guard against invalid upstream timestamps.
Logging moves from console.warn to consola tagged instances.

Signed-off-by: Alejandro Mery <amery@apptly.co>
Cache upstream version lookups in Cloudflare KV (VERSIONS_KV)
via Nitro's useStorage('versions'), with per-entry TTL: 1 hour
for successful lookups, 60 seconds for errors. Failed entries
auto-expire, preventing unbounded growth from bogus package
names. Local dev uses a memory driver.

The BadgeHandler derives cache keys from idSuffix (go:, npm:)
and wraps each endpoint's fetch callback with fetchVersion for
transparent KV caching. DELETE calls deleteCachedVersion before
the edge cache purge, which is now fault-tolerant (try-catch).

Concurrent requests at the TTL boundary are coalesced via an
in-flight promise map — only the originating caller writes to
KV. All KV operations degrade gracefully: failures are logged
but the badge is still returned.

Signed-off-by: Alejandro Mery <amery@apptly.co>
@amery

amery commented Apr 2, 2026

Copy link
Copy Markdown
Contributor Author

Branch update (force-push)

Changes since last push (aced1d3b3f8941, bcf646f0036e8e):

server/utils/badge.ts (BadgeHandler commit)

  • Extract setCacheHeaders from renderBadge — sets cache/validator headers and checks If-None-Match for 304 responses (h3 doesn't do this automatically)

server/utils/version-cache.ts (KV commit)

  • Rename firstSeenlastModified throughout (interfaces, variables, return type) — more accurate for what the field represents
  • Rename FetchResult.timeFetchResult.lastModified for consistency
  • Make StoredVersion.version optional instead of string | undefined — avoids storing null in JSON (undefined doesn't survive round-trip)
  • Fix in-flight .finally() to check identity before deleting — prevents IIFE-A from evicting IIFE-B's entry when DELETE replaces the inflight slot
  • DELETE evicts the inflight entry so an orphaned IIFE skips its KV write instead of writing stale data back

AGENTS.md

  • Document 304 conditional request support
  • Remove overstated per-registry Last-Modified details from the Caching section
  • Fix inaccurate "cached lastModified fallback" claim (code falls back to current time, not a cached value)

/review
@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown

@amery Thanks for the detailed branch update! Here's a quick summary of what I'll be reviewing:

  • server/utils/badge.ts: The new setCacheHeaders extraction and If-None-Match / 304 handling (since h3 doesn't do this automatically) — will pay close attention to the conditional response logic.
  • server/utils/version-cache.ts: The firstSeenlastModified rename, FetchResult.timeFetchResult.lastModified for consistency, StoredVersion.version made optional to survive JSON round-trips, and the .finally() identity check fix to prevent IIFE-A from evicting IIFE-B's inflight entry — the DELETE-evicts-inflight interaction is a subtle correctness fix I'll look at carefully.
  • AGENTS.md: Documentation corrections for 304 support and the fallback-to-current-time behaviour.

Triggering a full review now.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/utils/version-cache.ts (1)

82-83: Consider storing version even when it's an empty string.

The condition if (version) entry.version = version treats empty string as falsy, so an empty version would not be stored. While empty strings are unlikely valid versions, if the upstream ever returned one, the cache would treat it as a failed lookup (no version stored, 60s TTL) rather than a successful one.

This is likely fine in practice since npm and Go registries don't return empty version strings, but worth being aware of.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/version-cache.ts` around lines 82 - 83, The code currently skips
setting entry.version when version is an empty string because it uses a truthy
check; update the logic around StoredVersion creation so entry.version is set
when version is present but possibly empty (e.g., check for version !==
undefined/null or assign unconditionally) — modify the block that builds entry
(the variable entry and the version assignment) to store empty-string versions
as valid cache values so an empty upstream version counts as a successful
lookup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/utils/version-cache.ts`:
- Around line 82-83: The code currently skips setting entry.version when version
is an empty string because it uses a truthy check; update the logic around
StoredVersion creation so entry.version is set when version is present but
possibly empty (e.g., check for version !== undefined/null or assign
unconditionally) — modify the block that builds entry (the variable entry and
the version assignment) to store empty-string versions as valid cache values so
an empty upstream version counts as a successful lookup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f56ced0d-9345-4b0c-abc9-8ce8096fcea3

📥 Commits

Reviewing files that changed from the base of the PR and between aced1d3 and b3f8941.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • AGENTS.md
  • README.md
  • nuxt.config.ts
  • package.json
  • server/api/badge/go/[...package].ts
  • server/api/badge/npm/[...package].ts
  • server/utils/badge.ts
  • server/utils/version-cache.ts
  • wrangler.toml
✅ Files skipped from review due to trivial changes (1)
  • wrangler.toml
🚧 Files skipped from review as they are similar to previous changes (3)
  • package.json
  • nuxt.config.ts
  • server/api/badge/npm/[...package].ts

@qodo-code-review

qodo-code-review Bot commented Apr 2, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit b3f8941

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
AGENTS.md (1)

91-95: ⚠️ Potential issue | 🟡 Minor

Document the Last-Modified fallback accurately.

server/utils/version-cache.ts, Lines 78-88, stores result?.lastModified ?? new Date(), so known-version badges can still emit Last-Modified when the upstream omits one. These docs still describe the header as upstream-only.

📝 Suggested wording
-Successful responses carry split `Cache-Control` (`max-age=3600`
-for browsers, `s-maxage=300` for the CF edge), a weak `ETag`
-from the version string (conditional requests return `304`),
-and `Last-Modified` from the upstream timestamp. Unknown packages render a
+Successful responses carry split `Cache-Control` (`max-age=3600`
+for browsers, `s-maxage=300` for the CF edge), a weak `ETag`
+from the version string (conditional requests return `304`),
+and `Last-Modified` from the upstream timestamp when available,
+falling back to the cached fetch time otherwise. Unknown packages render a
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` around lines 91 - 95, Docs claim Last-Modified comes only from
upstream, but the code in version-cache (the result?.lastModified ?? new Date()
assignment in server/utils/version-cache.ts) falls back to the current date when
upstream omits it, so update the documentation to state that known-version
badges will emit a Last-Modified header using upstream's timestamp when present
and otherwise a fallback (current time) stored by the version-cache. Mention the
fallback behavior and its practical effect on conditional requests and caching
TTLs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/badge-version-go.vue`:
- Around line 9-11: The tooltip/title currently always calls `${pkg.value}` a
"module" even when this component is rendering a subpackage (when `props.dir` is
set); change the computed properties (`title` and `alt`, and adjust `href` if
needed) to conditionally reflect a package vs module: detect `props.dir` (or
`dir`) and use "Go package <pkg.value>" when dir is present, otherwise "Go
module <pkg.value>"; keep the badge source on `props.mod` as-is. Update the
`title` and `alt` computed functions to switch their text based on `props.dir`
so the tooltip is accurate for subpackages.

In `@server/utils/badge.ts`:
- Around line 111-120: The current conditional revalidation (returning
ifNoneMatch === etag) is non-compliant: update the logic that reads
getRequestHeader(event, 'if-none-match') and getRequestHeader(event,
'if-modified-since') to (1) handle the If-None-Match wildcard "*" which must
match any current representation, (2) parse comma-separated/quoted token lists
and perform a weak comparison against the response etag by normalizing both
sides (strip optional W/ prefix and surrounding quotes) when checking
membership, and (3) if If-None-Match is absent, fall back to If-Modified-Since
by parsing the header date and comparing it to lastModified (treat unparseable
dates as no-match; treat <= lastModified as not-modified). Keep setting of
'etag' and 'last-modified' headers unchanged and return true only when the
RFC-compliant matching rules determine the representation is not modified.

In `@server/utils/version-cache.ts`:
- Around line 55-60: The read path in storage.getItem for StoredVersion assumes
stored.lastModified exists; detect legacy entries written under the old
firstSeen schema and treat them as cache misses by returning null instead of
returning an object with lastModified undefined. Concretely, in the code that
handles the result of storage.getItem<StoredVersion>(key) (the stored variable),
if stored.firstSeen is present and stored.lastModified is missing (or falsy),
return null (a cache miss) so the caller (e.g., badge generation code) won’t
emit an invalid Last-Modified header; otherwise continue returning { version:
stored.version, lastModified: new Date(stored.lastModified) } as before. Ensure
you reference StoredVersion, stored.firstSeen, and stored.lastModified when
implementing the check.

---

Duplicate comments:
In `@AGENTS.md`:
- Around line 91-95: Docs claim Last-Modified comes only from upstream, but the
code in version-cache (the result?.lastModified ?? new Date() assignment in
server/utils/version-cache.ts) falls back to the current date when upstream
omits it, so update the documentation to state that known-version badges will
emit a Last-Modified header using upstream's timestamp when present and
otherwise a fallback (current time) stored by the version-cache. Mention the
fallback behavior and its practical effect on conditional requests and caching
TTLs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f139fb1-3ab1-45b0-90e1-7899ea41c736

📥 Commits

Reviewing files that changed from the base of the PR and between 1c4adf9 and b3f8941.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • app/components/badge-version-go.vue
  • app/components/badge-version.vue
  • nuxt.config.ts
  • package.json
  • server/api/badge/go/[...package].ts
  • server/api/badge/npm/[...package].ts
  • server/utils/badge.ts
  • server/utils/version-cache.ts
  • wrangler.toml

Comment on lines +9 to +11
const href = computed(() => `https://pkg.go.dev/${pkg.value}`);
const alt = computed(() => `Go version for ${pkg.value}`);
const title = computed(() => `Go module ${pkg.value}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify the tooltip for subpackages.

Keeping the badge source on props.mod makes sense for shared-go.mod subpackages, but Line 11 still labels ${pkg.value} as a module. When dir is set, that string is a package path, so the tooltip becomes misleading.

Suggested tweak
-const title = computed(() => `Go module ${pkg.value}`);
+const title = computed(() =>
+  props.dir ? `Go package ${pkg.value}` : `Go module ${pkg.value}`,
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/badge-version-go.vue` around lines 9 - 11, The tooltip/title
currently always calls `${pkg.value}` a "module" even when this component is
rendering a subpackage (when `props.dir` is set); change the computed properties
(`title` and `alt`, and adjust `href` if needed) to conditionally reflect a
package vs module: detect `props.dir` (or `dir`) and use "Go package
<pkg.value>" when dir is present, otherwise "Go module <pkg.value>"; keep the
badge source on `props.mod` as-is. Update the `title` and `alt` computed
functions to switch their text based on `props.dir` so the tooltip is accurate
for subpackages.

Comment thread server/utils/badge.ts
Comment on lines +111 to +120
const etag = `W/"${version}"`;
setHeader(event, 'cache-control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_S_MAX_AGE}`);
setHeader(event, 'etag', etag);
if (lastModified) {
setHeader(event, 'last-modified', lastModified.toUTCString());
}

// h3 doesn't check conditional headers automatically
const ifNoneMatch = getRequestHeader(event, 'if-none-match');
return ifNoneMatch === etag;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "badge.ts" --path "*/server/*" 2>/dev/null | head -20

Repository: apptly-oss/awesome-apptly

Length of output: 51


🏁 Script executed:

cat -n server/utils/badge.ts | sed -n '100,130p'

Repository: apptly-oss/awesome-apptly

Length of output: 1374


🏁 Script executed:

cat -n server/utils/badge.ts | head -50

Repository: apptly-oss/awesome-apptly

Length of output: 1898


🏁 Script executed:

cat -n server/utils/badge.ts | sed -n '1,150p'

Repository: apptly-oss/awesome-apptly

Length of output: 5842


🏁 Script executed:

# Check the imports and h3 library usage
rg "getRequestHeader|setHeader|setResponseStatus" server/utils/badge.ts -A 2

Repository: apptly-oss/awesome-apptly

Length of output: 1040


🏁 Script executed:

# Verify the RFC 7232 conditional request semantics
cat <<'EOF' | python3
# Verify the HTTP/1.1 conditional request rules from RFC 7232

# If-None-Match semantics:
# A recipient MUST use the weak comparison function when comparing 
# entity-tags for If-None-Match (Section 2.3.2)
# 
# The special value "*" matches any representation

# Current code issue examples:
current_code = 'return ifNoneMatch === etag;'
print("Current code: " + current_code)
print()
print("Issues with current code:")
print("1. Only exact string match - doesn't handle multi-value headers")
print("   Example: If-None-Match: \"tag1\", \"tag2\"")
print("   The code would check: '\"tag1\", \"tag2\"' === 'W/\"version\"' -> false")
print()
print("2. Doesn't use weak comparison - ignores W/ prefix")
print("   Example: If-None-Match: \"tag\" (no W/ prefix)")
print("   The code would check: '\"tag\"' === 'W/\"version\"' -> false")
print("   But weak comparison should match if content is the same")
print()
print("3. Doesn't handle special value '*'")
print("   Example: If-None-Match: * (means 'any representation')")
print("   The code would check: '*' === 'W/\"version\"' -> false")
print()
print("4. Ignores If-Modified-Since header completely")
print("   This is a fallback when If-None-Match is not present")
print("   Current code: always returns false if If-None-Match doesn't match exactly")
EOF

Repository: apptly-oss/awesome-apptly

Length of output: 864


Conditional revalidation is incomplete and non-compliant with RFC 7232.

Line 120 uses raw string equality (ifNoneMatch === etag), which fails for:

  • Weak etag variants: If-None-Match: "tag" won't match the W/"tag" response etag (weak comparison required)
  • Multi-value headers: If-None-Match: "tag1", "tag2" won't parse
  • Wildcard matching: If-None-Match: * won't match any representation
  • If-Modified-Since header is never checked, even though lastModified is available and set as a response header

Per RFC 7232, weak comparison (ignoring W/ prefix) is mandatory for If-None-Match, and If-Modified-Since should be a fallback when If-None-Match is absent.

🔧 One way to complete the validator handling
-    const ifNoneMatch = getRequestHeader(event, 'if-none-match');
-    return ifNoneMatch === etag;
+    const ifNoneMatch = getRequestHeader(event, 'if-none-match');
+    if (ifNoneMatch) {
+      const normalized = etag.replace(/^W\//, '');
+      return ifNoneMatch.split(',').some((candidate) => {
+        const tag = candidate.trim();
+        return tag === '*' || tag.replace(/^W\//, '') === normalized;
+      });
+    }
+
+    if (!lastModified) return false;
+
+    const ifModifiedSince = getRequestHeader(event, 'if-modified-since');
+    if (!ifModifiedSince) return false;
+
+    const modifiedSince = new Date(ifModifiedSince);
+    if (Number.isNaN(modifiedSince.getTime())) return false;
+
+    return Math.floor(lastModified.getTime() / 1000)
+      <= Math.floor(modifiedSince.getTime() / 1000);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const etag = `W/"${version}"`;
setHeader(event, 'cache-control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_S_MAX_AGE}`);
setHeader(event, 'etag', etag);
if (lastModified) {
setHeader(event, 'last-modified', lastModified.toUTCString());
}
// h3 doesn't check conditional headers automatically
const ifNoneMatch = getRequestHeader(event, 'if-none-match');
return ifNoneMatch === etag;
const etag = `W/"${version}"`;
setHeader(event, 'cache-control', `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_S_MAX_AGE}`);
setHeader(event, 'etag', etag);
if (lastModified) {
setHeader(event, 'last-modified', lastModified.toUTCString());
}
// h3 doesn't check conditional headers automatically
const ifNoneMatch = getRequestHeader(event, 'if-none-match');
if (ifNoneMatch) {
const normalized = etag.replace(/^W\//, '');
return ifNoneMatch.split(',').some((candidate) => {
const tag = candidate.trim();
return tag === '*' || tag.replace(/^W\//, '') === normalized;
});
}
if (!lastModified) return false;
const ifModifiedSince = getRequestHeader(event, 'if-modified-since');
if (!ifModifiedSince) return false;
const modifiedSince = new Date(ifModifiedSince);
if (Number.isNaN(modifiedSince.getTime())) return false;
return Math.floor(lastModified.getTime() / 1000)
<= Math.floor(modifiedSince.getTime() / 1000);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/badge.ts` around lines 111 - 120, The current conditional
revalidation (returning ifNoneMatch === etag) is non-compliant: update the logic
that reads getRequestHeader(event, 'if-none-match') and getRequestHeader(event,
'if-modified-since') to (1) handle the If-None-Match wildcard "*" which must
match any current representation, (2) parse comma-separated/quoted token lists
and perform a weak comparison against the response etag by normalizing both
sides (strip optional W/ prefix and surrounding quotes) when checking
membership, and (3) if If-None-Match is absent, fall back to If-Modified-Since
by parsing the header date and comparing it to lastModified (treat unparseable
dates as no-match; treat <= lastModified as not-modified). Keep setting of
'etag' and 'last-modified' headers unchanged and return true only when the
RFC-compliant matching rules determine the representation is not modified.

Comment on lines +55 to +60
const stored = await storage.getItem<StoredVersion>(key);
if (stored) {
return {
version: stored.version,
lastModified: new Date(stored.lastModified),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat legacy KV timestamps as cache misses.

This read path assumes every record already has the renamed lastModified field. Entries written under the previous firstSeen schema will come back with stored.lastModified === undefined, and server/utils/badge.ts, Lines 114-115, will then emit an invalid Last-Modified value until those entries expire.

🛡️ Backward-compatible read path
-    const stored = await storage.getItem<StoredVersion>(key);
+    const stored = await storage.getItem<StoredVersion & { firstSeen?: string }>(key);
     if (stored) {
+      const rawLastModified = stored.lastModified ?? stored.firstSeen;
+      const lastModified = rawLastModified ? new Date(rawLastModified) : undefined;
+      if (!lastModified || Number.isNaN(lastModified.getTime())) {
+        logger.warn(`invalid cached timestamp for ${key}, refetching`);
+      } else {
         return {
           version: stored.version,
-          lastModified: new Date(stored.lastModified),
+          lastModified,
         };
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const stored = await storage.getItem<StoredVersion>(key);
if (stored) {
return {
version: stored.version,
lastModified: new Date(stored.lastModified),
};
const stored = await storage.getItem<StoredVersion & { firstSeen?: string }>(key);
if (stored) {
const rawLastModified = stored.lastModified ?? stored.firstSeen;
const lastModified = rawLastModified ? new Date(rawLastModified) : undefined;
if (!lastModified || Number.isNaN(lastModified.getTime())) {
logger.warn(`invalid cached timestamp for ${key}, refetching`);
} else {
return {
version: stored.version,
lastModified,
};
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/version-cache.ts` around lines 55 - 60, The read path in
storage.getItem for StoredVersion assumes stored.lastModified exists; detect
legacy entries written under the old firstSeen schema and treat them as cache
misses by returning null instead of returning an object with lastModified
undefined. Concretely, in the code that handles the result of
storage.getItem<StoredVersion>(key) (the stored variable), if stored.firstSeen
is present and stored.lastModified is missing (or falsy), return null (a cache
miss) so the caller (e.g., badge generation code) won’t emit an invalid
Last-Modified header; otherwise continue returning { version: stored.version,
lastModified: new Date(stored.lastModified) } as before. Ensure you reference
StoredVersion, stored.firstSeen, and stored.lastModified when implementing the
check.

Comment thread server/utils/badge.ts
Comment on lines +62 to +70
switch (event.method) {
case 'GET':
case 'HEAD':
case 'DELETE':
break;
default:
setResponseStatus(event, 405);
setHeader(event, 'allow', 'GET, HEAD, DELETE');
return '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. badgehandler allows head 📘 Rule violation ≡ Correctness

The badge handler explicitly allows HEAD requests and includes HEAD in the Allow header, but
the compliance checklist requires only GET and DELETE to be accepted (all others must return
405). This can cause non-compliant method behaviour across all badge endpoints using
BadgeHandler.
Agent Prompt
## Issue description
`BadgeHandler` currently treats `HEAD` as an allowed method and returns `Allow: GET, HEAD, DELETE`, but the compliance requirement states only `GET` and `DELETE` may be accepted; all other methods must return `405` with an `Allow` header.

## Issue Context
This affects all badge endpoints that instantiate `BadgeHandler` (e.g. go/npm), since the method guard is centralised in `server/utils/badge.ts`.

## Fix Focus Areas
- server/utils/badge.ts[62-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you say HEAD isn't handled?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not saying it’s unhandled — it is handled/allowed right now (case 'HEAD' and Allow: GET, HEAD, DELETE). The point is the compliance checklist you referenced requires rejecting anything except GET/DELETE with a 405, so HEAD should fall into the default 405 path and be removed from the Allow header.

@amery amery merged commit a409692 into main Apr 2, 2026
15 checks passed
@amery amery deleted the pr-amery-nuxt branch April 2, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant