Skip to content

Commit a409692

Browse files
authored
Merge pull request #2
Badge caching, HTTP semantics, and dev server fixes
2 parents 1c4adf9 + b3f8941 commit a409692

12 files changed

Lines changed: 371 additions & 78 deletions

File tree

AGENTS.md

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,37 +70,70 @@ go: github.com/kagal-dev/example
7070
Self-hosted SVG version badges rendered by `badge-maker` with
7171
pre-computed logo data URIs (extracted from `simple-icons` at
7272
development time, inlined as base64 constants to avoid bundling the
73-
full icon library into the Nitro server). The shared rendering logic
74-
lives in `server/utils/badge.ts` (Nitro auto-imports it).
73+
full icon library into the Nitro server). The `BadgeHandler` class
74+
in `server/utils/badge.ts` handles method dispatch, input
75+
validation, cache headers, SVG rendering, and cache management —
76+
each endpoint provides only its identity and fetch callback.
7577

7678
### Endpoints
7779

78-
- `/api/badge/go/{module}` — fetches version from
80+
- `GET /api/badge/go/{module}` — fetches version from
7981
`proxy.golang.org/{module}/@latest`. Validates the path
8082
matches a Go module pattern (domain with dot in first segment).
81-
- `/api/badge/npm/{package}` — fetches version from
83+
- `GET /api/badge/npm/{package}` — fetches version from
8284
`registry.npmjs.org/{package}/latest`. Validates the name
8385
matches npm naming rules (`@scope/name` or `name`).
84-
85-
Both endpoints return `image/svg+xml` with cache headers (1 hour
86-
for successful responses, 60 seconds for errors). Unknown packages
87-
render a grey "unknown" badge instead of erroring.
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.
89+
- Other methods return `405` with an `Allow` header.
90+
91+
Successful responses carry split `Cache-Control` (`max-age=3600`
92+
for browsers, `s-maxage=300` for the CF edge), a weak `ETag`
93+
from the version string (conditional requests return `304`),
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.
114+
115+
Logging uses `consola` with tagged instances (`badge:go`,
116+
`badge:npm`, `version-cache`).
88117

89118
### Components
90119

91120
- `BadgeVersion` — generic badge `<img>` wrapper with loading
92121
skeleton, error fallback (shows alt text), and SSR hydration
93122
handling (`onMounted` checks `complete` + `naturalWidth`).
123+
- `BadgeVersionGo` — MDC wrapper (`:badge-version-go{mod="..."}`).
124+
Optional `dir` prop for subpackages sharing a parent `go.mod`
125+
badge fetches the parent module version, link points to the
126+
subpackage on pkg.go.dev.
127+
- `BadgeVersionNpm` — MDC wrapper (`:badge-version-npm{pkg="..."}`).
94128
- Icons use `@nuxt/icon` with `<Icon name="simple-icons:github" />`
95129
instead of hand-rolled SVG components.
96130

97131
## Content DB in development
98132

99-
After adding or removing content files, run `pnpm generate`
100-
before `pnpm dev`. The cloudflare preset serves the client-side
101-
SQL dump from Nitro build storage, which only `generate` (or
102-
`build`) populates. Without this step, SSR works but client-side
103-
navigation will 404.
133+
`pnpm dev` runs `nuxt cleanup` before starting the dev server,
134+
removing stale client-side SQL dumps that would otherwise cause
135+
SPA 404s. The dev server regenerates the content database on
136+
startup, so no separate `pnpm generate` step is needed.
104137

105138
## Dev server management
106139

README.md

Lines changed: 5 additions & 7 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) |
@@ -62,15 +63,12 @@ file updates the Zod enum automatically (derived via `readdirSync`).
6263

6364
```sh
6465
pnpm install
65-
pnpm generate # seed the client-side content DB (see below)
66-
pnpm dev # local dev server
66+
pnpm dev # runs nuxt cleanup + dev server
6767
```
6868

69-
**Content DB caveat:** the cloudflare preset's content handler
70-
serves the SQL dump from Nitro build storage. `pnpm generate`
71-
(or `pnpm build`) populates that storage. Without it, client-side
72-
navigation will 404 because the browser's WASM SQLite has no data.
73-
Re-run `pnpm generate` whenever content files are added or removed.
69+
`pnpm dev` runs `nuxt cleanup` before starting the dev server,
70+
removing stale client-side SQL dumps that would cause SPA 404s.
71+
The dev server regenerates the content database on startup.
7472

7573
## Build and deploy
7674

app/components/badge-version-go.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
<script setup lang="ts">
22
const props = defineProps<{
33
mod: string
4+
dir?: string
45
}>();
56
7+
const pkg = computed(() => props.dir ? `${props.mod}/${props.dir}` : props.mod);
68
const source = computed(() => `/api/badge/go/${props.mod}`);
7-
const href = computed(() => `https://pkg.go.dev/${props.mod}`);
8-
const alt = computed(() => `Go version for ${props.mod}`);
9-
const title = computed(() => `Go module ${props.mod}`);
9+
const href = computed(() => `https://pkg.go.dev/${pkg.value}`);
10+
const alt = computed(() => `Go version for ${pkg.value}`);
11+
const title = computed(() => `Go module ${pkg.value}`);
1012
</script>
1113

1214
<template>

app/components/badge-version.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ watch(() => props.src, () => {
3434
:title="title"
3535
target="_blank"
3636
rel="noopener"
37+
class="inline-flex items-center leading-none"
3738
>
3839
<span
3940
v-if="!loaded && !error"
@@ -50,14 +51,19 @@ watch(() => props.src, () => {
5051
ref="badge"
5152
:src="src"
5253
:alt="alt"
53-
class="h-5"
54+
class="badge-version-img"
5455
@load="loaded = true"
5556
@error="error = true"
5657
>
5758
</a>
5859
</template>
5960

6061
<style scoped>
62+
.badge-version-img {
63+
height: 20px;
64+
margin: 0;
65+
}
66+
6167
.badge-version-skeleton {
6268
display: inline-block;
6369
width: 72px;

nuxt.config.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,36 @@ 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()],
31-
optimizeDeps: {
32-
include: [],
33-
},
3442
build: {
3543
sourcemap: isDevelopment,
3644
},
3745
},
46+
hooks: {
47+
'vite:extendConfig'(config) {
48+
// @nuxtjs/mdc pushes remark/rehype entries into optimizeDeps.include
49+
// after config merges, but they're unresolvable (server-only deps).
50+
const include = config.optimizeDeps?.include;
51+
if (include) {
52+
config.optimizeDeps!.include = include.filter(
53+
(entry: string) => !entry.includes('@nuxtjs/mdc >'),
54+
);
55+
}
56+
},
57+
},
3858
eslint: {
3959
config: {
4060
stylistic: true,

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"check": "run-s generate lint:check typecheck",
1313
"clean": "rimraf .output .nuxt .wrangler node_modules",
1414
"deploy": "wrangler deploy",
15-
"dev": "nuxt dev --dotenv .env.local",
15+
"dev": "nuxt cleanup && nuxt dev --dotenv .env.local",
1616
"generate": "nuxt generate",
1717
"lint": "eslint . --fix",
1818
"lint:check": "eslint .",
@@ -24,6 +24,7 @@
2424
"@nuxt/content": "^3.12.0",
2525
"@nuxt/icon": "^2.2.1",
2626
"badge-maker": "^5.0.2",
27+
"consola": "^3.4.2",
2728
"nuxt": "^4.4.2",
2829
"nuxt-studio": "~1.4"
2930
},

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
import { logos } from '#badge-logos';
22

3-
const COLOR = '007D9C';
4-
53
// Go module paths: domain.tld/path — first segment must contain a dot
64
const GO_MODULE_RE = /^[a-z0-9][\w.-]*\.[a-z]{2,}(\/[\w.~-]+)*$/i;
75

8-
export default defineEventHandler(async (event) => {
9-
const pkg = getRouterParam(event, 'package');
10-
if (!pkg || !GO_MODULE_RE.test(pkg)) {
11-
return renderBadgeError(event, { logo: logos.go, idSuffix: 'go' });
12-
}
6+
// Go proxy requires uppercase letters encoded as !lowercase
7+
const escapeModulePath = (path: string) =>
8+
path.replaceAll(/[A-Z]/g, (c) => `!${c.toLowerCase()}`);
9+
10+
const badge = new BadgeHandler({
11+
logo: logos.go,
12+
idSuffix: 'go',
13+
color: '007D9C',
14+
pattern: GO_MODULE_RE,
1315

14-
const proxyUrl = `https://proxy.golang.org/${pkg}/@latest`;
15-
const info = await $fetch<{ Version: string }>(proxyUrl).catch((error) => {
16-
console.warn(`[badge/go] failed to fetch ${pkg}:`, error);
17-
return undefined;
18-
});
16+
async fetch(modulePath, logger) {
17+
const proxyUrl = `https://proxy.golang.org/${escapeModulePath(modulePath)}/@latest`;
18+
const info = await $fetch<{ Version: string; Time: string }>(proxyUrl).catch((error) => {
19+
logger.warn(`failed to fetch ${modulePath}:`, error);
20+
return undefined;
21+
});
1922

20-
return renderBadge(event, { logo: logos.go, idSuffix: 'go', color: COLOR, version: info?.Version });
23+
return { version: info?.Version, lastModified: parseDate(info?.Time) };
24+
},
2125
});
26+
27+
export default badge.eventHandler;
Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
import { logos } from '#badge-logos';
22

3-
const COLOR = 'CB3837';
4-
53
// npm package names: @scope/name or name
64
const NPM_PACKAGE_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
75

8-
export default defineEventHandler(async (event) => {
9-
const pkg = getRouterParam(event, 'package');
10-
if (!pkg || !NPM_PACKAGE_RE.test(pkg)) {
11-
return renderBadgeError(event, { logo: logos.npm, idSuffix: 'npm' });
12-
}
6+
const badge = new BadgeHandler({
7+
logo: logos.npm,
8+
idSuffix: 'npm',
9+
color: 'CB3837',
10+
pattern: NPM_PACKAGE_RE,
1311

14-
const registryUrl = `https://registry.npmjs.org/${pkg}/latest`;
15-
const info = await $fetch<{ version: string }>(registryUrl).catch((error) => {
16-
console.warn(`[badge/npm] failed to fetch ${pkg}:`, error);
17-
return undefined;
18-
});
12+
async fetch(pkg, logger) {
13+
const registryUrl = `https://registry.npmjs.org/${pkg}/latest`;
14+
const response = await $fetch.raw<{ version: string }>(registryUrl).catch((error) => {
15+
logger.warn(`failed to fetch ${pkg}:`, error);
16+
return undefined;
17+
});
1918

20-
return renderBadge(event, { logo: logos.npm, idSuffix: 'npm', color: COLOR, version: info?.version });
19+
return {
20+
version: response?._data?.version,
21+
lastModified: parseDate(response?.headers.get('last-modified') || undefined),
22+
};
23+
},
2124
});
25+
26+
export default badge.eventHandler;

0 commit comments

Comments
 (0)