diff --git a/.env.example b/.env.example index 174e80e..e7a192b 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,24 @@ SITES_REDIS_URL= # separated (default: cdn.medialit.cloud,images.unsplash.com). Build time: # rebuild frontlit-sites after changing it. IMAGE_REMOTE_HOSTS= + +# --- Optional edge cache / CDN (docs/site-delivery.md §5) ------------------- +# none (default): FrontLit's own cache only. ttl: any CDN or proxy in front +# may keep pages EDGE_CACHE_TTL seconds. purge: pages stay at the edge until +# frontlit-api purges them (needs EDGE_PURGE_* below). +EDGE_CACHE_MODE=none +EDGE_CACHE_TTL=60 +# Purge adapter used by frontlit-api in purge mode: http (signed POST to +# EDGE_PURGE_URL). deploy/edge-cache sets the URL itself and only needs +# EDGE_PURGE_SECRET. +EDGE_PURGE_ADAPTER=http +EDGE_PURGE_URL= +EDGE_PURGE_SECRET= +# deploy/edge-cache (Varnish) only: published port and cache size. +EDGE_PORT=8080 +VARNISH_SIZE=512M +# Optional separate host for /_next/static. Build time. +ASSET_PREFIX= INTEGRATIONS_ENCRYPTION_KEY= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/apps/api/.env.example b/apps/api/.env.example index 2bd5029..4445418 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -144,6 +144,13 @@ REVALIDATE_WEBHOOK_SECRET= # SITES_BASE_URL. SITES_REVALIDATE_URL= +# Edge cache purging, when apps/sites runs with EDGE_CACHE_MODE=purge +# (docs/site-delivery.md §5, deploy/edge-cache). The http adapter POSTs +# { teamId, tags } signed with EDGE_PURGE_SECRET to EDGE_PURGE_URL. +EDGE_PURGE_ADAPTER=http +EDGE_PURGE_URL= +EDGE_PURGE_SECRET= + # AI providers and credentials are configured per team in Settings → AI. # Documentation GitHub App. See docs/docs-management.md (operations runbook). diff --git a/apps/api/src/documentation/projections.ts b/apps/api/src/documentation/projections.ts index 10ad524..d573251 100644 --- a/apps/api/src/documentation/projections.ts +++ b/apps/api/src/documentation/projections.ts @@ -38,5 +38,6 @@ export function publicPurge(row: any) { expiresAt, createdAt, completedAt, + deliveredAt: row.deliveredAt ?? null, }); } diff --git a/apps/api/src/documentation/publications.ts b/apps/api/src/documentation/publications.ts index 0b4d283..345e456 100644 --- a/apps/api/src/documentation/publications.ts +++ b/apps/api/src/documentation/publications.ts @@ -1,5 +1,6 @@ import { siteOriginForTeam } from "../utils/site-url"; import { wakeDocumentationWorker } from "./worker-wake"; +import { deliverEdgePurge, edgeDestinations, NotYetDue } from "../services/edge-purge"; import { activateGitRelease } from "../pages/git-import"; import { checkGitPublicationHead, @@ -89,8 +90,10 @@ async function event( policyEpoch: settings.policyEpoch, type, payload, + // The edge is purged after the sites acknowledge; a sensitive-data + // purge is complete only once every destination has. destinations: process.env.REVALIDATE_WEBHOOK_SECRET - ? ["local", "sites"] + ? ["local", "sites", ...edgeDestinations()] : ["local"], }); } @@ -1416,9 +1419,18 @@ export async function applyDocumentationPurge( return updated; } export async function getPurge(teamId: string, purgeId: string) { - return db.query.documentationPurges.findFirst({ + const purge = await db.query.documentationPurges.findFirst({ where: and(eq(purges.teamId, teamId), eq(purges.purgeId, purgeId)), }); + if (!purge) return purge; + const delivery = await db.query.documentationDeliveryOutbox.findFirst({ + where: and( + eq(outbox.teamId, teamId), + eq(outbox.type, "purge"), + sql`${outbox.payload}->>'purgeId' = ${purgeId}`, + ), + }); + return { ...purge, deliveredAt: delivery?.completedAt ?? null }; } export async function deliverDocumentationEvent( @@ -1426,6 +1438,15 @@ export async function deliverDocumentationEvent( destination: string, event: typeof outbox.$inferSelect, ) => Promise = async (destination, event) => { + if (destination === "edge" || destination === "edge-repeat") { + const { findTeamById } = await import("../team/queries.js"); + const team = await findTeamById(event.teamId); + if (!team) return; + return deliverEdgePurge(destination, event, team.teamId, [ + `docs:${team.teamId}`, + `sitemap:${team.teamId}`, + ]); + } if (destination === "sites") { const { findTeamById } = await import("../team/queries.js"); const { signWebhookPayload } = await import("../services/webhook-signing.js"); @@ -1512,7 +1533,14 @@ export async function deliverDocumentationEvent( .update(outbox) .set({ completedAt: new Date(), leaseToken: null, leaseExpiresAt: null }) .where(and(eq(outbox.eventId, claimed.eventId), eq(outbox.leaseToken, token))); - } catch { + } catch (error) { + if (error instanceof NotYetDue) { + await db + .update(outbox) + .set({ nextAttemptAt: error.at, leaseToken: null, leaseExpiresAt: null }) + .where(and(eq(outbox.eventId, claimed.eventId), eq(outbox.leaseToken, token))); + return true; + } await db .update(outbox) .set({ diff --git a/apps/api/src/services/edge-purge.test.ts b/apps/api/src/services/edge-purge.test.ts new file mode 100644 index 0000000..cef2e84 --- /dev/null +++ b/apps/api/src/services/edge-purge.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { signWebhookPayload } from "./webhook-signing"; +import { + deliverEdgePurge, + edgeDestinations, + edgePurgeAdapter, + NotYetDue, +} from "./edge-purge"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + delete process.env.EDGE_PURGE_URL; + delete process.env.EDGE_PURGE_SECRET; + delete process.env.EDGE_PURGE_ADAPTER; +}); + +function configure() { + process.env.EDGE_PURGE_URL = "http://purge.test/purge"; + process.env.EDGE_PURGE_SECRET = "edge-secret"; +} + +describe("edge purge", () => { + it("is off until a purge URL and secret are set", () => { + expect(edgePurgeAdapter()).toBeNull(); + expect(edgeDestinations()).toEqual([]); + configure(); + expect(edgeDestinations()).toEqual(["edge", "edge-repeat"]); + }); + + it("records purges for a broken configuration and fails their delivery", async () => { + process.env.EDGE_PURGE_ADAPTER = "nope"; + expect(edgeDestinations()).toEqual(["edge", "edge-repeat"]); + expect(() => edgePurgeAdapter()).toThrow(/Unknown EDGE_PURGE_ADAPTER/); + await expect( + deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", ["docs:team_a"]), + ).rejects.toThrow(/Unknown EDGE_PURGE_ADAPTER/); + }); + + it("posts the tags signed with the edge secret", async () => { + configure(); + const fetchMock = vi.fn( + async (_url: string | URL, _init?: RequestInit) => new Response("ok"), + ); + vi.stubGlobal("fetch", fetchMock); + await deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", [ + "docs:team_a", + ]); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("http://purge.test/purge"); + const body = String(init?.body); + expect(JSON.parse(body)).toEqual({ teamId: "team_a", tags: ["docs:team_a"] }); + const headers = init?.headers as Record; + expect(headers["x-frontlit-signature"]).toBe( + signWebhookPayload("edge-secret", body, Number(headers["x-frontlit-timestamp"])), + ); + }); + + it("fails (so the outbox retries) when the receiver rejects", async () => { + configure(); + vi.stubGlobal("fetch", async () => new Response("no", { status: 502 })); + await expect( + deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", ["docs:team_a"]), + ).rejects.toThrow(/502/); + }); + + it("holds the second purge until ten seconds after the event", async () => { + configure(); + const fetchMock = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", fetchMock); + const createdAt = new Date(); + await expect( + deliverEdgePurge("edge-repeat", { createdAt }, "team_a", ["docs:team_a"]), + ).rejects.toBeInstanceOf(NotYetDue); + expect(fetchMock).not.toHaveBeenCalled(); + vi.useFakeTimers({ now: createdAt.getTime() + 10_001 }); + await deliverEdgePurge("edge-repeat", { createdAt }, "team_a", ["docs:team_a"]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/src/services/edge-purge.ts b/apps/api/src/services/edge-purge.ts new file mode 100644 index 0000000..87724e7 --- /dev/null +++ b/apps/api/src/services/edge-purge.ts @@ -0,0 +1,85 @@ +import { signWebhookPayload } from "./webhook-signing"; + +/** + * Purging an optional edge cache / CDN in front of the public sites + * (docs/site-delivery.md §5). Only used when apps/sites runs with + * `EDGE_CACHE_MODE=purge`. Tags are the same names the sites send in + * `Surrogate-Key` / `Cache-Tag`. + */ +export interface EdgePurgeAdapter { + purge(input: { teamId: string; tags: string[] }): Promise; +} + +/** Generic adapter: a signed POST of `{ teamId, tags }` to `EDGE_PURGE_URL`, + * signed with `EDGE_PURGE_SECRET` like every other FrontLit webhook + * (`x-frontlit-timestamp` / `x-frontlit-signature`). Self-hosters connect + * any cache with a small receiver (see deploy/edge-cache). */ +export function httpPurgeAdapter(url: string, secret: string): EdgePurgeAdapter { + return { + async purge({ teamId, tags }) { + const body = JSON.stringify({ teamId, tags }); + const timestamp = Date.now(); + const response = await fetch(url, { + method: "POST", + body, + headers: { + "content-type": "application/json", + "x-frontlit-timestamp": String(timestamp), + "x-frontlit-signature": signWebhookPayload(secret, body, timestamp), + }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error(`Edge purge rejected (${response.status})`); + }, + }; +} + +/** The configured adapter, or null when no edge cache needs purging. */ +export function edgePurgeAdapter(env = process.env): EdgePurgeAdapter | null { + const adapter = env.EDGE_PURGE_ADAPTER || "http"; + if (adapter === "http") { + if (!env.EDGE_PURGE_URL || !env.EDGE_PURGE_SECRET) return null; + return httpPurgeAdapter(env.EDGE_PURGE_URL, env.EDGE_PURGE_SECRET); + } + throw new Error(`Unknown EDGE_PURGE_ADAPTER "${adapter}"`); +} + +/** + * Outbox destinations for an edge purge, delivered after `sites` (Layer 1) + * acknowledges, so the edge can't refill from a stale origin. The second + * purge, ten seconds later, catches pages whose render started before the + * origin was invalidated and was stored at the edge after the first purge. + */ +export const EDGE_DESTINATIONS = ["edge", "edge-repeat"] as const; +export const EDGE_REPEAT_DELAY_MS = 10_000; + +/** Edge destinations whenever edge purging is configured at all. A broken + * configuration then fails delivery (retried and logged) rather than + * dropping purges or failing the publish itself. */ +export function edgeDestinations(env = process.env): string[] { + return env.EDGE_PURGE_URL || env.EDGE_PURGE_ADAPTER ? [...EDGE_DESTINATIONS] : []; +} + +/** Thrown by a destination that isn't due yet; the outbox reschedules it + * without counting a failure. */ +export class NotYetDue extends Error { + constructor(readonly at: Date) { + super("Destination not due yet"); + } +} + +/** Delivers an `edge` / `edge-repeat` destination for one outbox event. */ +export async function deliverEdgePurge( + destination: string, + event: { createdAt: Date }, + teamId: string, + tags: string[], +): Promise { + if (destination === "edge-repeat") { + const due = event.createdAt.getTime() + EDGE_REPEAT_DELAY_MS; + if (Date.now() < due) throw new NotYetDue(new Date(due)); + } + const adapter = edgePurgeAdapter(); + if (!adapter) throw new Error("No edge purge adapter configured"); + await adapter.purge({ teamId, tags }); +} diff --git a/apps/api/src/services/site-cache.ts b/apps/api/src/services/site-cache.ts index 3d2a8da..c231c0d 100644 --- a/apps/api/src/services/site-cache.ts +++ b/apps/api/src/services/site-cache.ts @@ -4,6 +4,12 @@ import { db } from "../db/client"; import { siteCacheOutbox, teams } from "../db/schema"; import { logger } from "./log"; import { signWebhookPayload } from "./webhook-signing"; +import { + deliverEdgePurge, + edgeDestinations, + edgePurgeAdapter, + NotYetDue, +} from "./edge-purge"; /** * Public-site cache invalidation (docs/site-delivery.md §4.5). apps/sites @@ -27,9 +33,10 @@ export const siteTags = { type Executor = Pick; type TagBuilder = (publicTeamId: string) => string[]; -/** Where invalidations go. Unset secret = no public site to invalidate. */ +/** Where invalidations go: the sites (Layer 1), then an edge cache when + * one is configured. Unset secret = no public site to invalidate. */ function destinations(): string[] { - return process.env.REVALIDATE_WEBHOOK_SECRET ? ["sites"] : []; + return process.env.REVALIDATE_WEBHOOK_SECRET ? ["sites", ...edgeDestinations()] : []; } /** @@ -126,14 +133,17 @@ export function revalidateRequest( type OutboxRow = typeof siteCacheOutbox.$inferSelect; async function deliver(destination: string, event: OutboxRow): Promise { - if (destination !== "sites") throw new Error(`Unknown destination ${destination}`); - const secret = process.env.REVALIDATE_WEBHOOK_SECRET; - if (!secret) throw new Error("REVALIDATE_WEBHOOK_SECRET is not set"); const team = await db.query.teams.findFirst({ where: eq(teams.id, event.teamId), columns: { teamId: true }, }); if (!team) return; + if (destination === "edge" || destination === "edge-repeat") { + return deliverEdgePurge(destination, event, team.teamId, event.tags); + } + if (destination !== "sites") throw new Error(`Unknown destination ${destination}`); + const secret = process.env.REVALIDATE_WEBHOOK_SECRET; + if (!secret) throw new Error("REVALIDATE_WEBHOOK_SECRET is not set"); const { url, init } = revalidateRequest( event, team.teamId, @@ -200,6 +210,13 @@ export async function deliverSiteCacheEvent(): Promise { .set({ completedAt: new Date(), leaseToken: null, leaseExpiresAt: null }) .where(owned); } catch (error) { + if (error instanceof NotYetDue) { + await db + .update(siteCacheOutbox) + .set({ nextAttemptAt: error.at, leaseToken: null, leaseExpiresAt: null }) + .where(owned); + return true; + } logger.warn( { eventId: claimed.eventId, @@ -239,6 +256,17 @@ function scheduleDelivery() { } export function startSiteCacheWorker(intervalMs = 1000): () => void { + if (edgeDestinations().length) { + try { + if (!edgePurgeAdapter()) + throw new Error("EDGE_PURGE_URL and EDGE_PURGE_SECRET are required"); + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Edge purging is misconfigured; edge purges will retry until it is fixed", + ); + } + } let stopped = false; let lastCleanup = 0; async function tick() { diff --git a/apps/sites/.env.example b/apps/sites/.env.example index c6cec52..faa1f24 100644 --- a/apps/sites/.env.example +++ b/apps/sites/.env.example @@ -27,3 +27,11 @@ DEPLOYMENT_ID= # Image hosts resized through Next's optimizer, comma separated. Build time. # Default: cdn.medialit.cloud,images.unsplash.com IMAGE_REMOTE_HOSTS= + +# Optional HTTP cache / CDN in front of this app (docs/site-delivery.md §5): +# none (default) | ttl (edge keeps pages EDGE_CACHE_TTL seconds) | purge +# (edge keeps pages until apps/api purges their tags; set EDGE_PURGE_* there). +EDGE_CACHE_MODE=none +EDGE_CACHE_TTL=60 +# Optional separate host for /_next/static. Build time. +ASSET_PREFIX= diff --git a/apps/sites/Dockerfile b/apps/sites/Dockerfile index 7476091..8455415 100644 --- a/apps/sites/Dockerfile +++ b/apps/sites/Dockerfile @@ -24,7 +24,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 # Read by `next build` (next.config.mjs), not at runtime. ARG IMAGE_REMOTE_HOSTS ARG DEPLOYMENT_ID -ENV IMAGE_REMOTE_HOSTS=$IMAGE_REMOTE_HOSTS DEPLOYMENT_ID=$DEPLOYMENT_ID +ARG ASSET_PREFIX +ENV IMAGE_REMOTE_HOSTS=$IMAGE_REMOTE_HOSTS DEPLOYMENT_ID=$DEPLOYMENT_ID ASSET_PREFIX=$ASSET_PREFIX RUN bun run --filter @frontlit/api-contract build RUN bun run --filter @frontlit/text-editor build diff --git a/apps/sites/lib/edge-cache.test.ts b/apps/sites/lib/edge-cache.test.ts new file mode 100644 index 0000000..7f53a2b --- /dev/null +++ b/apps/sites/lib/edge-cache.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { edgeCacheHeaders, edgeCacheMode, edgeTags } from "./edge-cache"; + +describe("edgeTags", () => { + it("tags pages by slug, with the shell every route renders", () => { + expect(edgeTags("team_a", "/")).toEqual([ + "team:team_a", + "shell:team_a", + "page:team_a:", + ]); + expect(edgeTags("team_a", "/pricing/")).toEqual([ + "team:team_a", + "shell:team_a", + "page:team_a:pricing", + ]); + }); + + it("tags content routes by their collection, and indexes by their page", () => { + expect(edgeTags("team_a", "/docs/guides/intro")).toEqual([ + "team:team_a", + "shell:team_a", + "docs:team_a", + ]); + expect(edgeTags("team_a", "/blog")).toEqual([ + "team:team_a", + "shell:team_a", + "blog:team_a", + "page:team_a:blog", + ]); + expect(edgeTags("team_a", "/changelog/rss.xml")).toContain("changelog:team_a"); + expect(edgeTags("team_a", "/sitemap.xml")).toContain("sitemap:team_a"); + expect(edgeTags("team_a", "/robots.txt")).toEqual(["team:team_a", "shell:team_a"]); + }); +}); + +describe("edgeCacheHeaders", () => { + it("leaves Next's headers alone by default", () => { + expect(edgeCacheMode(undefined)).toBe("none"); + expect(edgeCacheHeaders("team_a", "/", "none")).toBeNull(); + }); + + it("caches for the TTL at the edge and never in the browser", () => { + expect(edgeCacheHeaders("team_a", "/", "ttl", 120)).toEqual({ + "Cache-Control": "public, max-age=0, must-revalidate", + "CDN-Cache-Control": + "public, s-maxage=120, stale-while-revalidate=86400, stale-if-error=604800", + "Surrogate-Control": + "max-age=120, stale-while-revalidate=86400, stale-if-error=604800", + }); + }); + + it("caches until purged and names the tags to purge", () => { + const headers = edgeCacheHeaders("team_a", "/docs/intro", "purge")!; + expect(headers["CDN-Cache-Control"]).toContain("s-maxage=31536000"); + expect(headers["Surrogate-Key"]).toBe("team:team_a shell:team_a docs:team_a"); + expect(headers["Cache-Tag"]).toBe("team:team_a,shell:team_a,docs:team_a"); + }); + + it("keeps per-visitor routes out of every shared cache, in every mode", () => { + for (const mode of ["none", "ttl", "purge"] as const) { + for (const path of [ + "/help/tickets/tkt_1", + "/_preview/rel_1/pricing", + "/embed/chat", + ]) { + expect(edgeCacheHeaders("team_a", path, mode)).toEqual({ + "Cache-Control": "private, no-store", + }); + } + } + expect(edgeCacheHeaders("team_a", "/embed/chat.js", "purge")).not.toBeNull(); + expect( + edgeCacheHeaders("team_a", "/embed/chat.js", "purge")!["Cache-Control"], + ).toContain("public"); + }); + + it("never stores a response whose rsc header and _rsc parameter disagree", () => { + const privateOnly = { "Cache-Control": "private, no-store" }; + for (const mode of ["none", "ttl", "purge"] as const) { + expect( + edgeCacheHeaders("team_a", "/docs/x", mode, 60, { + rscHeader: true, + rscParam: false, + }), + ).toEqual(privateOnly); + expect( + edgeCacheHeaders("team_a", "/docs/x", mode, 60, { + rscHeader: false, + rscParam: true, + }), + ).toEqual(privateOnly); + expect( + edgeCacheHeaders("team_a", "/docs/x", mode, 60, { + rscHeader: true, + rscParam: true, + }), + ).not.toEqual(privateOnly); + } + }); +}); diff --git a/apps/sites/lib/edge-cache.ts b/apps/sites/lib/edge-cache.ts new file mode 100644 index 0000000..95bcbd6 --- /dev/null +++ b/apps/sites/lib/edge-cache.ts @@ -0,0 +1,91 @@ +/** + * Response headers for an optional HTTP cache or CDN in front of the public + * sites (docs/site-delivery.md §5). Standard headers only, so any cache can + * use them; FrontLit's own cache (Layer 1) stays the source of truth. + * + * - `none` (default): Next's own headers. + * - `ttl`: the edge keeps a page for `EDGE_CACHE_TTL` seconds; no purging. + * - `purge`: the edge keeps pages until FrontLit purges their tags. + * + * Browsers always revalidate (`max-age=0`), so a publish is never stuck in + * a visitor's cache. + */ +export type EdgeCacheMode = "none" | "ttl" | "purge"; + +export function edgeCacheMode(value = process.env.EDGE_CACHE_MODE): EdgeCacheMode { + return value === "ttl" || value === "purge" ? value : "none"; +} + +/** Per-visitor or per-request responses: never stored by a shared cache. */ +const PRIVATE_PATHS = [/^\/help\/tickets\//, /^\/_preview\//, /^\/embed\/chat\/?$/]; + +export function isPrivatePath(pathname: string): boolean { + return PRIVATE_PATHS.some((pattern) => pattern.test(pathname)); +} + +/** Edge tags for a public path: the Layer 1 tag names (§4.3), derived from + * the URL so the edge never needs page IDs. */ +export function edgeTags(teamId: string, pathname: string): string[] { + const path = pathname.replace(/\/+$/, "") || "/"; + // Every route renders settings (title, theme, canonical host) or chrome. + const tags = [`team:${teamId}`, `shell:${teamId}`]; + const [first = ""] = path.slice(1).split("/"); + if (first === "docs") { + tags.push(`docs:${teamId}`); + if (path === "/docs") tags.push(`page:${teamId}:docs`); + } else if (first === "blog") { + tags.push(`blog:${teamId}`); + if (path === "/blog") tags.push(`page:${teamId}:blog`); + } else if (first === "changelog") { + tags.push(`changelog:${teamId}`); + if (path === "/changelog") tags.push(`page:${teamId}:changelog`); + } else if (path === "/sitemap.xml") { + tags.push(`sitemap:${teamId}`); + } else if ( + !["robots.txt", "favicon.ico", "opengraph-image", "embed"].includes(first) + ) { + tags.push(`page:${teamId}:${path.slice(1)}`); + } + return tags; +} + +const SWR = "stale-while-revalidate=86400, stale-if-error=604800"; + +/** + * Next.js tells HTML and RSC responses apart by the `rsc` request header, + * and adds an `_rsc` query parameter so caches that ignore `Vary` still key + * them apart. A request where the two disagree (an `rsc` header without + * `_rsc` gets a redirect; `_rsc` without the header gets HTML) must never + * be stored: behind such a cache, one crafted request would otherwise + * poison the page for every visitor. + */ +export function isMixedRscRequest(request: { rscHeader: boolean; rscParam: boolean }) { + return request.rscHeader !== request.rscParam; +} + +/** Headers to add to a rewritten public response, or null to leave Next's. */ +export function edgeCacheHeaders( + teamId: string, + pathname: string, + mode: EdgeCacheMode = edgeCacheMode(), + ttl = Number(process.env.EDGE_CACHE_TTL) || 60, + rsc = { rscHeader: false, rscParam: false }, +): Record | null { + if (isPrivatePath(pathname)) return { "Cache-Control": "private, no-store" }; + if (isMixedRscRequest(rsc)) return { "Cache-Control": "private, no-store" }; + if (mode === "none") return null; + const maxAge = mode === "ttl" ? ttl : 31_536_000; + const headers: Record = { + "Cache-Control": "public, max-age=0, must-revalidate", + // CDN-Cache-Control (RFC 9213) and Surrogate-Control cover the common + // CDNs and Varnish; both only apply to shared caches. + "CDN-Cache-Control": `public, s-maxage=${maxAge}, ${SWR}`, + "Surrogate-Control": `max-age=${maxAge}, ${SWR}`, + }; + if (mode === "purge") { + const tags = edgeTags(teamId, pathname); + headers["Surrogate-Key"] = tags.join(" "); + headers["Cache-Tag"] = tags.join(","); + } + return headers; +} diff --git a/apps/sites/next.config.mjs b/apps/sites/next.config.mjs index 93235ec..fedae94 100644 --- a/apps/sites/next.config.mjs +++ b/apps/sites/next.config.mjs @@ -15,6 +15,10 @@ const nextConfig = { outputFileTracingRoot: monorepoRoot, outputFileTracingIncludes: { "/**": ["./cache-handler.cjs"] }, cacheComponents: true, + // proxy.ts must see the `rsc` header and `_rsc` parameter that tell HTML + // and RSC requests apart, to keep mismatched ones out of shared caches + // (lib/edge-cache.ts). Next strips both before the proxy by default. + skipProxyUrlNormalize: true, cacheHandlers: { default: cacheHandler, remote: cacheHandler }, cacheLife: { // Public content is invalidated by tag on publish. The hour only bounds @@ -27,6 +31,9 @@ const nextConfig = { // Version-skew protection. Read at build time: replicas of one image // already agree, so this matters only for separately built replicas. deploymentId: process.env.DEPLOYMENT_ID || undefined, + // Optional separate host for /_next/static (content-hashed, immutable). + // Read at build time. + assetPrefix: process.env.ASSET_PREFIX || undefined, images: { remotePatterns: imageRemoteHosts.map((hostname) => ({ protocol: "https", diff --git a/apps/sites/proxy.ts b/apps/sites/proxy.ts index 8c4c271..4c14262 100644 --- a/apps/sites/proxy.ts +++ b/apps/sites/proxy.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { resolveHost } from "./lib/api"; import { decideHost, lookupHost, normalizeHost } from "./lib/host"; +import { edgeCacheHeaders } from "./lib/edge-cache"; export async function proxy(request: NextRequest) { // Direct access to the internal /sites rewrite segment is never allowed. @@ -87,6 +88,19 @@ export async function proxy(request: NextRequest) { // feeds and images too, and keeps the cached HTML host-independent. if (resolved!.hideFromSearch || host !== resolved!.canonicalHost) response.headers.set("X-Robots-Tag", "noindex"); + // Optional HTTP cache / CDN in front (EDGE_CACHE_MODE, §5). + const edge = edgeCacheHeaders( + decision.teamId, + request.nextUrl.pathname, + undefined, + undefined, + { + rscHeader: request.headers.get("rsc") === "1", + rscParam: request.nextUrl.searchParams.has("_rsc"), + }, + ); + for (const [name, value] of Object.entries(edge ?? {})) + response.headers.set(name, value); return response; } diff --git a/deploy/edge-cache/README.md b/deploy/edge-cache/README.md new file mode 100644 index 0000000..f36bbe4 --- /dev/null +++ b/deploy/edge-cache/README.md @@ -0,0 +1,74 @@ +# Optional edge cache (Varnish) + +FrontLit caches public pages itself and refreshes them on publish. You +don't need this. Add it when you want a second cache in front of +`frontlit-sites`: to absorb traffic spikes, or to serve pages while the +origin is down. Design: [`docs/site-delivery.md`](../../docs/site-delivery.md) §5. + +``` +visitor → Caddy (TLS, unchanged) → Varnish → frontlit-sites + ↑ + frontlit-api → edge-purge (purge-receiver.mjs) +``` + +## Run it + +1. Set a secret shared by the API and the purge receiver in `.env`, and + optionally the published port and cache size: + + ``` + EDGE_PURGE_SECRET= + EDGE_PORT=8080 # default + VARNISH_SIZE=512M # default + ``` + +2. Start the stack with the override file: + + ```sh + docker compose -f docker-compose.yml -f deploy/edge-cache/docker-compose.edge.yml up -d + ``` + + It sets `EDGE_CACHE_MODE=purge` on `frontlit-sites`, points + `frontlit-api` at the purge receiver, and starts Varnish on + `${EDGE_PORT:-8080}`. + +3. Point Caddy at Varnish instead of the sites container, keeping on-demand + TLS as it is: + + ``` + https:// { + tls { on_demand } + reverse_proxy varnish:80 + } + ``` + +Check it: `curl -sI https:/// | grep -i x-edge-cache` shows +`MISS`, then `HIT`. + +## What it does + +- **Caches** each public page under host + full URL. The query string is + part of the key, which keeps Next.js RSC payloads (`?_rsc=…`) apart from + the HTML. Cookies are ignored for public pages. +- **Never caches** ticket threads, pull request previews, the chat embed, + non-GET requests, 5xx responses or anything FrontLit marks private. +- **Purges by tag.** Each page carries its FrontLit tags in `Surrogate-Key` + (`team:…`, `shell:…`, `page:…`, `docs:…`, `blog:…`, `changelog:…`, + `sitemap:…`). When content changes, the API purges the same tags it + expires in its own cache. It purges once after the origin acknowledged and + again ten seconds later. A failed purge is retried until it succeeds. +- **Serves stale on errors** for up to 7 days (`grace`). + +## Without a purge integration + +Set `EDGE_CACHE_MODE=ttl` (and optionally `EDGE_CACHE_TTL`, default 60 +seconds) instead of `purge`. Any CDN or caching proxy then keeps pages for +at most that long; no receiver or API settings are needed. + +## Another cache or CDN + +The API's `http` purge adapter sends a signed `POST { teamId, tags }`; +see `purge-receiver.mjs` for verifying it. Anything that can purge by tag +(Fastly surrogate keys, Cloudflare cache tags, nginx with a purge module) +works with a receiver of a few lines. The cache must include the query +string in its key. diff --git a/deploy/edge-cache/default.vcl b/deploy/edge-cache/default.vcl new file mode 100644 index 0000000..6b18547 --- /dev/null +++ b/deploy/edge-cache/default.vcl @@ -0,0 +1,92 @@ +vcl 4.1; + +# Optional edge cache for FrontLit public sites (docs/site-delivery.md §5). +# +# visitor → Caddy (TLS) → Varnish (this file) → frontlit-sites +# +# Run apps/sites with EDGE_CACHE_MODE=purge (or ttl). Pages are kept for as +# long as FrontLit's CDN-Cache-Control says and dropped by tag when +# FrontLit purges them through purge-receiver.mjs (xkey). Remove this layer +# and everything still works: FrontLit caches pages itself. + +import std; +import xkey; + +backend sites { + .host = "frontlit-sites"; + .port = "3002"; +} + +# Only the purge receiver may purge. +acl purgers { + "localhost"; + "127.0.0.1"; + "::1"; + "172.16.0.0"/12; + "10.0.0.0"/8; + "192.168.0.0"/16; +} + +sub vcl_recv { + if (req.method == "PURGE") { + if (client.ip !~ purgers) { + return (synth(403, "Forbidden")); + } + set req.http.n-gone = xkey.purge(req.http.xkey-purge); + return (synth(200, "Purged " + req.http.n-gone)); + } + + # Per-visitor routes and anything that isn't a plain read go straight + # through. FrontLit marks them private anyway. + if (req.method != "GET" && req.method != "HEAD") { + return (pass); + } + if (req.url ~ "^/(help/tickets/|_preview/|embed/chat(\?|$)|api/)") { + return (pass); + } + + # Public pages are the same for every visitor; cookies (analytics, the + # color-mode script) must not split the cache. + unset req.http.Cookie; + unset req.http.Authorization; + + # The cache key is host + full URL, query string included: client-side + # navigations fetch RSC payloads from the same path with `_rsc=…`, and + # they must never be confused with the HTML. + return (hash); +} + +sub vcl_backend_response { + # Index the page by its FrontLit tags for xkey purges. + if (beresp.http.Surrogate-Key) { + set beresp.http.xkey = beresp.http.Surrogate-Key; + } + + if (beresp.status >= 500 || beresp.http.Set-Cookie || + beresp.http.Cache-Control ~ "(private|no-store)" || + beresp.http.CDN-Cache-Control !~ "s-maxage=\d+") { + # Errors and private responses are never stored; a stored copy is + # served instead while the origin fails (grace below). + set beresp.uncacheable = true; + set beresp.ttl = 0s; + return (deliver); + } + + # CDN-Cache-Control (RFC 9213) is meant for this cache; the browser's + # Cache-Control (max-age=0) stays untouched for the visitor. + set beresp.ttl = std.duration( + regsub(beresp.http.CDN-Cache-Control, ".*s-maxage=(\d+).*", "\1s"), 0s); + # Past the TTL, a stored copy is served while Varnish refetches it, or + # while the origin is failing (stale-while-revalidate / stale-if-error). + set beresp.grace = 7d; + return (deliver); +} + +sub vcl_deliver { + unset resp.http.xkey; + if (obj.hits > 0) { + set resp.http.X-Edge-Cache = "HIT"; + } else { + set resp.http.X-Edge-Cache = "MISS"; + } +} diff --git a/deploy/edge-cache/docker-compose.edge.yml b/deploy/edge-cache/docker-compose.edge.yml new file mode 100644 index 0000000..a9e274c --- /dev/null +++ b/deploy/edge-cache/docker-compose.edge.yml @@ -0,0 +1,41 @@ +# Optional edge cache in front of frontlit-sites (docs/site-delivery.md §5). +# Not part of the default stack. Use it together with docker-compose.yml: +# +# docker compose -f docker-compose.yml -f deploy/edge-cache/docker-compose.edge.yml up +# +# and point Caddy (or your load balancer) at varnish:80 instead of +# frontlit-sites:3002. TLS stays where it is. +services: + frontlit-sites: + environment: + EDGE_CACHE_MODE: purge + + frontlit-api: + environment: + EDGE_PURGE_ADAPTER: http + EDGE_PURGE_URL: http://edge-purge:8090 + EDGE_PURGE_SECRET: ${EDGE_PURGE_SECRET:?Set EDGE_PURGE_SECRET} + + varnish: + image: varnish:7.6 + restart: unless-stopped + environment: + VARNISH_SIZE: ${VARNISH_SIZE:-512M} + volumes: + - ./deploy/edge-cache/default.vcl:/etc/varnish/default.vcl:ro + depends_on: + - frontlit-sites + ports: + - "${EDGE_PORT:-8080}:80" + + edge-purge: + image: node:22-alpine + restart: unless-stopped + environment: + EDGE_PURGE_SECRET: ${EDGE_PURGE_SECRET:?Set EDGE_PURGE_SECRET} + VARNISH_URL: http://varnish:80 + volumes: + - ./deploy/edge-cache/purge-receiver.mjs:/app/purge-receiver.mjs:ro + command: ["node", "/app/purge-receiver.mjs"] + depends_on: + - varnish diff --git a/deploy/edge-cache/purge-receiver.mjs b/deploy/edge-cache/purge-receiver.mjs new file mode 100644 index 0000000..60821d3 --- /dev/null +++ b/deploy/edge-cache/purge-receiver.mjs @@ -0,0 +1,48 @@ +// Receives FrontLit's signed edge purges (EDGE_PURGE_ADAPTER=http) and +// purges the tagged pages from Varnish with xkey. +// +// EDGE_PURGE_SECRET same value as on frontlit-api +// VARNISH_URL e.g. http://varnish:80 +// PORT default 8090 +// +// FrontLit sends POST { teamId, tags } with x-frontlit-timestamp and +// x-frontlit-signature = hex(HMAC-SHA256(secret, `${timestamp}.${body}`)). +import { createHmac, timingSafeEqual } from "node:crypto"; +import http from "node:http"; + +const secret = process.env.EDGE_PURGE_SECRET; +const varnish = process.env.VARNISH_URL ?? "http://varnish:80"; +if (!secret) throw new Error("EDGE_PURGE_SECRET is required"); + +function verified(body, timestamp, signature) { + if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > 300_000) + return false; + const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest(); + const given = Buffer.from(signature ?? "", "hex"); + return given.length === expected.length && timingSafeEqual(given, expected); +} + +http + .createServer(async (req, res) => { + if (req.method !== "POST") return res.writeHead(405).end(); + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 65_536) return res.writeHead(413).end(); + } + const timestamp = Number(req.headers["x-frontlit-timestamp"]); + if (!verified(body, timestamp, req.headers["x-frontlit-signature"])) { + return res.writeHead(401).end("Invalid signature"); + } + const { tags } = JSON.parse(body); + if (!Array.isArray(tags) || !tags.every((tag) => /^[^\s]+$/.test(tag))) { + return res.writeHead(400).end("Invalid tags"); + } + const purge = await fetch(varnish, { + method: "PURGE", + headers: { "xkey-purge": tags.join(" ") }, + }).catch((error) => ({ ok: false, status: 502, statusText: String(error) })); + // A non-2xx answer makes FrontLit retry: a purge must not be lost. + res.writeHead(purge.ok ? 200 : 502).end(purge.ok ? "Purged" : purge.statusText); + }) + .listen(Number(process.env.PORT ?? 8090)); diff --git a/docker-compose.yml b/docker-compose.yml index 36d85b8..bf02ae3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,6 +188,7 @@ services: dockerfile: apps/sites/Dockerfile args: IMAGE_REMOTE_HOSTS: ${IMAGE_REMOTE_HOSTS:-} + ASSET_PREFIX: ${ASSET_PREFIX:-} restart: unless-stopped environment: NODE_ENV: production @@ -196,6 +197,9 @@ services: REVALIDATE_WEBHOOK_SECRET: ${REVALIDATE_WEBHOOK_SECRET:-} # Optional: shared cache for several sites replicas. REDIS_URL: ${SITES_REDIS_URL:-} + # Optional HTTP cache / CDN in front (docs/site-delivery.md §5). + EDGE_CACHE_MODE: ${EDGE_CACHE_MODE:-none} + EDGE_CACHE_TTL: ${EDGE_CACHE_TTL:-60} depends_on: frontlit-api: condition: service_healthy diff --git a/docs/site-delivery.md b/docs/site-delivery.md index f753c9e..7e42506 100644 --- a/docs/site-delivery.md +++ b/docs/site-delivery.md @@ -243,22 +243,25 @@ The mode is chosen with `EDGE_CACHE_MODE`. | `ttl` | `CDN-Cache-Control: public, s-maxage=$EDGE_CACHE_TTL, stale-while-revalidate=86400, stale-if-error=604800` | none; staleness capped at the TTL | | `purge` | `CDN-Cache-Control: public, s-maxage=31536000, stale-while-revalidate=86400, stale-if-error=604800`, plus `Surrogate-Key` and `Cache-Tag` | outbox → purge adapter | -In every mode the browser gets `Cache-Control: public, max-age=0, -must-revalidate`. Uncached routes (§4.6) always send `private, no-store`, -so they are never stored at the edge. +In `ttl` and `purge` mode the browser gets `Cache-Control: public, +max-age=0, must-revalidate`, and `Surrogate-Control` repeats the edge +lifetime for caches that read it instead of `CDN-Cache-Control`. Uncached +routes (§4.6: `/help/tickets/*`, `/_preview/*`, `/embed/chat`) send +`private, no-store` in every mode, so they are never stored at the edge. +The headers are set in `proxy.ts` (`lib/edge-cache.ts`); Next keeps them +on the rendered response. **Edge tags** are computed in `proxy.ts` from the URL path, so the edge never needs page IDs. They use the same names as §4.3: | Path | Edge tags | | ---------------------------- | --------------------------------------------- | -| any | `team:` | -| `/docs`, `/docs/*` | `docs:` | -| `/blog`, `/blog/*` | `blog:` | -| `/changelog`, `/changelog/*` | `changelog:` | +| any | `team:`, `shell:` (every route renders settings or chrome) | +| `/docs`, `/docs/*` | `docs:`; `/docs` also `page::docs` | +| `/blog`, `/blog/*` | `blog:`; `/blog` also `page::blog` | +| `/changelog`, `/changelog/*` | `changelog:`; `/changelog` also `page::changelog` | | `/sitemap.xml` | `sitemap:` | | other page paths | `page::` | -| every HTML route | `shell:` (the shell is baked into every page) | **Purge adapters.** The outbox gains an `edge` destination, used only in `purge` mode, that calls `purge(tags[])` on the configured adapter: @@ -277,8 +280,12 @@ never needs page IDs. They use the same names as §4.3: 3. A second edge purge about 10 seconds later, to catch renders that started before step 1 and finished after step 2. +Both outboxes (`site_cache_outbox` and the documentation delivery outbox) +deliver `sites`, then `edge`, then `edge-repeat`; `edge-repeat` waits +until ten seconds after the event. A failed purge is retried with backoff. A sensitive-data purge counts as complete only after the edge destination -acknowledges it. +acknowledges it: its event completes then, and `GET …/purges/:purgeId` +reports that time as `deliveredAt`. **RSC payloads.** Client navigations fetch RSC payloads from the same paths with an `_rsc` query parameter, and Next varies those responses on request @@ -287,13 +294,22 @@ keep the `rsc`, `next-router-prefetch` and `_rsc` signals, so HTML and RSC payloads are never mixed up (Next.js `cdn-caching` guide). Every Layer 2 setup must pass a client-navigation test behind the cache. +A request whose `rsc` header and `_rsc` parameter disagree is never +cacheable (`private, no-store`, in every mode). Next answers an `rsc` +header without `_rsc` with a redirect; behind a cache that ignores `Vary`, +storing that redirect at the page's own URL would send every later visitor +to an RSC payload, and one crafted request is enough to do it. The proxy +needs both signals to tell, so `skipProxyUrlNormalize` is on (Next strips +them before the proxy by default). + **Static assets.** `/_next/static/*` is content-hashed and already `immutable`; any layer caches it. An optional `ASSET_PREFIX` serves it from a separate host. **Self-hosted recipe (optional, documented).** Caddy (TLS, unchanged) → Varnish with the `xkey` module → `apps/sites`, with a small purge receiver -wired to the `http` adapter. It is not part of the default compose stack. +wired to the `http` adapter: [`deploy/edge-cache`](../deploy/edge-cache/README.md). +It is not part of the default compose stack. ## 6. Page weight @@ -349,11 +365,12 @@ which prefetches on intent: hover, focus or touch. | `IMAGE_REMOTE_HOSTS` | `cdn.medialit.cloud,images.unsplash.com` | Build time. Image hosts the optimizer accepts (`images.remotePatterns`) | | `REVALIDATE_WEBHOOK_SECRET` | unset | Required for publish-time invalidation; unset = pages refresh every minute | | `SITES_REVALIDATE_URL` | `SITES_BASE_URL` | Where apps/api posts invalidations (set on `apps/api`) | -| `EDGE_CACHE_MODE` | `none` | `none` \| `ttl` \| `purge` (§5) | +| `EDGE_CACHE_MODE` | `none` | `none` \| `ttl` \| `purge` (§5), read at runtime | | `EDGE_CACHE_TTL` | `60` | `s-maxage` in `ttl` mode | | `EDGE_PURGE_ADAPTER` | `http` | Purge adapter in `purge` mode (set on `apps/api`) | | `EDGE_PURGE_URL`, `EDGE_PURGE_SECRET` | unset | Target of the `http` adapter (set on `apps/api`) | -| `ASSET_PREFIX` | unset | Separate host for `/_next/static` | +| `EDGE_PORT`, `VARNISH_SIZE` | `8080`, `512M` | `deploy/edge-cache` only: Varnish port and cache size | +| `ASSET_PREFIX` | unset | Separate host for `/_next/static` (build time) | | `IMAGE_LOADER` | `default` | `default` (built-in optimizer) or a loader URL template | `SITES_BASE_URL` keeps its current meaning (links in emails). @@ -402,6 +419,16 @@ API calls are counted by a proxy between sites and the API. | Second replica sees a publish (Redis) | up to 60 s | 0.33–0.48 s | | Cold replica's first 6 views (Redis) | 12 API calls | 0 | +Layer 2, Varnish recipe in front of the same origin (`purge` mode): + +| Measure | Origin (Layer 1) | Through Varnish | +| ------------------------------------------ | ------------------ | --------------- | +| Throughput, home / docs article / blog post | 140 / 181 / 242 req/s | 4,908 / 6,215 / 7,361 req/s | +| p50 under load | 36–63 ms | 1 ms | +| Publish → visible at the edge | — | 0.20–0.44 s (docs 0.42–0.54 s) | +| `ttl` mode, `EDGE_CACHE_TTL=5`, no purge | — | 5.0–5.1 s | +| Purge receiver down for 6 s | — | retried; visible at 6.7 s | + Phase 1 (#71) against the commit before it, Lighthouse: performance score 55 → 91 (home), 73 → 93 (docs), 69 → 90 (blog post); TBT 1.1–1.3 s → 0.14–0.23 s; JavaScript 643 KB → 192 KB. diff --git a/packages/api-contract/src/schemas/documentation.ts b/packages/api-contract/src/schemas/documentation.ts index 29fd909..92c8fad 100644 --- a/packages/api-contract/src/schemas/documentation.ts +++ b/packages/api-contract/src/schemas/documentation.ts @@ -265,6 +265,9 @@ export const documentationPurgeSchema = z.object({ expiresAt: z.string(), createdAt: z.string(), completedAt: z.string().nullable(), + /** When every cache (site and, if configured, edge) dropped the purged + * content. Null while delivery is pending. */ + deliveredAt: z.string().nullable(), }); export const documentationLiveIdentitySchema = z.object({ publicationId: z.string().nullable(),