Skip to content

fix(server): serve SPA fallback index.html with 200 instead of 404 - #9

Open
itwormz wants to merge 1 commit into
moghtech:mainfrom
itwormz:fix/server-spa-fallback-404
Open

fix(server): serve SPA fallback index.html with 200 instead of 404#9
itwormz wants to merge 1 commit into
moghtech:mainfrom
itwormz:fix/server-spa-fallback-404

Conversation

@itwormz

@itwormz itwormz commented Aug 20, 2026

Copy link
Copy Markdown

Summary

mogh_server::ui::serve_static_ui() returns HTTP 404 (with the index.html
body) for any SPA route that does not match a physical file — e.g. /login,
/stacks/<id>. This PR makes the SPA fallback respond with 200 as intended.

Root cause

The fallback router is built correctly and returns 200:

let index_router = Router::new().fallback_service(ServeFile::new(&index));

but it is attached via ServeDir::not_found_service():

ServeDir::new(directory).not_found_service(index)   // crates/server/src/ui.rs

tower-http's ServeDir::not_found_service is implemented as:

pub fn not_found_service<S>(self, service: S) -> ServeDir<SetStatus<S>> {
    self.fallback(SetStatus::new(service, StatusCode::NOT_FOUND))
}

i.e. it unconditionally wraps the fallback in a SetStatus(_, 404) layer, which
overrides the fallback's own 200 status. Every request that misses a
physical file therefore comes back as 404 + index.html.

This breaks single-page apps: client-side routing never works for any
non-root URL (full page load on /login, refresh on a detail page, deep links,
share links). Observed downstream in
moghtech/komodo#886:
Safari shows "The server responded with 404" on /login while the HTML body is
correct.

Fix

Use ServeDir::fallback() instead, which does not rewrite the response status:

-  ServeDir::new(directory).not_found_service(index)
+  ServeDir::new(directory).fallback(index)

Applied in both branches of serve_static_ui (force_no_cache and ETag
paths). The SetStatus import and the ServeDir<SetStatus<Router>> return
type become ServeDir<Router>.

Behavior

Request Before After
GET / 200 + index.html 200 + index.html (unchanged)
GET /login, GET /stacks/<id> 404 + index.html 200 + index.html
Existing static file 200 + file 200 + file (unchanged)
API 404s unaffected (routes match before fallback) unaffected

Notes:

  • ETag (SHA-256 of index.html) and force_no_cache
    (Cache-Control: no-cache) behavior is preserved.
  • Trade-off (standard SPA fallback behavior, same as Vite/CRA dev servers): a
    request for a non-existent static asset now returns 200 + index.html
    instead of a bare 404. The SPA shell handles this via its 404 route.

Public API change

serve_static_ui's return type changes from ServeDir<SetStatus<Router>> to
ServeDir<Router>. Consumers that use the service as-is (e.g.
Router::fallback_service(serve_static_ui(...)) — how komodo consumes it) are
unaffected; only code that names the concrete type explicitly would need a
one-line update.

Tests

Added #[cfg(test)] unit tests in ui.rs using tower::ServiceExt::oneshot
(Router::oneshot was removed in axum 0.8). The service is wrapped in an axum
Router fallback — exactly the way downstream consumers (komodo) attach it:

  • GET / → 200, body equals index.html, ETag present
  • GET /login → 200 + index.html body (fails with 404 before this PR)
  • GET /stacks/abc → 200 + index.html body (fails with 404 before this PR)
  • GET /asset.txt (existing file) → 200 + file contents
  • force_no_cache = true: GET /login → 200 + Cache-Control: no-cache
$ cargo test -p mogh_server   # before fix
running 5 tests
test ui::tests::root_returns_index_html_with_etag ... ok
test ui::tests::existing_static_file_is_served ... ok
test ui::tests::deep_spa_route_returns_index_html_with_200 ... FAILED
test ui::tests::force_no_cache_spa_route_returns_200_with_no_cache ... FAILED
test ui::tests::spa_route_returns_index_html_with_200 ... FAILED

---- ui::tests::spa_route_returns_index_html_with_200 stdout ----
panicked at crates/server/src/ui.rs:142:5:
assertion `left == right` failed
  left: 404
 right: 200

test result: FAILED. 2 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out

$ cargo test -p mogh_server   # after fix
running 5 tests
test ui::tests::root_returns_index_html_with_etag ... ok
test ui::tests::force_no_cache_spa_route_returns_200_with_no_cache ... ok
test ui::tests::existing_static_file_is_served ... ok
test ui::tests::spa_route_returns_index_html_with_200 ... ok
test ui::tests::deep_spa_route_returns_index_html_with_200 ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

(cargo fmt --all -- --check and cargo build are clean. This repo has no CI
workflow, so results are attached here.)

Release request

Could the version be bumped to 1.5.1 (or 1.6.0, if the public return-type
change should be treated as minor) and published to crates.io after merge?

Fixes moghtech/komodo#886 (komodo; cross-repo link, will close on that side).

serve_static_ui() attached the SPA fallback router via ServeDir::not_found_service(), which tower-http implements as fallback(SetStatus(_, 404)) — the SetStatus layer unconditionally overrode the fallback's own 200, so every SPA route that did not match a physical file (e.g. /login, /stacks/<id>) came back as 404 + index.html. This broke client-side routing on full page load, refresh, deep and share links (moghtech/komodo#886).

Use ServeDir::fallback() instead, which does not rewrite the response status. ETag (SHA-256 of index.html) and force_no_cache (Cache-Control: no-cache) behavior are preserved. Public return type changes from ServeDir<SetStatus<Router>> to ServeDir<Router>; consumers attaching the service as-is (Router::fallback_service, e.g. komodo) are unaffected.

Add 5 regression tests in ui.rs (tower::ServiceExt::oneshot, axum 0.8) covering GET / (200 + ETag), GET /login and GET /stacks/abc (200 + index.html — failed with 404 before this fix), GET /asset.txt (200 + file contents) and force_no_cache=true (200 + Cache-Control: no-cache).
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.

Odd behavior: 404 on refresh or session timeout

1 participant