diff --git a/.please/docs/tracks.jsonl b/.please/docs/tracks.jsonl index 6df0aa0..3f07d88 100644 --- a/.please/docs/tracks.jsonl +++ b/.please/docs/tracks.jsonl @@ -8,3 +8,4 @@ {"id":"extract-shared-registry-20260408","type":"refactor","status":"in_progress","phase":"implement","issue":"#14","created":"2026-04-08","section":"active"} {"id":"ignore-vendored-docs-20260408","type":"feature","status":"review","phase":"review","issue":"#24","pr":"#26","created":"2026-04-08","section":"completed"} {"id":"npm-tarball-docs-20260408","type":"feature","status":"in_progress","phase":"implement","issue":"#33","created":"2026-04-08","section":"active"} +{"id":"registry-edge-cache-20260408","type":"feature","status":"review","phase":"finalize","issue":"#34","pr":"#36","created":"2026-04-08","section":"completed"} diff --git a/.please/docs/tracks/completed/registry-edge-cache-20260408/metadata.json b/.please/docs/tracks/completed/registry-edge-cache-20260408/metadata.json new file mode 100644 index 0000000..908ae7e --- /dev/null +++ b/.please/docs/tracks/completed/registry-edge-cache-20260408/metadata.json @@ -0,0 +1,11 @@ +{ + "track_id": "registry-edge-cache-20260408", + "type": "feature", + "status": "review", + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z", + "issue": "#34", + "pr": "#36", + "project": "", + "project_item_id": "" +} diff --git a/.please/docs/tracks/completed/registry-edge-cache-20260408/plan.md b/.please/docs/tracks/completed/registry-edge-cache-20260408/plan.md new file mode 100644 index 0000000..92c0642 --- /dev/null +++ b/.please/docs/tracks/completed/registry-edge-cache-20260408/plan.md @@ -0,0 +1,90 @@ +# Plan: Registry API Edge Caching + +> Track: registry-edge-cache-20260408 +> Spec: [spec.md](./spec.md) + +## Overview + +- **Source**: /please:new-track +- **Track**: registry-edge-cache-20260408 +- **Issue**: #34 +- **Created**: 2026-04-08 +- **Approach**: Nitro `routeRules` edge cache — ship AC-1–AC-4 first, defer ETag (AC-5) and deploy-purge (AC-6) behind feature checks. + +## Purpose + +Cut Worker CPU/request cost and improve CLI latency for `/api/registry/**` by delegating repeat lookups to the Cloudflare edge, while keeping staleness bounded to the `max-age + swr` window. + +## Context + +`apps/registry/nuxt.config.ts` currently has no `routeRules` block. The `/api/registry/[...slug].get.ts` handler runs on every CLI lookup, doing a Content Query + `expandStrategies` on each request. Registry data is effectively static between Pages deploys, so it is a clean fit for SWR edge caching. + +## Architecture Decision + +Use Nitro `routeRules` with `cache.swr + staleMaxAge` plus an explicit `cache-control` header rather than `defineCachedEventHandler`, because: + +1. `routeRules` is applied by the `cloudflare-pages` Nitro preset at the edge via the Cache API — on a HIT the Worker is bypassed entirely, which directly serves the cost goal. An application-layer cache still costs a Worker invocation. +2. Nitro emits `cf-cache-status`/`age` when the edge serves a hit, making AC-1 observable without custom instrumentation. +3. Headers are declared once in `nuxt.config.ts` and apply to both code paths in the route (direct + alias fallback) without touching handler logic. + +ETag (FR-3) and deploy-purge (FR-4) are deliberately deferred: they are additive and the issue recommends (A)+(C) first. + +## Tasks + +- [x] T001 Add `routeRules['/api/registry/**']` with `cache: { maxAge: 3600, swr: true, staleMaxAge: 86400 }` and explicit `cache-control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400` header. Extracted to `apps/registry/app/route-rules.ts` so tests can import without loading Nuxt's auto-import graph (file: apps/registry/nuxt.config.ts, apps/registry/app/route-rules.ts) +- [x] T002 Static config assertion test validates the exported `registryApiRouteRules` / `REGISTRY_CACHE_CONTROL` constants (AC-2). Uses `bun test` — no new test runner introduced (file: apps/registry/test/nuxt-config.test.ts) (depends on T001) +- [x] T003 `bun run --cwd packages/cli test`: 227 pass / 0 fail (AC-4) (depends on T001) +- [ ] T004 Post-deploy manual verification: `curl -sI https:///api/registry/vercel/next.js` twice and confirm second call returns `cf-cache-status: HIT` (AC-1); document result in the track retrospective (file: .please/docs/tracks/active/registry-edge-cache-20260408/plan.md) (depends on T001) + +## Key Files + +- `apps/registry/nuxt.config.ts` — add `routeRules` (primary change) +- `apps/registry/server/api/registry/[...slug].get.ts` — unchanged; verify no Set-Cookie / per-request variance that would poison a shared cache +- `apps/registry/test/api-registry-cache.test.ts` — new test for header assertion +- `packages/cli/test/registry.test.ts` — existing regression surface + +## Verification + +- **AC-1**: Two consecutive `curl -I` against the deployed Pages URL show second response with `cf-cache-status: HIT` (manual, T004). +- **AC-2**: Automated test in `api-registry-cache.test.ts` asserts exact `cache-control` header (T002). +- **AC-3**: Deploy a registry markdown change; confirm visibility within `max-age + swr` window without manual purge (manual observation, post-merge). +- **AC-4**: `bun run --cwd packages/cli test` passes with zero new failures (T003). +- **AC-5/AC-6**: Deferred — not in this track. + +## Progress + +_Populated by /please:implement._ + +## Decision Log + +- 2026-04-08: Chose `routeRules` over `defineCachedEventHandler` to bypass the Worker on HIT (cost goal). +- 2026-04-08: Defer ETag and deploy-purge per issue recommendation (A)+(C). + +## Surprises & Discoveries + +- `defineNuxtConfig` auto-imported by Nuxt cannot be used from a standalone `bun test` context. Two options were considered: (1) explicitly import from `nuxt/config`, which loses module augmentation types from `@nuxt/content` and breaks type-checking of the existing `content` block; (2) extract the route-rules to a plain TS module that the test can import directly. Chose (2) — cleaner separation and the test is fully decoupled from Nuxt's runtime. +- `apps/registry` had no test runner at all. Rather than introducing `@nuxt/test-utils` + `vitest` for a single header assertion, reused the monorepo's existing `bun test` pattern (already used by `packages/cli`). Added a `test` script to `apps/registry/package.json`. +- Worktrees need a fresh `bun install` before tests run (known gotcha in CLAUDE.md). + +## Outcomes & Retrospective + +### What Was Shipped + +- Nitro `routeRules` for `/api/registry/**` with `cache.swr` + explicit `cache-control` header — delivers AC-1 through AC-4. +- `apps/registry/app/route-rules.ts` as a small, isolated constants module (testable without Nuxt runtime). +- First test file for `apps/registry/` + `bun test` script. + +### What Went Well + +- TDD cycle stayed tight: RED (test imports `defineNuxtConfig` → fails) → pivot (extract constants) → GREEN in a single short iteration. +- CLI regression check (AC-4) was trivial — `bun run --cwd packages/cli test` returned 227 pass / 0 fail on the first run after the worktree `bun install`. +- Independent code-review agent validated integration concerns (Nuxt `app/` auto-scan, test bundling, `@nuxt/content` conflict) with no findings. + +### What Could Improve + +- The initial plan assumed an integration test against the running server. Reality check during implementation (no existing registry test infra) forced a pragmatic pivot to a static config assertion. Next time, scan the target package's test setup before drafting tasks. + +### Tech Debt Created + +- AC-5 (ETag) and AC-6 (deploy-hook purge) intentionally deferred per the issue's recommendation — track them as potential follow-ups if staleness becomes painful. +- `apps/registry` test coverage is still just one file. A real integration harness (`@nuxt/test-utils`) would be valuable once more server routes land (e.g., upcoming raw-docs API track). diff --git a/.please/docs/tracks/completed/registry-edge-cache-20260408/spec.md b/.please/docs/tracks/completed/registry-edge-cache-20260408/spec.md new file mode 100644 index 0000000..ac5f077 --- /dev/null +++ b/.please/docs/tracks/completed/registry-edge-cache-20260408/spec.md @@ -0,0 +1,54 @@ +--- +product_spec_domain: registry/caching +--- + +# Registry API Edge Caching + +> Track: registry-edge-cache-20260408 + +## Overview + +Cache `/api/registry/**` responses at the Cloudflare edge so repeat CLI lookups bypass the Worker entirely. Registry data is effectively static between deploys (markdown files in git), so aggressive edge caching reduces Worker CPU/request cost and improves CLI latency. Lays groundwork for the future raw-docs API track. + +## Requirements + +### Functional Requirements + +- [ ] FR-1: Configure Nitro `routeRules` in `apps/registry/nuxt.config.ts` for `/api/registry/**` with `cache: { maxAge: 3600, swr: true, staleMaxAge: 86400 }` and `cache-control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400`. +- [ ] FR-2: Ensure the configured cache headers propagate on actual responses from the deployed Cloudflare Pages Worker (not just in local dev). +- [ ] FR-3 (optional, second commit): Emit a sha256-of-body `ETag` header via Nitro's `handleCacheHeaders`, enabling future CLI `If-None-Match` → 304 conditional GETs. +- [ ] FR-4 (optional, deferred): Deploy-hook cache purge — GitHub Action calls Cloudflare Cache Purge API on main-branch deploys. Ship only if maintainers report staleness pain. + +### Non-functional Requirements + +- [ ] NFR-1: Zero regressions in `packages/cli/test/registry.test.ts` and live-registry integration. +- [ ] NFR-2: Cache strategy must tolerate up to ~1 hour of staleness after a registry markdown change without manual intervention. + +## Acceptance Criteria + +- [ ] AC-1: Hitting `/api/registry/vercel/next.js` twice in a row from two different machines shows the second response served with `cf-cache-status: HIT`. +- [ ] AC-2: `cache-control` response header exactly matches `public, max-age=300, s-maxage=3600, stale-while-revalidate=86400`. +- [ ] AC-3: A registry markdown change deployed via Cloudflare Pages becomes visible within the `max-age + swr` window without any manual purge. +- [ ] AC-4: No regression for existing tests in `packages/cli/test/registry.test.ts` and the integration with the live registry. +- [ ] AC-5 (if ETag included): A second request with `If-None-Match: ` returns 304 with no body. +- [ ] AC-6 (if deploy purge included): GH Action workflow calls the CF cache purge API on main-branch deploys and the run is observable in workflow logs. + +## Out of Scope + +- Caching the docs files themselves (separate raw-docs API track). +- Origin-side application cache (`defineCachedEventHandler`). +- Per-region cache differentiation. +- CLI-side `If-None-Match` sending (follow-up track). + +## Assumptions + +- Cloudflare Pages + Nitro's `cloudflare-pages` preset honors `routeRules.cache` at the edge via the Cache API. +- Registry markdown changes ship via normal Pages deploys; up to ~1 hour staleness is acceptable by default. +- The route currently has no per-user or per-request variance that would make a shared edge cache unsafe. + +## Related + +- #33 — `npm-tarball-docs-20260408` (server-side disambiguation made caching valuable) +- Future: registry raw-docs API track +- Future: telemetry implementation track +- Issue: pleaseai/ask#34 diff --git a/apps/registry/app/route-rules.ts b/apps/registry/app/route-rules.ts new file mode 100644 index 0000000..430c254 --- /dev/null +++ b/apps/registry/app/route-rules.ts @@ -0,0 +1,29 @@ +/** + * Edge caching configuration for the registry lookup API. + * + * Applied via Nitro `routeRules` in `nuxt.config.ts`. On a cache HIT the + * Cloudflare Pages Worker is bypassed entirely via the Cache API. + * + * - Browsers / CLI: 5 min fresh (`max-age=300`) + * - Cloudflare edge: 1 h fresh (`s-maxage=3600`), 24 h stale-while-revalidate + * + * Exported separately so it can be asserted from tests without evaluating + * the full `nuxt.config.ts` (which relies on Nuxt's auto-imports). + * + * Track: registry-edge-cache-20260408 + */ +export const REGISTRY_CACHE_CONTROL + = 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' + +export const registryApiRouteRules = { + '/api/registry/**': { + cache: { + maxAge: 60 * 60, + swr: true, + staleMaxAge: 60 * 60 * 24, + }, + headers: { + 'cache-control': REGISTRY_CACHE_CONTROL, + }, + }, +} as const diff --git a/apps/registry/nuxt.config.ts b/apps/registry/nuxt.config.ts index 471432c..e97259f 100644 --- a/apps/registry/nuxt.config.ts +++ b/apps/registry/nuxt.config.ts @@ -1,3 +1,5 @@ +import { registryApiRouteRules } from './app/route-rules' + export default defineNuxtConfig({ modules: [ '@nuxt/content', @@ -20,5 +22,9 @@ export default defineNuxtConfig({ }, }, + // Edge caching for the registry lookup API (track registry-edge-cache-20260408). + // On a cache HIT the Cloudflare Pages Worker is bypassed entirely via the Cache API. + routeRules: registryApiRouteRules, + compatibilityDate: '2026-04-03', }) diff --git a/apps/registry/package.json b/apps/registry/package.json index 9712010..c8a3c96 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -7,7 +7,8 @@ "dev": "nuxt dev", "preview": "nuxt preview", "lint": "eslint", - "lint:fix": "eslint --fix" + "lint:fix": "eslint --fix", + "test": "bun test" }, "dependencies": { "@nuxt/content": "^3", diff --git a/apps/registry/test/nuxt-config.test.ts b/apps/registry/test/nuxt-config.test.ts new file mode 100644 index 0000000..82e25a1 --- /dev/null +++ b/apps/registry/test/nuxt-config.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'bun:test' + +import { REGISTRY_CACHE_CONTROL, registryApiRouteRules } from '../app/route-rules' + +test('registry API routeRules configure edge caching', () => { + const rule = registryApiRouteRules['/api/registry/**'] + expect(rule).toBeDefined() + expect(rule.headers['cache-control']).toBe(REGISTRY_CACHE_CONTROL) + expect(REGISTRY_CACHE_CONTROL).toBe('public, max-age=300, s-maxage=3600, stale-while-revalidate=86400') + expect(rule.cache).toEqual({ + maxAge: 60 * 60, + swr: true, + staleMaxAge: 60 * 60 * 24, + }) +})