Skip to content

Configurable cache headers for the static plugin (maxAge, immutable, cacheControl)#1748

Merged
kriszyp merged 6 commits into
mainfrom
kris/static-cache-headers
Jul 11, 2026
Merged

Configurable cache headers for the static plugin (maxAge, immutable, cacheControl)#1748
kriszyp merged 6 commits into
mainfrom
kris/static-cache-headers

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 9, 2026

Copy link
Copy Markdown
Member

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(), hardcoding Cache-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 as public, max-age=<seconds>. Malformed values throw rather than silently degrading to 0.
  • immutable — adds the immutable directive, for content-hashed assets.
  • cacheControl — full override string (takes precedence over maxAge/immutable, set via send's headers event with send's own header suppressed), or false to suppress Cache-Control entirely.
static:
  files: 'web/**'
  maxAge: 1d
  immutable: true

Deliberate scoping:

  • The notFound fallback keeps send's default max-age=0 — the right policy for SPA index fallbacks (a 200 index.html fallback should revalidate).
  • The static handler runs before authentication by design, so its files are public by construction; documented in the plugin docstring.

Notes

  • While writing the integration test, found a pre-existing OptionsWatcher race: a config write landing milliseconds after the previous one can lose its chokidar event and never apply — filed as OptionsWatcher can lose a config write that lands milliseconds after the previous one #1747. The test carries a resilience workaround (periodic config re-send while polling) so it exercises the header options, not the watcher.
  • Cross-model review (Gemini + Harper domain pass) ran clean: no blockers; the one accepted suggestion (loud failure on malformed 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 immutable while index.html gets a short window / stale-while-revalidate.

static:
  files: 'web/**'
  maxAge: 1y
  immutable: true
  cacheOverrides:
    'index.html': { cacheControl: 'public, max-age=0, stale-while-revalidate=60' }
    '*.css':      { maxAge: 60 }   # partial — immutable inherited
  • First match wins (config order); each entry is a partial (present keys replace, absent inherit), same cacheControl-beats-maxAge/immutable precedence as the top level.
  • Patterns matched via micromatch (the files engine) 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. Integration suite gains a 6th case + an index.html fixture.

Kris Zyp and others added 2 commits July 9, 2026 17:51
…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>
gemini-code-assist[bot]

This comment was marked as resolved.

Comment thread server/static.ts
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Prior finding resolved: 1y is now a recognised suffix in convertToMS (commit 019254fd3).

Kris Zyp and others added 2 commits July 9, 2026 18:20
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>
@kriszyp

kriszyp commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Companion docs PR: HarperFast/documentation#578 (static plugin cache options section; shared with #1746's directive/floor docs). — Claude (Opus 4.8)

@kriszyp kriszyp requested review from Ethan-Arrowood and dawsontoth and removed request for DavidCockerill and kylebernhardy July 10, 2026 12:14
@dawsontoth

Copy link
Copy Markdown
Contributor

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 Ethan-Arrowood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@kriszyp

kriszyp commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

@dawsontoth Added the per-file override map you suggested (and @Ethan-Arrowood +1'd) directly to this PR — new cacheOverrides option, a glob → partial-options map layered over the top-level defaults:

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
  • First match wins (config order), so you control specific-before-general.
  • Each entry is a partial: keys present replace the default, absent keys inherit — same cacheControl-beats-maxAge/immutable precedence as the top level.
  • Patterns use micromatch (the same engine as files) and match against both the mount-relative URL path and the served file's basename, so index.html also targets the / directory-index serve — exactly your "index served live / small window / SWR" case.

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 stale-while-revalidate note (Azure doesn't honor it, CloudFront/CloudFlare do) is a good call-out for the docs — I'll fold that guidance into the companion docs PR. Thanks for the nudge!

— Claude (Opus 4.8) with Kris

Comment thread server/static.ts
* ```yaml
* static:
* files: 'web/**'
* maxAge: 1y

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* 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>
@kriszyp

kriszyp commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Also fixed a latent footgun the 1y examples exposed: convertToMS had a M (30-day month) unit but no year, so maxAge: 1y silently resolved to 1 second (parseFloat('1y') → 1, no matching case). Added y/Y = 365 days (019254fd3), with a note on the case-sensitive M(month)/m(minute) distinction and unit coverage for the duration units.

— Claude (Opus 4.8) with Kris

@kriszyp kriszyp merged commit 8bab798 into main Jul 11, 2026
53 checks passed
@kriszyp kriszyp deleted the kris/static-cache-headers branch July 11, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants