/** * Makes an app's own HTML caching survive skew protection. * * Freshness is not this module's decision. How often a page changes is the * app's knowledge, it differs per route, and Nuxt already has a way to say it: * * ```ts * routeRules: { * '/gh/**': { headers: { 'cache-control': 'public, s-maxage=300' } }, * '/blog/**': { headers: { 'cache-control': 'public, s-maxage=3600' } }, * } * ``` * * What this module knows is different and singular: how long a document may * outlive the build that rendered it, which falls out of `retentionDays` and * `maxNumberOfVersions`. So it contributes two things and invents no config of * its own. * * First, it gets out of the way. `set-skew-protection-cookie` puts `__nkpv` on * every document, and shared caches will not store a response carrying * Set-Cookie, so the rule above silently does nothing today. The version cookie * is dropped from exactly the documents a shared cache was asked to keep. * * Second, it states the bound. Any route whose declared window exceeds what * retention can promise is named at build time, because a document that * outlives its chunks is the failure this module exists to prevent. * * There is no option to turn this on. Writing the route rule is the opt-in, and * a flag would only mean the rule keeps silently doing nothing until the author * finds a second thing to write. What the response asks for is the whole input, * so a route that says nothing about caching is never touched. */ export type HtmlCacheSkipReason = 'not-cacheable-method' | 'not-document' | 'request-has-cookie' | 'request-is-authenticated' | 'not-ok-status' | 'no-shared-cache-directive'; export type HtmlCacheDecision = { _tag: 'shared-cacheable'; seconds: number; } | { _tag: 'skipped'; reason: HtmlCacheSkipReason; }; export interface HtmlCacheRequest { method: string; secFetchDest: string | undefined; accept: string | undefined; cookie: string | undefined; /** Any credential that personalises a document without being a cookie. */ authorization: string | undefined; } /** * How long a shared cache may hold this response, in seconds. * * Returns null when the app did not ask for shared caching. `private` and * `no-store` are refusals. `s-maxage` beats `max-age` because it is the one * addressed to shared caches, which is what a stale document is served from. */ export declare function sharedCacheSeconds(cacheControl: unknown): number | null; /** * Whether a shared-cache window came only from `max-age`. * * `s-maxage` is read by shared caches and ignored by browsers, so writing it is * proof the author meant a CDN. `max-age` is also how you say "browser, hold * this", and nothing in the header separates that intent from CDN intent. The * response is storable by a shared cache either way, so this does not change * what the policy decides. It exists so the build can name the routes where the * author may not have meant it. */ export declare function sharedWindowFromMaxAgeAlone(cacheControl: unknown): boolean; /** * Whether this response is one a shared cache was asked to keep. * * The app's `cache-control` is the whole input. However it got there, route * rules, a nitro plugin, or a handler, the answer is the same, so an app with * its own cache layer needs no special case. */ export declare function resolveHtmlCachePolicy(request: HtmlCacheRequest, response: { status: number; cacheControl: unknown; }): HtmlCacheDecision; /** * The same Set-Cookie header with one cookie removed. * * Only the version cookie goes. An app cookie set during render is not ours to * drop, and a response still carrying one simply will not be stored, which is * the correct outcome. */ export declare function withoutCookie(setCookie: string | string[] | undefined, name: string): string[]; /** * The longest a document may safely outlive the build that rendered it. * * This is the number to clamp a route rule against: * * ```ts * const sMaxAge = Math.min(300, skewCacheCeilingSeconds(retentionDays)) * ``` * * `retentionDays` is the only bound expressible without knowing the deploy * rate. `maxNumberOfVersions` is the tighter one in practice and cannot be * checked here, which is what the build-time guidance says out loud. */ export declare function skewCacheCeilingSeconds(retentionDays: number): number; /** * A route rule as far as this check is concerned. * * Nitro accepts several spellings of "cache this", and they do not all end up * in `headers`. `swr` and `isr` are normalised into `cache` during nitro's own * config pass and only become a `cache-control` header at request time, so a * check that reads `headers` alone sees nothing and stays silent on exactly the * rules most likely to outlive retention: `swr: 31536000` is a year. */ export interface InspectableRouteRule { headers?: Record; cache?: unknown; swr?: boolean | number; isr?: unknown; } export interface OverlongRoute { route: string; seconds: number; /** Which spelling declared it, so the warning names the line to edit. */ source: 'cache-control' | 'cache' | 'swr' | 'isr'; /** The window came from `max-age` alone, so CDN intent is a guess. */ fromMaxAgeAlone: boolean; } declare const SHARED_CACHE_CONTROL_HEADERS: readonly ["cloudflare-cdn-cache-control", "cdn-cache-control", "cache-control"]; interface SharedCacheControlHeader { name: typeof SHARED_CACHE_CONTROL_HEADERS[number]; value: unknown; } /** The highest-precedence header that states a shared-cache policy. */ export declare function sharedCacheControlHeader(getHeader: (name: string) => unknown): SharedCacheControlHeader | null; /** * Route rules that ask a shared cache to keep a document. * * Reads the app's own `routeRules` rather than a second copy of them. Public * asset prefixes are excluded because their cached responses are not HTML. * Empty means the app never asked for shared HTML caching in its config. */ export declare function cachingRouteRules(routeRules: Record, publicAssetBaseURLs?: readonly string[]): OverlongRoute[]; /** Route rules whose declared window outlives the retained builds. */ export declare function overlongRouteRules(routeRules: Record, ceilingSeconds: number, publicAssetBaseURLs?: readonly string[]): OverlongRoute[]; /** * Reads the request fields the policy needs off an H3 event. * * Takes the header getter rather than importing one, so this module stays free * of a runtime dependency and the policy is testable as plain data. */ export declare function htmlCacheRequestFromEvent(event: { method?: string; node?: { req?: { method?: string; }; }; }, getHeader: (event: never, name: string) => string | undefined): HtmlCacheRequest; /** * The response's `Set-Cookie` entries, one per cookie, across h3 majors. * * This cannot go through `getResponseHeader`. On h3 v2 the response headers are * a `Headers` instance, and `Headers.get('set-cookie')` returns every cookie * joined with `", "` as one string. Filtering that string treats two cookies as * one value: measured on h3 2.0.1, a `__nkpv` set before an app's `session` * cookie produced a single joined value beginning `__nkpv=`, so a prefix filter * dropped both and deleted the app's cookie. `getSetCookie()` is the only * accessor that separates them, and it exists solely on `Headers`. * * h3 v1 appends each cookie as its own header, so the array comes back from * `getResponseHeader` directly. Feature detection rather than a version check, * because the shape is what matters. */ export declare function readSetCookies(event: { res?: { headers?: { getSetCookie?: () => string[]; }; }; }, fallback: string | string[] | number | undefined): string[]; /** * What this module can promise another module about cached documents. * * Duplicated verbatim in `@harlan-zw/nuxt-cloudflare`, which forces * `private, no-store` on HTML because a cached document can name chunks a later * deploy deleted. Retention is the answer to that, so this is how retention is * stated in a form another module can act on. * * It states a bound and never an instruction. A module should not be able to * tell another module to lower a safety rail; it can only supply the number the * other module needs to make its own decision. * * Kept to a versioned, field-only interface so the two copies cannot drift in * behaviour, only in whether they recognise a version. A consumer that reads an * unknown `v` is expected to ignore it rather than guess. */ export interface HtmlCacheCapability { v: 1; by: string; /** Seconds a document may outlive its build and still resolve every chunk. */ documentTtlCeilingSeconds: number; basis: 'observed-retained-builds' | 'retention-days' | 'none'; /** Requests for a retired build's chunks resolve instead of 404. */ assetRecovery: boolean; } /** * The capability this configuration supports, or null when it supports none. * * `assetRecovery` is the load-bearing field, not the ceiling. Retaining old * builds is what turns a stale document from a `ChunkLoadError` into a slow * page, and a consumer is expected to refuse the whole handshake without it. * It is true only when this module actually stores asset bytes: the preset * says where old builds would be served from, `bundleAssets` and `storage` * decide whether any were kept. * * The ceiling is the smaller of what time allows and what rank allows, because * whichever binds first is the one that ends the guarantee. */ export declare function htmlCacheCapability(input: { retentionDays: number; maxNumberOfVersions: number; assetRecovery: boolean; }): HtmlCacheCapability | null; export {};