Skip to content

Commit b67d3b2

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 9d0d988 commit b67d3b2

6 files changed

Lines changed: 157 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 25 additions & 5 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,9 +83,9 @@ 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`
@@ -94,8 +94,28 @@ from the version string, and `Last-Modified` when the upstream
9494
provides a timestamp. Unknown packages render a grey "unknown"
9595
badge with a 60-second TTL.
9696

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. The Go handler uses the upstream `Time`
114+
field for `Last-Modified`; the npm handler uses the registry's
115+
`Last-Modified` response header.
116+
97117
Logging uses `consola` with tagged instances (`badge:go`,
98-
`badge:npm`).
118+
`badge:npm`, `version-cache`).
99119

100120
### Components
101121

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: 16 additions & 4 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, firstSeen } = await fetchVersion(cacheKey, async () => {
90+
const result = await this.fetchUpstream(pkg, this.logger);
91+
if (!result.version) return undefined;
92+
return { version: result.version, time: result.lastModified };
93+
});
94+
95+
return this.renderBadge(event, { version, lastModified: firstSeen });
8896
}
8997

9098
private validatePackage(event: H3Event): string | undefined {
@@ -118,8 +126,12 @@ export class BadgeHandler {
118126
}
119127

120128
private async purgeBadgeCache(event: H3Event): Promise<void> {
121-
if (typeof caches !== 'undefined' && caches.default) {
122-
await caches.default.delete(getRequestURL(event).href);
129+
try {
130+
if (typeof caches !== 'undefined' && caches.default) {
131+
await caches.default.delete(getRequestURL(event).href);
132+
}
133+
} catch (error) {
134+
this.logger.warn('edge cache purge failed:', error);
123135
}
124136
}
125137
}

server/utils/version-cache.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { consola } from 'consola';
2+
3+
const logger = consola.withTag('version-cache');
4+
5+
export interface FetchResult {
6+
version: string
7+
time?: Date
8+
}
9+
10+
interface StoredVersion {
11+
version: string | undefined
12+
firstSeen: string // ISO 8601 — Date doesn't survive JSON round-trip
13+
}
14+
15+
interface InflightResult {
16+
result: FetchResult | undefined
17+
firstSeen: 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+
try {
32+
await useStorage('versions').removeItem(key);
33+
} catch (error) {
34+
logger.warn(`failed to delete ${key}:`, error);
35+
}
36+
}
37+
38+
/**
39+
* Returns the last known version, polling upstream only when
40+
* the KV entry has expired. Uses the upstream publish time
41+
* when available, otherwise tracks first-seen locally.
42+
*
43+
* Degrades gracefully — KV failures fall through to an
44+
* upstream fetch rather than crashing the handler.
45+
*/
46+
export async function fetchVersion(
47+
key: string,
48+
fetcher: () => Promise<FetchResult | undefined>,
49+
): Promise<{ version: string | undefined; firstSeen: Date }> {
50+
const storage = useStorage('versions');
51+
52+
// KV cache hit — return stored data directly
53+
try {
54+
const stored = await storage.getItem<StoredVersion>(key);
55+
if (stored) {
56+
return {
57+
version: stored.version,
58+
firstSeen: new Date(stored.firstSeen),
59+
};
60+
}
61+
} catch (error) {
62+
logger.warn(`KV read failed for ${key}:`, error);
63+
}
64+
65+
// KV miss or expired — poll upstream with in-flight dedup.
66+
// The first caller fetches + writes KV; concurrent joiners
67+
// just await the same result without duplicate writes.
68+
let pending = inflight.get(key);
69+
if (!pending) {
70+
pending = (async () => {
71+
let result: FetchResult | undefined;
72+
try {
73+
result = await fetcher();
74+
} catch (error) {
75+
logger.warn(`upstream fetch failed for ${key}:`, error);
76+
}
77+
const version = result?.version;
78+
const firstSeen = result?.time ?? new Date();
79+
const ttl = version === undefined ? TTL_ERROR : TTL_OK;
80+
81+
try {
82+
await storage.setItem(key, {
83+
version,
84+
firstSeen: firstSeen.toISOString(),
85+
} satisfies StoredVersion, { ttl });
86+
} catch (error) {
87+
logger.warn(`KV write failed for ${key}:`, error);
88+
}
89+
90+
return { result, firstSeen, ttl };
91+
})();
92+
inflight.set(key, pending);
93+
pending.finally(() => inflight.delete(key));
94+
}
95+
96+
const { result, firstSeen } = await pending;
97+
return { version: result?.version, firstSeen };
98+
}

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)