Skip to content

Conversation

riya-amemiya
Copy link

@riya-amemiya riya-amemiya commented Oct 17, 2025

This PR adds an enableFallback option that allows requests to fall through to other route handlers when static files are not found, instead of immediately returning a 404 error.

Problem: When using the static plugin with prefix: '/', other routes like .all('/api/auth/*') were being intercepted by the wildcard static route, causing them to return 404.

Solution: Added an enableFallback option that, when set to true, uses onError({ as: 'global' }) to handle 404s globally and serve static files only after other routes have been tried.

Usage Example:

const app = new Elysia()
    .all('/api/auth/*', () => ({ auth: 'success' }))
    .use(
        staticPlugin({
            prefix: '/',
            enableFallback: true
        })
    )

// Now /api/auth/* routes work correctly
// Static files are served only when no other route matches

Changes:

  • Added enableFallback option to StaticOptions interface (defaults to false)
  • Refactored static file serving logic into a serveStaticFile helper function to avoid code duplication
  • When enableFallback is true, uses global error handler instead of registering a wildcard route
  • Added test cases for the new functionality

Backward Compatibility: This change is fully backward compatible. The default behavior (enableFallback: false) remains unchanged.

P.S. This PR also includes:

  • Updated bun.lock (changes that appeared when running bun install on main branch)
  • Removed unused import type { Stats } from 'fs' from src/index.ts and src/utils.ts

Summary by CodeRabbit

  • New Features

    • Added an opt-in enableFallback (default: false) to allow requests to fall through when static files aren’t found.
    • Unified static file serving with improved caching (Cache-Control and ETag), proper index.html handling, and GET/HEAD-only static responses.
  • Tests

    • Added comprehensive tests covering fallback routing, interactions with API routes, .all() patterns, and directory traversal protection.
  • Chores

    • Removed an unused import.

Add enableFallback option that allows requests to fall through to other
route handlers when static files are not found, instead of returning 404.

This enables use cases where static file serving should not intercept
other routes like .all() or API endpoints.
Copy link

coderabbitai bot commented Oct 17, 2025

Walkthrough

Adds an enableFallback option to the static plugin; extracts static-serving into serveStaticFile (path resolution, index handling, fileCache, ETag, Cache-Control); switches error handling to either a global GET/HEAD fallback to next routes when enabled or preserves per-route 404 behavior when disabled; updates types and tests.

Changes

Cohort / File(s) Summary
Type definitions
src/types.ts
Added enableFallback?: boolean to StaticOptions (default false) to control fallback-to-next-route behavior.
Core implementation
src/index.ts
Added serveStaticFile helper (URI decode, strip prefix, index.html handling, fileCache, ETag, Cache-Control/ETag headers); introduced public enableFallback option; implemented conditional global onError fallback for GET/HEAD when enabled and preserved per-route NOT_FOUND handling when disabled.
Utilities cleanup
src/utils.ts
Removed unused import type { Stats } from 'fs'.
Tests
test/index.test.ts
Added tests covering fallback behavior (enabled/disabled), cross-route interactions, .all() route patterns, and directory traversal protection scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Plugin as StaticPlugin
    participant FS as FileSystem
    participant Next as NextRoute

    Client->>Plugin: HTTP request (method, path)
    Plugin->>FS: resolve path (decode URI, strip prefix), check index.html
    alt file exists
        FS-->>Plugin: file bytes + stats
        Plugin->>Plugin: compute/lookup ETag in fileCache
        Plugin-->>Client: 200 + body + Cache-Control + ETag
    else file missing
        alt enableFallback = true AND method is GET/HEAD
            Plugin->>Next: fall through to next route (silently)
            Note right of Next: next handler may respond or return 404
            Next-->>Client: next handler response
        else
            Plugin-->>Client: 404 / NotFound (per-route behavior)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through paths and checked each door,

Cached a nibble, tagged it with ETag lore.
If a file hides, I either pass or stay—
Fall back gentle, or guard the way.
A tiny rabbit cheers the routing play!

Pre-merge checks and finishing touches

❌ 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%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "feat: add enableFallback option to allow route fallback" directly and accurately describes the primary change in the changeset. The title clearly identifies that a new option called enableFallback is being added to enable route fallback functionality, which is the core objective of this PR. The title is concise, uses clear language without noise or vague terms, and provides sufficient specificity for a teammate reviewing the repository history to understand the main purpose of the change. The implementation details (serveStaticFile helper, global error handler, test coverage) do not need to be reflected in the title, and the title appropriately focuses on the user-facing feature being introduced.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

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

Copy link

@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 (3)
src/types.ts (1)

120-129: Document scope: enableFallback has no effect with alwaysStatic

Clarify in the JSDoc that enableFallback only applies when alwaysStatic is false (dynamic mode). Otherwise it’s ignored.

src/index.ts (1)

241-294: Preserve headers on 304 responses from serveStaticFile

When returning 304, include initialHeaders to match behavior elsewhere and avoid header regressions.

Apply this diff:

-            if (requestHeaders && etag && (await isCached(requestHeaders, etag, pathName)))
-                return new Response(null, { status: 304 })
+            if (requestHeaders && etag && (await isCached(requestHeaders, etag, pathName)))
+                return new Response(null, {
+                    status: 304,
+                    headers: isNotEmpty(initialHeaders) ? initialHeaders : undefined
+                })
test/index.test.ts (1)

446-498: Nice coverage; consider adding cache and method tests for fallback

  • Add a test asserting 304 with If-None-Match when enableFallback is true (to prevent regressions).
  • Add a test that POST to a static path still 404s when enableFallback is true.

Apply this diff to extend tests:

@@
     it('should fallback to other routes when enableFallback is true', async () => {
@@
         expect(staticRes.status).toBe(200)
     })
 
+    it('should return 304 with If-None-Match when enableFallback is true', async () => {
+        const app = new Elysia().use(
+            staticPlugin({
+                prefix: '/',
+                enableFallback: true
+            })
+        )
+        await app.modules
+        // First fetch to get current ETag
+        const first = await app.handle(req('/takodachi.png'))
+        const etag = first.headers.get('Etag')
+        expect(etag).toBeTruthy()
+        const r = req('/takodachi.png')
+        if (etag) r.headers.append('If-None-Match', etag)
+        const second = await app.handle(r)
+        expect(second.status).toBe(304)
+    })
+
+    it('should not serve static content for POST when enableFallback is true', async () => {
+        const app = new Elysia().use(
+            staticPlugin({
+                prefix: '/',
+                enableFallback: true
+            })
+        )
+        await app.modules
+        const postReq = new Request('http://localhost/takodachi.png', { method: 'POST' })
+        const res = await app.handle(postReq)
+        expect(res.status).toBe(404)
+    })
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 516491c and e033522.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • src/index.ts (2 hunks)
  • src/types.ts (1 hunks)
  • src/utils.ts (0 hunks)
  • test/index.test.ts (1 hunks)
💤 Files with no reviewable changes (1)
  • src/utils.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/index.ts (1)
src/utils.ts (6)
  • getFile (193-198)
  • isBun (6-6)
  • fileExists (70-77)
  • isNotEmpty (217-223)
  • generateETag (200-215)
  • isCached (137-189)
test/index.test.ts (2)
src/index.ts (1)
  • staticPlugin (19-350)
test/utils.ts (1)
  • req (1-1)
🔇 Additional comments (1)
src/index.ts (1)

32-34: API addition looks good

Default enableFallback = false matches the types and preserves backward compatibility.

- Forward request headers to serveStaticFile to enable 304 caching
- Restrict static file serving to GET/HEAD methods only
- Prevents unexpected behavior with POST/PUT/DELETE requests
Copy link

@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

♻️ Duplicate comments (1)
src/index.ts (1)

296-328: Previous feedback addressed: method guard + header forwarding added

The fallback now limits to GET/HEAD and forwards request headers into serveStaticFile, fixing the caching/method issues raised earlier. Nice.

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

241-294: 304 should include cache headers; also cache responses when useETag=false

  • 304 responses currently omit Cache-Control/ETag, reducing cache correctness.
  • When useETag=false, responses aren’t added to fileCache, causing unnecessary reads.

Apply this diff:

-            if (!useETag)
-                return new Response(
-                    file,
-                    isNotEmpty(initialHeaders)
-                        ? { headers: initialHeaders }
-                        : undefined
-                )
+            if (!useETag) {
+                const response = new Response(
+                    file,
+                    isNotEmpty(initialHeaders)
+                        ? { headers: initialHeaders }
+                        : undefined
+                )
+                fileCache.set(pathName, response)
+                return response.clone()
+            }
@@
-            if (requestHeaders && etag && (await isCached(requestHeaders, etag, pathName)))
-                return new Response(null, { status: 304 })
+            if (
+                requestHeaders &&
+                etag &&
+                (await isCached(requestHeaders, etag, pathName))
+            )
+                return new Response(null, {
+                    status: 304,
+                    headers: Object.assign(
+                        {
+                            'Cache-Control': maxAge
+                                ? `${directive}, max-age=${maxAge}`
+                                : directive
+                        },
+                        initialHeaders,
+                        etag ? { Etag: etag } : {}
+                    )
+                })

296-304: Optional: Avoid sending a body on HEAD requests

To better match semantics, return a no-body response for HEAD (same status/headers as GET path).

Would you like a small patch that wraps serveStaticFile’s response for HEAD by stripping the body?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e033522 and a59eae1.

📒 Files selected for processing (1)
  • src/index.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (1)
src/utils.ts (6)
  • getFile (193-198)
  • isBun (6-6)
  • fileExists (70-77)
  • isNotEmpty (217-223)
  • generateETag (200-215)
  • isCached (137-189)
🔇 Additional comments (1)
src/index.ts (1)

32-34: API option looks good

Adding enableFallback with default false preserves backward compatibility. LGTM.

- Use path.resolve() to get absolute paths and enforce containment
- Check that resolved paths stay within assetsDir boundary
- Convert to relative paths when checking ignorePatterns
- Apply to both enableFallback and non-fallback modes
- Add tests to verify directory traversal protection
Copy link

@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)
test/index.test.ts (1)

519-541: Add HEAD and 304 cache tests for fallback path

To lock in method guard and caching:

  • Assert HEAD on static path returns 200 with no body.
  • Assert 304 is returned when If-None-Match/If-Modified-Since are present under enableFallback.
    Also consider a case with enableFallback: true and prefix: '/public' where '/takodachi.png' is 404 but '/public/takodachi.png' is 200, to verify prefix strip logic.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a59eae1 and 918323b.

📒 Files selected for processing (2)
  • src/index.ts (2 hunks)
  • test/index.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/index.ts (1)
src/utils.ts (6)
  • getFile (193-198)
  • isBun (6-6)
  • fileExists (70-77)
  • isNotEmpty (217-223)
  • generateETag (200-215)
  • isCached (137-189)
test/index.test.ts (2)
src/index.ts (1)
  • staticPlugin (19-375)
test/utils.ts (1)
  • req (1-1)
🔇 Additional comments (5)
test/index.test.ts (3)

446-463: Fallback behavior test (enableFallback: true) looks good

Covers the core regression: API routes win; static file served at root. Nice.


484-498: .all() interop test is solid

Confirms route pattern precedence under fallback. Good coverage.


500-517: Traversal protection (default mode) verified

Good set of cases, including encoded “..”. Matches path containment checks.

src/index.ts (2)

296-337: Fallback handler: method guard, prefix strip, traversal, and header forwarding — LGTM

GET/HEAD check prevents serving on mutating methods; prefix handling and containment checks look right; request headers are forwarded to preserve 304 behavior.


338-371: Route-based handler: traversal enforcement and shared serving path — LGTM

Mirrors fallback logic with NotFoundError semantics. Header passthrough enables 304s. Looks correct.

- Use relative path for shouldIgnore check instead of absolute path
- Include initialHeaders in 304 responses for consistency
- Use actual file path (index.html) for isCached check instead of directory path
Copy link

@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: 0

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

241-304: Cache key should use targetPath; include standard ETag on 304 and 200

When serving a directory index.html, you compute targetPath but still cache under pathName. Cache under targetPath to ensure future hits and consistency. Also, return ETag on 304 and use the standard header casing "ETag" in success responses.

Apply this diff:

@@
-            const etag = await generateETag(file)
-            if (requestHeaders && etag && (await isCached(requestHeaders, etag, targetPath)))
-                return new Response(null, {
-                    status: 304,
-                    headers: isNotEmpty(initialHeaders) ? initialHeaders : undefined
-                })
+            const etag = await generateETag(file)
+            if (requestHeaders && etag && (await isCached(requestHeaders, etag, targetPath)))
+                return new Response(null, {
+                    status: 304,
+                    headers: Object.assign({}, initialHeaders, etag ? { ETag: etag } : {})
+                })
@@
-            const response = new Response(file, {
+            const response = new Response(file, {
                 headers: Object.assign(
                     {
                         'Cache-Control': maxAge
                             ? `${directive}, max-age=${maxAge}`
                             : directive
                     },
                     initialHeaders,
-                    etag ? { Etag: etag } : {}
+                    etag ? { ETag: etag } : {}
                 )
             })
@@
-            fileCache.set(pathName, response)
+            fileCache.set(targetPath, response)
             return response.clone()

Optional: also add Last-Modified from fileStat to 200 responses to enable If-Modified-Since flows.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 918323b and 86dc6f3.

📒 Files selected for processing (1)
  • src/index.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (1)
src/utils.ts (6)
  • getFile (193-198)
  • isBun (6-6)
  • fileExists (70-77)
  • isNotEmpty (217-223)
  • generateETag (200-215)
  • isCached (137-189)
🔇 Additional comments (3)
src/index.ts (3)

32-34: Option addition looks good

Defaults preserve backward compatibility; no concerns.


306-347: Global fallback path: method guard + header forwarding LGTM

GET/HEAD restriction, prefix gating, traversal protection, and forwarding headers to honor 304s look correct.


348-381: The review comment is based on incorrect assumptions and should be disregarded.

Elysia (v1.4+, which this project uses) automatically normalizes HTTP header names to lowercase in ctx.headers. The isCached function correctly expects lowercase keys ('if-none-match', 'cache-control', 'if-modified-since'), and it receives them already normalized—no additional normalization needed.

Additionally, Elysia v1.4+ automatically handles HEAD requests for GET routes; a separate HEAD route handler is not required.

The code at lines 348–381 works correctly as-is.

Likely an incorrect or invalid review comment.

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