// Tier 3: the CDN. Ultimate does not read from the CDN (it sits in front of us), so this // tier's job is the other two thirds of caching: emitting the headers that let the CDN hold // the response, and purging by surrogate key when a tag changes. Surrogate keys are built from // the tags in this one file, so what a purge sends cannot drift from what a response carries. import { dependentsOfKind } from './graph'; import { assertPurgeableKeys } from './purge-http'; import type { CacheTag } from './tags'; import { serializeTag } from './tags'; import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers'; export interface CacheHeaderOptions { /** Browser freshness, seconds. */ readonly maxAge?: number; /** Shared (CDN) freshness, seconds. */ readonly sMaxAge?: number; /** Serve stale for N seconds while revalidating behind the request. */ readonly staleWhileRevalidate?: number; readonly staleIfError?: number; /** Per-user responses: never shared, no surrogate keys. */ readonly visibility?: 'public' | 'private'; readonly immutable?: boolean; readonly tags?: readonly CacheTag[]; } /** * The edge's entity index: every response tagged with ANY tag of `entity` carries it. The CDN twin * of `redis.ts`'s `e:{entity}` bucket, and a separate key for the same reason — one key serving as * both "the collection tag" and "everything of this entity" is what makes a row bust over-reach. */ const entityIndexKey = (entity: string): string => `e:${entity}`; const screened = (emitter: string, keys: readonly string[]): readonly string[] => { const unique = [...new Set(keys)]; assertPurgeableKeys(emitter, unique); return unique; }; /** * What a response CARRIES: each tag's wire form, plus its entity's index key. * * A CDN cannot ask "does this key match?" — it purges by exact key — so `tagMatches`, the two-way * rule every other tier keeps, has to be built out of what is carried and what is purged. Carrying * the wire tags alone meant a collection bust (`post`) never reached a detail page keyed `post:1`, * with `errors: []`. `purgeKeysFor` below is the other half; they live together so they cannot drift. * * Screened HERE, at emission: a key carrying whitespace or a comma is split by the CDN into keys * nothing ever purges, so the response would be tagged and could never be cleared. */ export function surrogateKeys( tags: readonly CacheTag[], emitter = 'surrogateKeys', ): readonly string[] { return screened( emitter, tags.flatMap((owned) => [serializeTag(owned), entityIndexKey(owned.entity)]), ); } /** * What a bust PURGES. A COLLECTION bust matches every tag of the entity, so it purges the index — * and the bare collection key too, which responses cached before the index existed still carry. * A ROW bust matches its own tag and the bare collection tag only: `post:2` survives `post:1`. */ function purgeKeysFor(tags: readonly CacheTag[]): readonly string[] { return screened( 'cdn', tags.flatMap((requested) => requested.id === undefined ? [entityIndexKey(requested.entity), requested.entity] : [serializeTag(requested), requested.entity], ), ); } /** `Cache-Control` + `Surrogate-Key` + `Cache-Tag`, ready to spread into a `Headers` init. */ export function cacheHeaders(options: CacheHeaderOptions = {}): Record { const visibility = options.visibility ?? 'public'; const parts: string[] = [visibility]; if (visibility === 'private') { parts.push('no-store'); return { 'Cache-Control': parts.join(', ') }; } parts.push(`max-age=${options.maxAge ?? 0}`); if (options.sMaxAge !== undefined) parts.push(`s-maxage=${options.sMaxAge}`); if (options.staleWhileRevalidate !== undefined) { parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`); } if (options.staleIfError !== undefined) parts.push(`stale-if-error=${options.staleIfError}`); if (options.immutable === true) parts.push('immutable'); const headers: Record = { 'Cache-Control': parts.join(', ') }; const keys = surrogateKeys(options.tags ?? [], 'cacheHeaders'); if (keys.length > 0) { headers['Surrogate-Key'] = keys.join(' '); // Cloudflare's spelling of the same list; `@ultimat3/http`'s `applyCacheHeaders` writes both. headers['Cache-Tag'] = keys.join(','); } return headers; } export interface PurgeDriver { readonly name: string; /** Purge by surrogate key. Returns the keys the provider accepted. */ purge(keys: readonly string[]): Promise; purgeAll(): Promise; } /** Default: the CDN is optional infrastructure, so its absence must not fail a write. */ export function noopPurgeDriver(): PurgeDriver { return { name: 'noop', purge(keys) { return Promise.resolve(keys); }, purgeAll() { return Promise.resolve(); }, }; } /** * A driver that reaches no CDN, so a caller can report "purges nothing" without a name match. * * Lives here rather than beside `selectPurgeDriver`, which is where it was until 2026-08: the * `name: 'noop'` it tests for is declared one function up, and `cdnTier` — the caller that * most needs it — cannot import from `purge-env.ts` without making a cycle of the two files. */ export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop'; export interface CdnTierOptions { readonly purge?: PurgeDriver; /** * Maps a cache key to the CDN path(s) it renders, for key-level purges. Those paths are * purged **as surrogate keys** — `PurgeDriver.purge` has one currency and this is it — so a * host using this must tag those responses with their own path. */ readonly pathsForKey?: (key: string) => readonly string[]; } export function cdnTier(options: CdnTierOptions = {}): CacheTier { const driver = options.purge ?? noopPurgeDriver(); return { name: 'cdn', get(): Promise | undefined> { // The CDN is upstream of the origin; a read here would be a round trip to ourselves. return Promise.resolve(undefined); }, set(_key: string, _value: T, _options?: CacheSetOptions): Promise { // Population happens by responding with `cacheHeaders()`, never by pushing. return Promise.resolve(); }, async del(key: string): Promise { const paths = options.pathsForKey?.(key) ?? []; if (paths.length > 0) await driver.purge(paths); }, /** * `purgeKeysFor(tags)` plus every `cdn-path` the graph hangs off them — one purge, one list. * * Those paths were computed by `invalidate.ts` and reported as busted while nothing ever * purged them, so `x cache bust --json` named a path the edge still held for its whole * `s-maxage`: a partial bust reading as a clean one, which is the one failure the report * exists to prevent. They go out **as surrogate keys**, the single currency `PurgeDriver` * has — the same convention `pathsForKey` already documents, so a host registering a * `cdn-path` dependent must tag that response with its own path. */ async invalidateTags(tags: readonly CacheTag[]): Promise { // The default state of any deployment with no CDN credentials, and it has to say so: the // noop driver ECHOES its argument, so every tag came back as an accepted purge and // `recentInvalidations().busted` listed keys nothing had cleared, `errors: []`. `skipped` // is the field that already exists for this — a tier that did not run, named as one. if (isNoopPurgeDriver(driver)) { return { tier: 'cdn', keys: [], skipped: 'no purge driver configured' }; } const keys = [...new Set([...purgeKeysFor(tags), ...dependentsOfKind(tags, 'cdn-path')])]; if (keys.length === 0) return { tier: 'cdn', keys: [] }; const accepted = await driver.purge(keys); return { tier: 'cdn', keys: accepted }; }, }; }