Configurable cache headers for the static plugin (maxAge, immutable, cacheControl)#1748
Conversation
…cacheControl) The static handler passed no options to send(), hardcoding Cache-Control: public, max-age=0 — every CDN/edge request revalidated at origin with no way to mark hashed assets immutable. New options (read live from component config, like fallthrough): maxAge (seconds or duration string), immutable, and a full cacheControl override string (or false to suppress). Applies to the main file serve only; the notFound fallback keeps max-age=0, which is right for SPA index fallbacks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Reviewed; no blockers found. Prior finding resolved: |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…'' cacheControl semantics Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Companion docs PR: HarperFast/documentation#578 (static plugin cache options section; shared with #1746's directive/floor docs). — Claude (Opus 4.8) |
|
The index is usually served live from source every time or with a very small cache window, sometimes stale-while-revalidate if the CDN supports it (Azure doesn't, CloudFront and CloudFlare do). Having the top level defaults defined here will be great, if we can also have an override map for specific files. |
Ethan-Arrowood
left a comment
There was a problem hiding this comment.
LGTM. +1 to Dawson's per-file override map as a follow-up — top-level defaults are the right first step.
sent with Claude Fable 5
Follow-up to Dawson's suggestion on this PR: layer a glob->partial cache options map over the top-level maxAge/immutable/cacheControl defaults, so content-hashed assets can stay `immutable` while index.html gets a short window or stale-while-revalidate. - Entries tested in config order (first match wins); each is a partial — keys present replace the default, absent keys inherit, same cacheControl-beats-maxAge/immutable precedence as the top level. - Patterns matched (micromatch, the same engine as `files`) against the mount-relative URL path AND the served file's basename, so `index.html` also targets the directory-index (`/`) serve. - Read live per request; malformed map/entry throws loudly, consistent with the existing option validation. Option reading/validation refactored out of serveFile into resolveCacheOptions(scope, urlKey, basename). Integration suite gains a 6th case + an index.html fixture proving partial-merge, basename match on `/`, and full-string override precedence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@dawsontoth Added the per-file override map you suggested (and @Ethan-Arrowood +1'd) directly to this PR — new static:
files: 'web/**'
maxAge: 1y
immutable: true # hashed assets stay immutable
cacheOverrides:
'index.html': { cacheControl: 'public, max-age=0, stale-while-revalidate=60' }
'*.css': { maxAge: 60 } # partial — immutable inherited from top level
Read live per request like the rest of the plugin. Integration suite gained a 6th case proving the layering; docs update to follow on documentation#578. Your — Claude (Opus 4.8) with Kris |
| * ```yaml | ||
| * static: | ||
| * files: 'web/**' | ||
| * maxAge: 1y |
There was a problem hiding this comment.
1y is not a recognised suffix in convertToMS — it silently produces max-age=1.
convertToMS (utility/common_utils.ts) handles 'd'/'D', 'h'/'H', 'm', 'M' (month) — but has no 'y' case. When YAML parses maxAge: 1y it delivers the string "1y". parseFloat("1y") is 1, the switch falls through without a multiplier, and convertToMS returns 1 × 1000 = 1000 ms. send then emits Cache-Control: public, max-age=1 — one second, not one year.
Because parseFloat("1y") is a finite number (not NaN), the NaN guard below (Number.isNaN(maxAgeMs)) doesn't catch it. No error is raised; the setting silently misbehaves.
The same value appears in the cacheOverrides integration test (case 6), but every file in that fixture has an override that takes precedence over the top-level maxAge, so the test passes without ever exercising the top-level 1y value.
Suggested fix: replace 1y in this example (and the test YAML at integrationTests/components/static-cache-headers.test.ts:108) with a supported format such as 365d or 31536000. Alternatively, add a 'y' case to convertToMS.
| * maxAge: 1y | |
| * maxAge: 365d |
The static plugin's documented `maxAge: 1y` silently resolved to 1 second:
convertToMS had `M` (30-day month) but no year unit, so parseFloat('1y')
matched no case and returned 1s. Add `y`/`Y` = 365 days, alongside a note
on the case-sensitive M/m distinction, and unit coverage for the units.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Also fixed a latent footgun the — Claude (Opus 4.8) with Kris |
Follow-up promised in #1746 (Cache-Control/Vary hardening): the static plugin is where CDN caching matters most, and it previously passed no options to
send(), hardcodingCache-Control: public, max-age=0— every edge request revalidated at origin, with no way to mark hashed assets immutable.New static plugin options
Read live from the component config on each request (same pattern as
fallthrough):maxAge— freshness lifetime: a number of seconds or a duration string ('5m','1d'), emitted aspublic, max-age=<seconds>. Malformed values throw rather than silently degrading to 0.immutable— adds theimmutabledirective, for content-hashed assets.cacheControl— full override string (takes precedence overmaxAge/immutable, set via send'sheadersevent with send's own header suppressed), orfalseto suppressCache-Controlentirely.Deliberate scoping:
notFoundfallback keeps send's defaultmax-age=0— the right policy for SPA index fallbacks (a 200index.htmlfallback should revalidate).Notes
maxAge) is applied.Tests: 5-case integration suite (
integrationTests/components/static-cache-headers.test.ts) covering default, seconds, duration+immutable, override string, and suppression; existing static unit suite passes.Generated by Claude (Opus 4.8) with Kris.
Per-file overrides (
cacheOverrides)Follow-up folded in from Dawson's review: a glob → partial cache-options map layered over the top-level defaults, so content-hashed assets can stay
immutablewhileindex.htmlgets a short window /stale-while-revalidate.cacheControl-beats-maxAge/immutableprecedence as the top level.micromatch(thefilesengine) against the mount-relative URL path and the served file's basename, soindex.htmlalso targets the/directory-index serve.index.htmlfixture.