Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions docs/cold-page-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>
GH-->>Refs: sha
Refs-->>Content: sha
Refs->>GH: commits?sha=<branch>&path=<contentDir>
GH-->>Refs: latest content sha
Refs-->>Content: content sha
end
Content->>Content: rebuild if sha advanced
Content->>GH: init partial (~36 files, at <sha>)
Content->>Content: rebuild if content sha advanced
Content->>GH: init partial (~36 files, at <content-sha>)
ContentRoute-->>SSR: nav tree

SSR->>ContentRoute: $fetch (page)
ContentRoute->>Content: getProdContent() (same sha → no rebuild)
Content->>GH: fetch + parse 1 page (at <sha>)
Content->>GH: fetch + parse 1 page (at <content-sha>)
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
`<sha>`.
**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 `<content-sha>`. 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
Expand All @@ -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.
4 changes: 2 additions & 2 deletions playground/content/2.concepts/1.architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sha>/...` | 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.
4 changes: 2 additions & 2 deletions playground/skills/preview-versions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sha>/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.

Expand Down
2 changes: 1 addition & 1 deletion server/api/content/tree/[branch]/[...path].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 8 additions & 4 deletions server/api/revalidate.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ?? '<none>'}`)
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}]`
Expand All @@ -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

Expand Down
21 changes: 14 additions & 7 deletions server/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@ 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). */
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,
})
}
Expand All @@ -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()
Expand Down
10 changes: 6 additions & 4 deletions server/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComarkContent> | 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 } }),
Expand Down Expand Up @@ -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<ComarkContent> {
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
Expand Down
62 changes: 37 additions & 25 deletions server/utils/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,62 +39,74 @@ 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
* unauthenticated way to burn the token's rate limit. Off by default because the production branch
* 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<string> {
export async function resolveContentSha(
branch: string,
contentDir: string,
opts: { cacheMisses?: boolean; refresh?: boolean } = {}
): Promise<string> {
if (import.meta.dev) return branch

const cached = await refStorage.getItem<string>(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<string>(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<Array<{ sha: string }>>(`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<void> {
await refStorage.setItem(refKey(branch), sha)
await refStorage.setItem(key, sha)
return sha
}

export interface PageCommit {
Expand Down
10 changes: 5 additions & 5 deletions server/utils/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<source>:<path>` 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 `<source>:<path>` 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'

/**
Expand Down
43 changes: 43 additions & 0 deletions test/github.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
4 changes: 4 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,3 +43,5 @@ globals.createError = (input: { statusCode?: number; statusMessage?: string; mes
error.statusCode = input.statusCode
return error
}

globals.refCacheDriver = () => memoryDriver()
Loading