diff --git a/docs/cold-page-request.md b/docs/cold-page-request.md index a9204c9..6da3639 100644 --- a/docs/cold-page-request.md +++ b/docs/cold-page-request.md @@ -17,30 +17,30 @@ sequenceDiagram SSR->>ContentRoute: $fetch (navigation) ContentRoute->>Content: getProdContent() - Content->>Refs: resolveSha(targetBranch) + Content->>Refs: resolveContentSha(targetBranch, contentDir) alt cache hit (within 60s TTL) - Refs-->>Content: cached sha + Refs-->>Content: cached content sha else cache miss - Refs->>GH: commits/ - GH-->>Refs: sha - Refs-->>Content: sha + Refs->>GH: commits?sha=&path= + GH-->>Refs: latest content sha + Refs-->>Content: content sha end - Content->>Content: rebuild if sha advanced - Content->>GH: init partial (~36 files, at ) + Content->>Content: rebuild if content sha advanced + Content->>GH: init partial (~36 files, at ) ContentRoute-->>SSR: nav tree SSR->>ContentRoute: $fetch (page) ContentRoute->>Content: getProdContent() (same sha → no rebuild) - Content->>GH: fetch + parse 1 page (at ) + Content->>GH: fetch + parse 1 page (at ) ContentRoute-->>SSR: parsed page SSR-->>Edge: HTML Edge-->>Browser: HTML (cached for next visitor) ``` -**Cost:** one shared-cache lookup for the branch tip + the instance builds its index -from GitHub once per head, then one page parse. All reads pinned to the immutable -``. +**Cost:** one shared-cache lookup for the latest commit touching the content directory + the +instance builds its index from GitHub once per content revision, then one page parse. All reads +are pinned to the immutable ``. Code-only commits do not rebuild the content instance. The ref cache is shared across *instances*, so GitHub is hit once per 60s TTL window rather than once per cold start. It is **not** shared across regions — Vercel's @@ -49,14 +49,19 @@ Runtime Cache is regional (see the note on `refCacheDriver()` in This project runs single-region, which is what makes that distinction academic today. A ref that doesn't resolve is cached too, for the same window, but **only** when the -caller asks for it (`resolveSha(ref, { cacheMisses: true })`) — the public +caller asks for it (`resolveContentSha(ref, contentDir, { cacheMisses: true })`) — the public `/tree/:branch` route does, so a nonexistent branch can't be replayed into one GitHub API call per request. The production branch above deliberately does not: GitHub answers 404 when a token loses access to a private repo, and caching that would turn an expired token into a site-wide outage for the window rather than one failed request. -**On a content push**, `server/api/revalidate.post.ts` writes the new SHA -directly into the same shared ref cache (`cacheSha()`) before fanning out ISR +**On a content push**, `server/api/revalidate.post.ts` forces a fresh `resolveContentSha()` lookup, +which writes the latest content SHA into the same shared ref cache before fanning out ISR purges for the affected pages, so a freshly-purged page's next render already sees the new SHA instead of waiting out the 60s TTL. + +Parsed manifests and bodies live under a parser-version + content-SHA namespace. Vercel +Runtime Cache persists across deployments within an environment, so unrelated deployments can reuse +immutable content artifacts. `CONTENT_PARSER_VERSION` must be bumped when parser/plugin configuration, +relevant parser dependencies, or cached derived data changes. diff --git a/playground/content/2.concepts/1.architecture.md b/playground/content/2.concepts/1.architecture.md index 3b5ab1f..3253844 100644 --- a/playground/content/2.concepts/1.architecture.md +++ b/playground/content/2.concepts/1.architecture.md @@ -8,9 +8,9 @@ description: The serving modes and caching tiers behind the layer. | Mode | URL | Content | | --- | --- | --- | | prod | `/getting-started/introduction` | pinned production SHA | -| tree | `/tree/main/...` | branch tip preview | +| tree | `/tree/main/...` | latest content commit on the branch | | blob | `/blob//...` | immutable commit preview | ## Caching -Two tiers: ISR-cached page HTML at the edge, and a per-SHA runtime cache for parsed Markdown bodies. +Two tiers: ISR-cached page HTML at the edge, and a per-parser-version, per-content-SHA runtime cache for parsed Markdown bodies. diff --git a/playground/skills/preview-versions/SKILL.md b/playground/skills/preview-versions/SKILL.md index 5a51fb5..2d0a9cd 100644 --- a/playground/skills/preview-versions/SKILL.md +++ b/playground/skills/preview-versions/SKILL.md @@ -13,12 +13,12 @@ Content is served at request time. Production is pinned to a commit SHA; any bra | Mode | URL | Content | | --- | --- | --- | | prod | `/getting-started/introduction` | pinned production SHA | -| tree | `/tree/main/getting-started/introduction` | branch tip | +| tree | `/tree/main/getting-started/introduction` | latest commit touching the content directory | | blob | `/blob//getting-started/introduction` | immutable commit | Raw markdown mirrors exist at `/raw/**` (and under `/tree/.../raw/` / `/blob/.../raw/`). -Two cache tiers: ISR-cached page HTML at the edge, and a per-SHA runtime cache for parsed Markdown bodies. A GitHub push to the production branch hits `/api/revalidate` and purges ISR. +Two cache tiers: ISR-cached page HTML at the edge, and a per-parser-version, per-content-SHA runtime cache for parsed Markdown bodies. A GitHub push to the production branch hits `/api/revalidate` and purges ISR. Keyboard shortcut `g` `h` toggles the version-history panel on a docs page. diff --git a/server/api/content/tree/[branch]/[...path].get.ts b/server/api/content/tree/[branch]/[...path].get.ts index 9f0ef4e..362414c 100644 --- a/server/api/content/tree/[branch]/[...path].get.ts +++ b/server/api/content/tree/[branch]/[...path].get.ts @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => { } // `cacheMisses`: the ref comes from the URL, so a miss must not re-cost a GitHub call each time. - const sha = await resolveSha(branch, { cacheMisses: true }) + const sha = await resolveContentSha(branch, useRuntimeConfig(event).docs.contentDir, { cacheMisses: true }) const content = await getPreviewContent(sha, `/api/content/tree/${encodeURIComponent(branch)}`) return await content.handler(toWebRequest(event)) diff --git a/server/api/revalidate.post.ts b/server/api/revalidate.post.ts index ace2e2b..70d65a8 100644 --- a/server/api/revalidate.post.ts +++ b/server/api/revalidate.post.ts @@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => { const payload = JSON.parse(raw) as GitHubPushPayload const branch = targetBranch() + const contentDir = docs.contentDir const expectedRef = `refs/heads/${branch}` if (payload.ref !== expectedRef) { @@ -103,10 +104,11 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Missing head commit SHA' }) } - // Ahead of the purge fan-out, so a freshly-purged page can't re-render against the stale SHA. - await cacheSha(branch, headSha) + // Bypass the short ref cache and write the canonical path-filtered revision before the purge fan-out, + // so a freshly-purged page cannot re-render against a stale or payload-order-dependent content SHA. + const contentSha = await resolveContentSha(branch, contentDir, { refresh: true }) - console.log(`[content] revalidate push headSha=${headSha ?? ''}`) + console.log(`[content] revalidate push headSha=${headSha} contentSha=${contentSha}`) const requestId = getHeader(event, 'x-vercel-id') ?? getHeader(event, 'x-request-id') ?? 'local' const tag = `[revalidate:${requestId}]` @@ -127,7 +129,9 @@ export default defineEventHandler(async (event) => { } } - const headContent = await createSourceContent(headSha, { cache: { driver: cacheDriver(headSha) } }) + // The head snapshot has the same content directory as `contentSha`; populate the namespace that + // production instances will read even when later commits in this push only changed code. + const headContent = await createSourceContent(headSha, { cache: { driver: cacheDriver(contentSha) } }) await headContent.init() const newItems = headContent.manifest.items diff --git a/server/utils/cache.ts b/server/utils/cache.ts index bb9ba52..2c621cc 100644 --- a/server/utils/cache.ts +++ b/server/utils/cache.ts @@ -5,7 +5,7 @@ import vercelRuntimeCache from 'unstorage/drivers/vercel-runtime-cache' /** SHA-pinned content is immutable, so it can be cached for a long time. */ const TTL = 60 * 60 * 24 -/** Branch tips move, so the ref pointer cache uses a short TTL. */ +/** Content refs move with their branches, so the pointer cache uses a short TTL. */ const REF_TTL = 60 /** Whether the Vercel Runtime Cache is available (i.e. running on Vercel). */ @@ -13,11 +13,17 @@ function cacheAvailable(): boolean { return !import.meta.dev && Boolean(process.env.VERCEL) } -/** Per-SHA driver backing comark's content cache (parsed bodies). */ +/** + * Bump when content parser/plugin configuration, relevant parser dependencies, or cached derived + * data changes. Keeping this explicit lets unrelated deployments reuse immutable content artifacts. + */ +export const CONTENT_PARSER_VERSION = 'v1' + +/** Per-parser-version, per-content-SHA driver backing comark's manifest and parsed bodies. */ export function cacheDriver(sha: string): Driver { if (!cacheAvailable()) return memoryDriver() return vercelRuntimeCache({ - base: `content:${sha}`, + base: `content:${CONTENT_PARSER_VERSION}:${sha}`, ttl: TTL, }) } @@ -32,13 +38,14 @@ export function shaCacheStorage(sha: string): Storage { } /** - * Shared driver backing the branch → commit SHA pointer (`resolveSha`/`cacheSha` in `github.ts`), - * in its own namespace so every instance reads one pointer instead of keeping its own timer. + * Shared driver backing the branch + content directory → content commit pointer + * (`resolveContentSha` in `github.ts`), in its own namespace so every instance reads one pointer + * instead of keeping its own timer. * * Vercel Runtime Cache is **regional**, not global (https://vercel.com/docs/caching/runtime-cache): * this assumes Functions run in a single region (no `regions` in `vercel.json`/`nuxt.config.ts`). - * Multi-region would confine `cacheSha()`'s write-through to the webhook's region — others self-heal - * on TTL, so reach for a globally replicated store (e.g. Edge Config) only if that day comes. + * Multi-region would confine the webhook's forced refresh to its region — others self-heal on TTL, + * so reach for a globally replicated store (e.g. Edge Config) only if that day comes. */ export function refCacheDriver(): Driver { if (!cacheAvailable()) return memoryDriver() diff --git a/server/utils/content.ts b/server/utils/content.ts index 57622d2..e30eeb3 100644 --- a/server/utils/content.ts +++ b/server/utils/content.ts @@ -15,6 +15,7 @@ import { contentTracer } from './tracer.ts' // assignment lands after the await, so two requests on a cold instance would each build a CMS. let content: Promise | undefined +// Bump CONTENT_PARSER_VERSION in `cache.ts` when these plugins or their options change cached output. const comarkPlugins = [ mermaid({ theme: 'zinc-light', themeDark: 'zinc-dark' }), rangi({ theme: { light: githubLight, dark: githubDark } }), @@ -86,18 +87,19 @@ export function targetBranch(): string { let headRef: string | undefined export function getHeadRef(): string { - headRef ??= process.env.VERCEL_GIT_COMMIT_SHA || targetBranch() + headRef ??= targetBranch() return headRef } /** * Shared content instance for the lifetime of this server instance, pinned to `headRef`. In production every - * call resolves the tip of `targetBranch()` via `resolveSha()` — a shared, short-TTL cache, not a - * per-instance timer — and rebuilds when it advances. Previews stay pinned to their build commit. + * call resolves the latest commit touching the content directory via `resolveContentSha()` — a shared, + * short-TTL cache, not a per-instance timer — and rebuilds when that advances. Previews stay pinned. */ export async function getProdContent(): Promise { if (['production', 'preview'].includes(process.env.VERCEL_ENV || '')) { - const sha = await resolveSha(targetBranch()) + const { contentDir } = useRuntimeConfig().docs + const sha = await resolveContentSha(targetBranch(), contentDir) if (sha !== getHeadRef()) { console.log(`[content] head ${getHeadRef()} -> ${sha}`) headRef = sha diff --git a/server/utils/github.ts b/server/utils/github.ts index acc67e1..64cb15f 100644 --- a/server/utils/github.ts +++ b/server/utils/github.ts @@ -39,16 +39,18 @@ export function githubToken(): string | undefined { return docs.githubToken || process.env.GITHUB_TOKEN || undefined } -// Branch → commit SHA pointer, shared across every instance so only one pays for the GitHub API -// call per TTL window. See `refCacheDriver()` for the single-region assumption this relies on. +// Branch + content directory → content commit SHA pointer, shared across every instance so only one +// pays for the GitHub API call per TTL window. See `refCacheDriver()` for the single-region assumption. const refStorage = createStorage({ driver: refCacheDriver() }) -const refKey = (branch: string) => `branch:${branch}` +const normalizeContentDir = (contentDir: string) => contentDir.replace(/^\/+|\/+$/g, '') +const refKey = (branch: string, contentDir: string) => + `branch:${encodeURIComponent(branch)}:path:${encodeURIComponent(normalizeContentDir(contentDir))}` -/** Sentinel for "this ref doesn't resolve" — see the negative caching in `resolveSha`. */ +/** Sentinel for "this ref doesn't resolve" — see the negative caching in `resolveContentSha`. */ const UNRESOLVED = '\0unresolved' /** - * Resolve a branch name to its tip commit SHA. + * Resolve a branch to the latest commit that touched `contentDir`. * * Callers serving attacker-supplied refs must set `cacheMisses`: `/tree/:branch` is public, so with * no negative entry every missing-branch request costs an authenticated GitHub call — an @@ -56,45 +58,55 @@ const UNRESOLVED = '\0unresolved' * must not be negative-cached: GitHub answers 404, not 403, for a repo a token can't see, so a * rotated token looks like a missing ref and caching that downs the site for the TTL. */ -export async function resolveSha(branch: string, opts: { cacheMisses?: boolean } = {}): Promise { +export async function resolveContentSha( + branch: string, + contentDir: string, + opts: { cacheMisses?: boolean; refresh?: boolean } = {} +): Promise { if (import.meta.dev) return branch - const cached = await refStorage.getItem(refKey(branch)) - if (cached === UNRESOLVED) { - throw createError({ statusCode: 404, statusMessage: `Ref not found: ${branch}` }) + const key = refKey(branch, contentDir) + if (!opts.refresh) { + const cached = await refStorage.getItem(key) + if (cached === UNRESOLVED) { + throw createError({ statusCode: 404, statusMessage: `Ref not found: ${branch}` }) + } + if (cached) return cached } - if (cached) return cached const token = githubToken() - let commit: { sha: string } + let commits: Array<{ sha: string }> try { - commit = await $fetch<{ sha: string }>(`https://api.github.com/repos/${githubRepo()}/commits/${branch}`, { + commits = await $fetch>(`https://api.github.com/repos/${githubRepo()}/commits`, { headers: { Accept: 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + query: { + sha: branch, + path: normalizeContentDir(contentDir), + per_page: 1, + }, }) - } catch (error: any) { + } catch (error: unknown) { // Only a definitive 404 is cacheable; a 5xx, rate-limit 403 or network blip stays retryable. - const status = error?.statusCode ?? error?.response?.status + const failure = error as { statusCode?: number; response?: { status?: number } } + const status = failure.statusCode ?? failure.response?.status if (status === 404) { - if (opts.cacheMisses) await refStorage.setItem(refKey(branch), UNRESOLVED) + if (opts.cacheMisses) await refStorage.setItem(key, UNRESOLVED) throw createError({ statusCode: 404, statusMessage: `Ref not found: ${branch}` }) } throw error } - await refStorage.setItem(refKey(branch), commit.sha) - return commit.sha -} + const sha = commits[0]?.sha + if (!sha) { + if (opts.cacheMisses) await refStorage.setItem(key, UNRESOLVED) + throw createError({ statusCode: 404, statusMessage: `Content not found at ref: ${branch}` }) + } -/** - * Write-through, so the revalidate webhook needn't wait for the next `resolveSha` TTL window — this - * stops freshly-purged ISR pages re-rendering against a stale SHA. Reaches only the region running - * it (see `refCacheDriver()`); other regions self-heal via TTL. - */ -export async function cacheSha(branch: string, sha: string): Promise { - await refStorage.setItem(refKey(branch), sha) + await refStorage.setItem(key, sha) + return sha } export interface PageCommit { diff --git a/server/utils/search.ts b/server/utils/search.ts index 827bc18..af4a2c6 100644 --- a/server/utils/search.ts +++ b/server/utils/search.ts @@ -9,11 +9,11 @@ interface SearchSection { } // Building the index parses every document — the most expensive read in the app. The built sections -// are persisted in `content.cache`, whose driver is namespaced per SHA (`content:${sha}`), so the -// index survives cold starts, is shared across lambda instances in the region, and a stale index is -// unreachable: a new head or preview SHA reads from a fresh namespace. Colon-free so it can't collide -// with `:` content keys, the `manifest` key, or the shared `gh:` namespace, and the SWR -// fallback in `cache.get` (`key.split(':')`) can't map it to a real source. +// are persisted in `content.cache`, whose driver is namespaced per parser version and content SHA, so +// the index survives cold starts, is shared across lambda instances in the region, and stale parser +// output is unreachable. Colon-free so it can't collide with `:` content keys, the +// `manifest` key, or the shared `gh:` namespace, and the SWR fallback in `cache.get` +// (`key.split(':')`) can't map it to a real source. const SEARCH_SECTIONS_KEY = 'search-sections' /** diff --git a/test/github.test.ts b/test/github.test.ts new file mode 100644 index 0000000..b39103d --- /dev/null +++ b/test/github.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveContentSha } from '../server/utils/github' + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('resolveContentSha', () => { + it('resolves the latest commit touching the configured content directory', async () => { + const fetch = vi.fn().mockResolvedValue([{ sha: 'content-sha' }]) + vi.stubGlobal('$fetch', fetch) + + await expect(resolveContentSha('feat/docs', '/docs/content/')).resolves.toBe('content-sha') + expect(fetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/comarkdown/comark-docs/commits', + expect.objectContaining({ + query: { sha: 'feat/docs', path: 'docs/content', per_page: 1 }, + }) + ) + }) + + it('caches each branch and content directory independently', async () => { + const fetch = vi.fn().mockResolvedValueOnce([{ sha: 'docs-sha' }]).mockResolvedValueOnce([{ sha: 'api-sha' }]) + vi.stubGlobal('$fetch', fetch) + + await expect(resolveContentSha('test/cache-key', 'docs/content')).resolves.toBe('docs-sha') + await expect(resolveContentSha('test/cache-key', 'docs/content')).resolves.toBe('docs-sha') + await expect(resolveContentSha('test/cache-key', 'api/content')).resolves.toBe('api-sha') + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('can refresh a cached content revision for the push webhook', async () => { + const fetch = vi.fn().mockResolvedValueOnce([{ sha: 'before' }]).mockResolvedValueOnce([{ sha: 'after' }]) + vi.stubGlobal('$fetch', fetch) + + await expect(resolveContentSha('test/refresh', 'docs/content')).resolves.toBe('before') + await expect(resolveContentSha('test/refresh', 'docs/content')).resolves.toBe('before') + await expect(resolveContentSha('test/refresh', 'docs/content', { refresh: true })).resolves.toBe('after') + await expect(resolveContentSha('test/refresh', 'docs/content')).resolves.toBe('after') + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/setup.ts b/test/setup.ts index 225c393..ba5873c 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,6 +2,8 @@ // importing one directly in a test leaves those names undefined. Declaring the few the tests touch here beats // pulling in the whole Nuxt/Nitro harness for a handful of pure functions. `useRuntimeConfig` returns the shape // `modules/config.ts` seeds. +import memoryDriver from 'unstorage/drivers/memory' + export interface TestRuntimeConfig { docs: { contentDir: string @@ -41,3 +43,5 @@ globals.createError = (input: { statusCode?: number; statusMessage?: string; mes error.statusCode = input.statusCode return error } + +globals.refCacheDriver = () => memoryDriver()