Skip to content

Commit b3f8941

Browse files
committed
Add KV-backed version caching with per-entry TTL
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>
1 parent 0036e8e commit b3f8941

6 files changed

Lines changed: 157 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pre-computed logo data URIs (extracted from `simple-icons` at
7272
development time, inlined as base64 constants to avoid bundling the
7373
full icon library into the Nitro server). The `BadgeHandler` class
7474
in `server/utils/badge.ts` handles method dispatch, input
75-
validation, cache headers, SVG rendering, and edge cache purge
75+
validation, cache headers, SVG rendering, and cache management
7676
each endpoint provides only its identity and fetch callback.
7777

7878
### Endpoints
@@ -83,20 +83,37 @@ each endpoint provides only its identity and fetch callback.
8383
- `GET /api/badge/npm/{package}` — fetches version from
8484
`registry.npmjs.org/{package}/latest`. Validates the name
8585
matches npm naming rules (`@scope/name` or `name`).
86-
- `DELETE` on either endpoint purges the CF edge cache
87-
(current PoP), so a freshly published version is picked
88-
up on the next GET.
86+
- `DELETE` on either endpoint busts the version cache
87+
and purges the CF edge cache (current PoP), so a freshly
88+
published version is picked up on the next GET.
8989
- Other methods return `405` with an `Allow` header.
9090

9191
Successful responses carry split `Cache-Control` (`max-age=3600`
9292
for browsers, `s-maxage=300` for the CF edge), a weak `ETag`
9393
from the version string (conditional requests return `304`),
94-
and `Last-Modified` when the upstream provides a timestamp.
95-
Unknown packages render a grey "unknown" badge with a 60-second
96-
TTL.
94+
and `Last-Modified` from the upstream timestamp. Unknown packages render a
95+
grey "unknown" badge with a 60-second TTL.
96+
97+
### Caching
98+
99+
Version lookups are cached in Cloudflare KV via Nitro's
100+
`useStorage('versions')` (`server/utils/version-cache.ts`).
101+
Each entry has a per-key TTL: 1 hour for successful lookups,
102+
60 seconds for errors — failed entries auto-expire, preventing
103+
unbounded growth from bogus package names. The KV namespace
104+
binding (`VERSIONS_KV`) is configured in `wrangler.toml` and
105+
mounted in `nuxt.config.ts`; local dev uses a memory driver.
106+
107+
Concurrent requests for the same key at the TTL boundary are
108+
coalesced via an in-flight promise map — only one upstream
109+
fetch runs, and only the originating caller writes to KV.
110+
111+
KV operations degrade gracefully: read failures fall through
112+
to an upstream fetch, write failures are logged but the badge
113+
is still returned.
97114

98115
Logging uses `consola` with tagged instances (`badge:go`,
99-
`badge:npm`).
116+
`badge:npm`, `version-cache`).
100117

101118
### Components
102119

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ open-source projects, live at <https://awesome-apptly.com>.
1111
| Framework | [Nuxt 4](https://nuxt.com) + [Nuxt Content 3](https://content.nuxt.com) |
1212
| Hosting | [Cloudflare Workers](https://developers.cloudflare.com/workers/) (`cloudflare-module` preset) |
1313
| Database | [Cloudflare D1](https://developers.cloudflare.com/d1/) (content database) |
14+
| Cache | [Cloudflare KV](https://developers.cloudflare.com/kv/) (version cache) |
1415
| Styling | [Tailwind CSS 4](https://tailwindcss.com) + `@tailwindcss/typography` |
1516
| Icons | [`@nuxt/icon`](https://nuxt.com/modules/icon) + `@iconify-json/simple-icons` |
1617
| Badges | [`badge-maker`](https://github.com/badges/shields/tree/master/badge-maker) (self-hosted SVG badges, logos inlined as base64) |

nuxt.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export default defineNuxtConfig({
2525
routes: ['/'],
2626
crawlLinks: true,
2727
},
28+
storage: {
29+
versions: {
30+
driver: 'cloudflare-kv-binding',
31+
binding: 'VERSIONS_KV',
32+
},
33+
},
34+
devStorage: {
35+
versions: {
36+
driver: 'memory',
37+
},
38+
},
2839
},
2940
vite: {
3041
plugins: [tailwindcss()],

server/utils/badge.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,22 @@ export class BadgeHandler {
7777
return this.renderBadge(event, { version: undefined });
7878
}
7979

80+
const cacheKey = `${this.idSuffix}:${pkg}`;
81+
8082
if (event.method === 'DELETE') {
83+
await deleteCachedVersion(cacheKey);
8184
await this.purgeBadgeCache(event);
8285
setResponseStatus(event, 204);
8386
return '';
8487
}
8588

86-
const result = await this.fetchUpstream(pkg, this.logger);
87-
return this.renderBadge(event, result);
89+
const { version, lastModified } = await fetchVersion(cacheKey, async () => {
90+
const result = await this.fetchUpstream(pkg, this.logger);
91+
if (!result.version) return undefined;
92+
return { version: result.version, lastModified: result.lastModified };
93+
});
94+
95+
return this.renderBadge(event, { version, lastModified });
8896
}
8997

9098
private validatePackage(event: H3Event): string | undefined {

server/utils/version-cache.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { consola } from 'consola';
2+
3+
const logger = consola.withTag('version-cache');
4+
5+
export interface FetchResult {
6+
version: string
7+
lastModified?: Date
8+
}
9+
10+
interface StoredVersion {
11+
version?: string // omitted for failed lookups — avoids null in JSON
12+
lastModified: string // ISO 8601 — Date doesn't survive JSON round-trip
13+
}
14+
15+
interface InflightResult {
16+
result: FetchResult | undefined
17+
lastModified: Date
18+
ttl: number
19+
}
20+
21+
const TTL_OK = 3600; // 1 hour
22+
const TTL_ERROR = 60; // 1 minute
23+
24+
// In-flight deduplication — only holds promises for active upstream
25+
// fetches, so naturally bounded by concurrency, not by key space.
26+
// The originating caller writes to KV; joiners just read the result.
27+
const inflight = new Map<string, Promise<InflightResult>>();
28+
29+
/** Remove a cached version entry, forcing a fresh upstream poll. */
30+
export async function deleteCachedVersion(key: string): Promise<void> {
31+
inflight.delete(key); // any running IIFE will see it lost its slot and skip the KV write
32+
try {
33+
await useStorage('versions').removeItem(key);
34+
} catch (error) {
35+
logger.warn(`failed to delete ${key}:`, error);
36+
}
37+
}
38+
39+
/**
40+
* Returns the last known version, polling upstream only when
41+
* the KV entry has expired. Uses the upstream publish time
42+
* when available, otherwise falls back to the current time.
43+
*
44+
* Degrades gracefully — KV failures fall through to an
45+
* upstream fetch rather than crashing the handler.
46+
*/
47+
export async function fetchVersion(
48+
key: string,
49+
fetcher: () => Promise<FetchResult | undefined>,
50+
): Promise<{ version: string | undefined; lastModified: Date }> {
51+
const storage = useStorage('versions');
52+
53+
// KV cache hit — return stored data directly
54+
try {
55+
const stored = await storage.getItem<StoredVersion>(key);
56+
if (stored) {
57+
return {
58+
version: stored.version,
59+
lastModified: new Date(stored.lastModified),
60+
};
61+
}
62+
} catch (error) {
63+
logger.warn(`KV read failed for ${key}:`, error);
64+
}
65+
66+
// KV miss or expired — poll upstream with in-flight dedup.
67+
// The first caller fetches + writes KV; concurrent joiners
68+
// just await the same result without duplicate writes.
69+
let pending = inflight.get(key);
70+
if (!pending) {
71+
pending = (async () => {
72+
let result: FetchResult | undefined;
73+
try {
74+
result = await fetcher();
75+
} catch (error) {
76+
logger.warn(`upstream fetch failed for ${key}:`, error);
77+
}
78+
const version = result?.version;
79+
const lastModified = result?.lastModified ?? new Date();
80+
const ttl = version === undefined ? TTL_ERROR : TTL_OK;
81+
82+
const entry: StoredVersion = { lastModified: lastModified.toISOString() };
83+
if (version) entry.version = version;
84+
85+
// Skip the write if a DELETE evicted us from inflight mid-fetch
86+
if (inflight.get(key) === pending) {
87+
try {
88+
await storage.setItem(key, entry, { ttl });
89+
} catch (error) {
90+
logger.warn(`KV write failed for ${key}:`, error);
91+
}
92+
}
93+
94+
return { result, lastModified, ttl };
95+
})();
96+
inflight.set(key, pending);
97+
pending.finally(() => {
98+
if (inflight.get(key) === pending) inflight.delete(key);
99+
});
100+
}
101+
102+
const { result, lastModified } = await pending;
103+
return { version: result?.version, lastModified };
104+
}

wrangler.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ ip = "0.0.0.0"
1313
directory = ".output/public"
1414
binding = "ASSETS"
1515

16+
# pnpm wrangler kv namespace create awesome-apptly-versions
17+
[[kv_namespaces]]
18+
binding = "VERSIONS_KV"
19+
id = "37a05e89d7a24bc69a3c5e8b5a549ee3"
20+
preview_id = "75c57f6d78ab483eb6b98b961214d1fa"
21+
1622
# wrangler d1 create awesome-apptly-content
1723
[[d1_databases]]
1824
binding = "DB"

0 commit comments

Comments
 (0)