Skip to content

fix(server): answer conditional requests for static files and uploads with 304 - #653

Open
ngocht wants to merge 1 commit into
CoreBunch:mainfrom
ngocht:fix/static-conditional-requests
Open

ngocht wants to merge 1 commit into
CoreBunch:mainfrom
ngocht:fix/static-conditional-requests

Conversation

@ngocht

@ngocht ngocht commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Summary

serveStaticFile (server/static.ts) sends Cache-Control: no-cache for /uploads/* and for admin shell files outside /assets/. no-cache tells the browser to revalidate before reusing a cached copy. The responses carried no ETag or Last-Modified, though, so the browser had nothing to revalidate with and downloaded every file in full each time.

This hits the editor preview hardest: the preview iframe reloads every image and font on each rebuild. On one production site (75 pages, image-heavy), one preview load was about 117 requests and 3.7 MB, all of it unchanged media, and that site's /uploads traffic reached 64 MB before noon.

Changes:

  • Responses from serveStaticFile now carry a weak ETag (file size + mtime) and Last-Modified.
  • If-None-Match and If-Modified-Since on GET/HEAD get a bodiless 304 with the same Cache-Control, the validators and, for compressible files, Vary: accept-encoding. If-None-Match takes precedence, as RFC 9110 §13.1 requires, and handles lists and *.
  • The ETag is weak because one validator covers the identity, gzip and brotli bodies.
  • A media replace that keeps the same public_path (replaceMediaAssetBinary allows that) rewrites the file, which changes the mtime and with it the validator. Caching therefore stays correct without immutable on uploads.
  • The 304 is returned before the file is read, so a revalidation costs a stat, not a full read into memory.
  • /uploads/* responses still go through hardenUploadResponse, so a 304 keeps nosniff, the CSP and the plugin CORS headers.

Running on that production site as a patched build: the same image now answers a conditional request with 304, 0 bytes, where it used to send 194 KB.

Verification

  • bun run build — ran tsc -p tsconfig.app.json and tsc -p tsconfig.node.json (clean)

  • bun test — ran bun test src/__tests__/server: 1116 pass. Two new tests in staticAdmin.test.ts, both of which fail without the change:

    • Upload revalidation covers matching and listed If-None-Match, If-Modified-Since (older and current), If-None-Match precedence, the security headers on the 304, and a rewrite of the same path returning 200 with a new ETag.
    • Validators and a 304 on an immutable /assets/ file.

    The compressed path isn't exercised in tests: the test preload installs happy-dom's Request, which drops Accept-Encoding as a forbidden header.

  • bun run lint — eslint on the changed files (clean)

  • Docker/deployment check, if relevant

Checklist

  • Tests cover behavior changes.
  • Docs were updated when behavior, config, deployment, or public surfaces changed.
  • No compatibility shim was added for old pre-release behavior.
  • No secrets, local databases, uploads, or generated artifacts are included.

🤖 Generated with Claude Code

… with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ngocht
ngocht marked this pull request as ready for review October 15, 2026 09:20

This branch has not been deployed

No deployments
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.

1 participant