Skip to content

fix: #46 return 304 for cached files with matching etag#77

Open
JoshuaNam wants to merge 3 commits into
elysiajs:mainfrom
JoshuaNam:fix/head-handler
Open

fix: #46 return 304 for cached files with matching etag#77
JoshuaNam wants to merge 3 commits into
elysiajs:mainfrom
JoshuaNam:fix/head-handler

Conversation

@JoshuaNam
Copy link
Copy Markdown

@JoshuaNam JoshuaNam commented Apr 28, 2026

Closes #46.

What this PR does

Fixes a caching bug in the static plugin. The bug causes browsers and CDNs
to re-download files they already have.

What we expected to find

The issue title says "HEAD request 404s for all static files." We started
by trying to reproduce that.

What we actually found

On Elysia 1.4.28, HEAD requests don't 404 anymore. Elysia itself now
handles HEAD requests automatically. It runs the GET handler and strips
the body. So the surface-level symptom in the issue title is already gone.

But the production problem the issue actually describes is still happening.
Caches still re-download files they already have. We dug into why. The
real bug turned out to be in how the plugin handles repeat requests.

The actual bug

When a browser requests a file it already has cached, it sends an
If-None-Match header with the file's etag. The server is supposed to
check if its version matches what the browser already has. If yes, the
server replies with 304 Not Modified. The browser then uses its cached
copy.

The plugin has two layers of caching that run in this order on every
request:

  1. An in-memory cache of recently-served responses. This avoids re-reading
    the file from disk.
  2. The "do you already have this?" check that produces 304.

Here is the bug. On the second request for the same file, layer 1 returned
the saved 200 response immediately. Layer 2 never ran. The etag check that
would have returned 304 was unreachable. Browsers and CDNs got a fresh 200
with the full file body every time, even when they already had it.

HEAD requests inherited the same 200. This is because Elysia derives HEAD
responses from GET responses. Once GET is wrong, HEAD is wrong.

The fix

Made layer 1 aware of conditional requests. When the in-memory cache hits,
we now do this:

  1. Read the etag from the cached response's headers.
  2. Compare it to the request's If-None-Match header.
  3. Return 304 if they match.
  4. Return the cached 200 if they don't.

10 lines of code. The in-memory cache still does its job. We just stopped
it from skipping the correctness check.

This also fixes HEAD requests automatically. HEAD inherits GET's status.

Tests

Added two tests in test/index.test.ts:

  • GET with matching If-None-Match returns 304
  • HEAD with matching If-None-Match returns 304

The HEAD test exists to catch any future Elysia change that breaks the
GET-to-HEAD inheritance.

Verification

Tests in the PR (Bun):

  • 30/30 pass (28 existing plus 2 new for conditional GET and HEAD)

Manual cross-runtime verification (not in the PR):

  • Node CJS smoke (test/node/cjs/index.js, pre-existing): pass
  • Node ESM smoke (test/node/esm/index.js, pre-existing): pass
  • Deno: ran a local smoke confirming GET and HEAD with If-None-Match
    return 304 against the built dist/ artifact

The PR adds Bun tests only. The Node and Deno smoke runs above are
local manual checks; happy to add Node test coverage of the 304
behavior in a follow-up if useful.

Something else we noticed but didn't fix

The eager-mode handler (handleCache, around lines 162 to 189) has a
similar but separate problem with If-Modified-Since requests. It also
checks the in-memory cache before checking whether the client's copy is
still fresh. We left it alone to keep this PR focused on the reproducible
bug from #46. Happy to file a separate issue if useful.

Summary by CodeRabbit

  • New Features

    • ETag-based conditional caching added so dynamic requests can return 304 Not Modified when content is unchanged.
    • Cache behavior updated to validate stored ETags before serving cached responses.
  • Bug Fixes

    • Ensure cached responses perform ETag checks to prevent serving stale content.
  • Tests

    • Added tests covering GET/HEAD conditional requests, 304 responses, and cache-warmed scenarios.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 731713ed-88c4-4d0e-807e-622797846c02

📥 Commits

Reviewing files that changed from the base of the PR and between 83cefcd and 3434b95.

📒 Files selected for processing (1)
  • test/index.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/index.test.ts

Walkthrough

The dynamic (non-alwaysStatic) request path now validates conditional caching against the cached Response ETag before serving from fileCache. On cache hits the code extracts the cached etag, runs isCached(requestHeaders, cachedEtag, pathName), and returns a 304 with empty body when fresh; otherwise it returns the cached response clone.

Changes

Cohort / File(s) Summary
Conditional Caching Implementation
src/index.ts
Modify cache hit handling for dynamic (lazy) paths: extract etag from cached Response, call isCached(requestHeaders, cachedEtag, pathName), return a 304 with empty body when fresh, otherwise return cloned cached response.
Conditional Caching Test Coverage
test/index.test.ts
Add tests for conditional requests on lazy/static paths: verify GET and HEAD return 304 with empty body when If-None-Match matches cached etag, verify stale/mismatched If-None-Match returns 200 with content, and regression tests for cache-warmed scenarios.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Server as Server
  participant Cache as fileCache
  participant Checker as isCached()

  Client->>Server: GET/HEAD with headers (maybe If-None-Match)
  Server->>Cache: lookup(pathName)
  Cache-->>Server: cached Response (with ETag)
  Server->>Server: extract cachedEtag from Response
  Server->>Checker: isCached(requestHeaders, cachedEtag, pathName)
  alt fresh (matches)
    Checker-->>Server: true
    Server-->>Client: 304 Not Modified (empty body)
  else stale/mismatch
    Checker-->>Server: false
    Server-->>Client: 200 OK (cached Response clone with body/headers)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A tiny ETag patrol now guards the cache,
If-None-Match met, it sends a silent 304, sugoi
(^▽^)
Else it serves the goods with a satisfied grin,
Quick checks, less bandwidth — neat lil' win ♡

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: returning 304 for cached files with matching etag, which directly addresses issue #46.
Linked Issues check ✅ Passed The PR successfully implements the core requirement from issue #46: HEAD requests now return proper conditional responses (304 with matching etag) instead of 404, matching GET behavior.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the conditional caching bug for dynamic paths; no unrelated modifications detected in the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/index.ts (1)

314-323: Return validator/cache headers on cached 304 responses, okay smarty~ ( ̄▽ ̄)♡

Line 320 returns a bare 304. Revalidation works, but include cached validator metadata (Etag, optionally Cache-Control) so intermediaries stay fully informed.

♻️ Suggested patch
                 if (cache) {
                     const cachedEtag = cache.headers.get('etag')
                     if (
                         cachedEtag &&
                         (await isCached(requestHeaders, cachedEtag, pathName))
                     ) {
-                        return new Response(null, { status: 304 })
+                        const headers = new Headers(
+                            isNotEmpty(initialHeaders) ? initialHeaders : undefined
+                        )
+                        headers.set('Etag', cachedEtag)
+
+                        const cacheControl = cache.headers.get('cache-control')
+                        if (cacheControl && !headers.has('Cache-Control'))
+                            headers.set('Cache-Control', cacheControl)
+
+                        return new Response(null, { status: 304, headers })
                     }
                     return cache.clone()
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 314 - 323, The 304 response currently returns no
validator headers; update the branch that returns new Response(null, { status:
304 }) to include validator metadata from the cached response: read cachedEtag
(already fetched) and the cachedCacheControl
(cache.headers.get('cache-control')) and set them on the 304 response headers
(e.g., 'ETag': cachedEtag and, if present, 'Cache-Control': cachedCacheControl).
Keep using isCached(requestHeaders, cachedEtag, pathName) for validation and
only return cache.clone() when not a 304.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/index.test.ts`:
- Around line 464-486: Tests rely on staticPlugin() running in lazy mode but
NODE_ENV can change its default; update both test instances that call new
Elysia().use(staticPlugin()) (the one checking 304 for GET and the one for HEAD)
to pass an explicit option to pin lazy mode (e.g., staticPlugin({ lazy: true }))
so the plugin behavior is deterministic regardless of environment.

---

Nitpick comments:
In `@src/index.ts`:
- Around line 314-323: The 304 response currently returns no validator headers;
update the branch that returns new Response(null, { status: 304 }) to include
validator metadata from the cached response: read cachedEtag (already fetched)
and the cachedCacheControl (cache.headers.get('cache-control')) and set them on
the 304 response headers (e.g., 'ETag': cachedEtag and, if present,
'Cache-Control': cachedCacheControl). Keep using isCached(requestHeaders,
cachedEtag, pathName) for validation and only return cache.clone() when not a
304.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3d13deaa-f053-4fce-8e97-5a37d55e1694

📥 Commits

Reviewing files that changed from the base of the PR and between 62aea0c and 83cefcd.

📒 Files selected for processing (2)
  • src/index.ts
  • test/index.test.ts

Comment thread test/index.test.ts Outdated
@JoshuaNam JoshuaNam marked this pull request as draft April 28, 2026 17:12
@cirex-web
Copy link
Copy Markdown
Contributor

Here is the bug. On the second request for the same file, layer 1 returned
the saved 200 response immediately. Layer 2 never ran. The etag check that
would have returned 304 was unreachable. Browsers and CDNs got a fresh 200
with the full file body every time, even when they already had it.

If what you're saying is true, this is also resolved by #79 :P

@JoshuaNam JoshuaNam marked this pull request as ready for review April 30, 2026 02:05
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.

HEAD request 404s for all static files

3 participants